Update environment configuration and enhance user management features

- Changed VITE_SUPABASE_URL in .env.example for deployment consistency.
- Added new user management functionality to reset user passwords in UserManagement component.
- Updated supabase.ts to include first and last name fields in user profiles and added password reset functionality.
- Enhanced DashboardLayout to include a user profile view and improved user display in TopNavbar.
- Updated seed.sql to create additional users with roles for testing purposes.
This commit is contained in:
salirezav
2025-09-22 11:20:15 -04:00
parent 0ba385eebc
commit 44c8c3f6dd
23 changed files with 1398 additions and 31 deletions

View File

@@ -31,3 +31,5 @@ export function CameraRoute({ cameraNumber }: CameraRouteProps) {

View File

@@ -8,6 +8,7 @@ import { DataEntry } from './DataEntry'
import { VisionSystem } from './VisionSystem'
import { Scheduling } from './Scheduling'
import { VideoStreamingPage } from '../features/video-streaming'
import { UserProfile } from './UserProfile'
import { userManagement, type User } from '../lib/supabase'
interface DashboardLayoutProps {
@@ -25,7 +26,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
const [isHovered, setIsHovered] = useState(false)
// Valid dashboard views
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library']
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library', 'profile']
// Save current view to localStorage
const saveCurrentView = (view: string) => {
@@ -194,6 +195,8 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
return <Scheduling user={user} currentRoute={currentRoute} />
case 'video-library':
return <VideoStreamingPage />
case 'profile':
return <UserProfile user={user} />
default:
return <DashboardHome user={user} />
}
@@ -274,6 +277,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
currentView={currentView}
onToggleSidebar={handleToggleSidebar}
isSidebarOpen={isMobileOpen}
onNavigateToProfile={() => handleViewChange('profile')}
/>
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
{renderCurrentView()}

View File

@@ -140,3 +140,5 @@ export function LiveCameraView({ cameraName }: LiveCameraViewProps) {

View File

@@ -7,6 +7,7 @@ interface TopNavbarProps {
currentView?: string
onToggleSidebar?: () => void
isSidebarOpen?: boolean
onNavigateToProfile?: () => void
}
export function TopNavbar({
@@ -14,7 +15,8 @@ export function TopNavbar({
onLogout,
currentView = 'dashboard',
onToggleSidebar,
isSidebarOpen = false
isSidebarOpen = false,
onNavigateToProfile
}: TopNavbarProps) {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
@@ -145,11 +147,16 @@ export function TopNavbar({
>
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{user.email.charAt(0).toUpperCase()}
{(user.first_name || user.email).charAt(0).toUpperCase()}
</div>
</span>
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
<span className="block mr-1 font-medium text-theme-sm">
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.email.split('@')[0]
}
</span>
<svg
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
}`}
@@ -174,7 +181,10 @@ export function TopNavbar({
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark z-50">
<div>
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
{user.email.split('@')[0]}
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.email.split('@')[0]
}
</span>
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
{user.email}
@@ -183,7 +193,13 @@ export function TopNavbar({
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
<li>
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
<button
onClick={() => {
setIsUserMenuOpen(false)
onNavigateToProfile?.()
}}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300 w-full text-left"
>
<svg
className="fill-gray-500 dark:fill-gray-400"
width="24"
@@ -200,7 +216,7 @@ export function TopNavbar({
/>
</svg>
Profile
</div>
</button>
</li>
<li>
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">

View File

@@ -81,6 +81,20 @@ export function UserManagement() {
}
}
const handlePasswordReset = async (userId: string, userEmail: string) => {
if (!confirm(`Are you sure you want to reset the password for ${userEmail}? The password will be reset to "password123".`)) {
return
}
try {
const result = await userManagement.resetUserPassword(userId)
alert(`Password reset successfully for ${result.email}. New password: ${result.new_password}`)
} catch (err) {
console.error('Password reset error:', err)
alert('Failed to reset user password')
}
}
const handleUserCreated = (newUser: User) => {
setUsers([...users, newUser])
setShowCreateModal(false)
@@ -260,6 +274,7 @@ export function UserManagement() {
onStatusToggle={handleStatusToggle}
onRoleUpdate={handleRoleUpdate}
onEmailUpdate={handleEmailUpdate}
onPasswordReset={handlePasswordReset}
getRoleBadgeColor={getRoleBadgeColor}
/>
))}
@@ -290,6 +305,7 @@ interface UserRowProps {
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
onEmailUpdate: (userId: string, newEmail: string) => void
onPasswordReset: (userId: string, userEmail: string) => void
getRoleBadgeColor: (role: string) => string
}
@@ -302,6 +318,7 @@ function UserRow({
onStatusToggle,
onRoleUpdate,
onEmailUpdate,
onPasswordReset,
getRoleBadgeColor
}: UserRowProps) {
const [editEmail, setEditEmail] = useState(user.email)
@@ -381,8 +398,8 @@ function UserRow({
<button
onClick={() => onStatusToggle(user.id, user.status)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-red-100 text-red-800 hover:bg-red-200'
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-red-100 text-red-800 hover:bg-red-200'
}`}
>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
@@ -408,12 +425,26 @@ function UserRow({
</button>
</div>
) : (
<button
onClick={onEdit}
className="text-blue-600 hover:text-blue-900"
>
Edit
</button>
<div className="flex space-x-2">
<button
onClick={onEdit}
className="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
title="Edit user"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onPasswordReset(user.id, user.email)}
className="p-1 text-orange-600 hover:text-orange-900 hover:bg-orange-50 rounded"
title="Reset password to 'password123'"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
</div>
)}
</td>
</tr>

View File

@@ -0,0 +1,312 @@
import { useState } from 'react'
import { userManagement, type User } from '../lib/supabase'
interface UserProfileProps {
user: User
}
interface ChangePasswordModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
}
function ChangePasswordModal({ isOpen, onClose, onSuccess }: ChangePasswordModalProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const handlePasswordChange = async () => {
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError('All fields are required')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters long')
return
}
try {
setIsLoading(true)
setError(null)
const result = await userManagement.changeUserPassword(currentPassword, newPassword)
alert('Password changed successfully!')
onSuccess()
onClose()
// Clear form
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (err: any) {
console.error('Password change error:', err)
if (err.message?.includes('Current password is incorrect')) {
setError('Current password is incorrect')
} else {
setError('Failed to change password. Please try again.')
}
} finally {
setIsLoading(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Change Password</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Current Password
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter your current password"
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter your new password"
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Confirm your new password"
disabled={isLoading}
/>
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handlePasswordChange}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</div>
</div>
</div>
)
}
export function UserProfile({ user }: UserProfileProps) {
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false)
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'conductor':
return 'bg-blue-100 text-blue-800'
case 'analyst':
return 'bg-green-100 text-green-800'
case 'data recorder':
return 'bg-purple-100 text-purple-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Profile</h1>
<p className="text-sm text-gray-600">Manage your account information and settings</p>
</div>
<div className="flex items-center space-x-3">
<div className="w-16 h-16 bg-brand-500 rounded-full flex items-center justify-center text-white text-xl font-medium">
{(user.first_name || user.email).charAt(0).toUpperCase()}
</div>
</div>
</div>
</div>
{/* Profile Information */}
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Basic Information</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md border">
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: 'Name not set'
}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md border">
{user.email}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account Status
</label>
<div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Member Since
</label>
<div className="text-sm text-gray-900">
{formatDate(user.created_at)}
</div>
</div>
</div>
{/* Roles and Permissions */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Roles & Permissions</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Assigned Roles
</label>
<div className="flex flex-wrap gap-2">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Updated
</label>
<div className="text-sm text-gray-900">
{formatDate(user.updated_at)}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">Account Actions</h3>
<div className="flex flex-wrap gap-4">
<button
onClick={() => setShowChangePasswordModal(true)}
className="inline-flex items-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Change Password
</button>
</div>
</div>
</div>
</div>
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
onSuccess={() => {
// Could add success notification here
}}
/>
</div>
)
}

View File

@@ -19,6 +19,8 @@ export type ResultsStatus = 'valid' | 'invalid'
export interface User {
id: string
email: string
first_name?: string
last_name?: string
roles: RoleName[]
status: UserStatus
created_at: string
@@ -257,6 +259,8 @@ export const userManagement = {
.select(`
id,
email,
first_name,
last_name,
status,
created_at,
updated_at
@@ -373,6 +377,8 @@ export const userManagement = {
.select(`
id,
email,
first_name,
last_name,
status,
created_at,
updated_at
@@ -397,6 +403,27 @@ export const userManagement = {
...profile,
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
}
},
// Reset user password to default (admin only)
async resetUserPassword(userId: string): Promise<{ user_id: string; email: string; new_password: string; reset_at: string }> {
const { data, error } = await supabase.rpc('reset_user_password', {
target_user_id: userId
})
if (error) throw error
return data
},
// Change user password (user can only change their own password)
async changeUserPassword(currentPassword: string, newPassword: string): Promise<{ user_id: string; email: string; password_changed_at: string }> {
const { data, error } = await supabase.rpc('change_user_password', {
current_password: currentPassword,
new_password: newPassword
})
if (error) throw error
return data
}
}