can successfully add new users

This commit is contained in:
Alireza Vaezi
2025-07-20 11:33:51 -04:00
parent cfa8a0de81
commit b5848d9cba
10 changed files with 1720 additions and 207 deletions

View File

@@ -0,0 +1,243 @@
import { useState } from 'react'
import { userManagement, type User, type Role, type RoleName, type CreateUserRequest } from '../lib/supabase'
interface CreateUserModalProps {
roles: Role[]
onClose: () => void
onUserCreated: (user: User) => void
}
export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserModalProps) {
const [formData, setFormData] = useState<CreateUserRequest>({
email: '',
roles: [],
tempPassword: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const handleRoleToggle = (roleName: RoleName) => {
if (formData.roles.includes(roleName)) {
setFormData({
...formData,
roles: formData.roles.filter(r => r !== roleName)
})
} else {
setFormData({
...formData,
roles: [...formData.roles, roleName]
})
}
}
const generatePassword = () => {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
setFormData({ ...formData, tempPassword: result })
setGeneratedPassword(result)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
// Validation
if (!formData.email) {
setError('Email is required')
return
}
if (formData.roles.length === 0) {
setError('At least one role must be selected')
return
}
if (!formData.tempPassword) {
setError('Password is required')
return
}
try {
setLoading(true)
const response = await userManagement.createUser(formData)
// Create user object for the parent component
const newUser: User = {
id: response.user_id,
email: response.email,
roles: response.roles,
status: response.status,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
onUserCreated(newUser)
// Show success message with password
alert(`User created successfully!\n\nEmail: ${response.email}\nTemporary Password: ${response.temp_password}\n\nPlease save this password as it won't be shown again.`)
} catch (err: any) {
setError(err.message || 'Failed to create user')
console.error('Create user error:', err)
} finally {
setLoading(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'
}
}
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
{/* Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Create New User</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Address
</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="user@example.com"
required
/>
</div>
{/* Roles */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Roles (select at least one)
</label>
<div className="space-y-2">
{roles.map((role) => (
<label key={role.id} className="flex items-center">
<input
type="checkbox"
checked={formData.roles.includes(role.name)}
onChange={() => handleRoleToggle(role.name)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
<span className="ml-2 text-xs text-gray-500">- {role.description}</span>
</label>
))}
</div>
{/* Selected roles preview */}
{formData.roles.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-500 mb-1">Selected roles:</div>
<div className="flex flex-wrap gap-1">
{formData.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Temporary Password
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<input
type={showPassword ? 'text' : 'password'}
id="password"
value={formData.tempPassword}
onChange={(e) => setFormData({ ...formData, tempPassword: e.target.value })}
className="flex-1 block w-full border-gray-300 rounded-l-md focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Enter password or generate one"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-50 text-gray-500 text-sm"
>
{showPassword ? '🙈' : '👁️'}
</button>
<button
type="button"
onClick={generatePassword}
className="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 rounded-r-md bg-gray-50 text-gray-500 text-sm hover:bg-gray-100"
>
Generate
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
User will need to change this password on first login
</p>
</div>
{/* Error */}
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -1,209 +1,9 @@
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
import type { User } from '../lib/supabase'
import { DashboardLayout } from "./DashboardLayout"
interface DashboardProps {
onLogout: () => void
}
export function Dashboard({ onLogout }: DashboardProps) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchUserProfile()
}, [])
const fetchUserProfile = async () => {
try {
setLoading(true)
setError(null)
// Get current auth user
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
if (authError) {
setError('Failed to get authenticated user')
return
}
if (!authUser) {
setError('No authenticated user found')
return
}
// Get user profile with role information
const { data: profile, error: profileError } = await supabase
.from('user_profiles')
.select(`
id,
email,
created_at,
updated_at,
role_id,
roles!inner (
name,
description
)
`)
.eq('id', authUser.id)
.single()
if (profileError) {
setError('Failed to fetch user profile: ' + profileError.message)
return
}
if (profile) {
setUser({
id: profile.id,
email: profile.email,
role: profile.roles.name as 'admin' | 'conductor' | 'analyst',
created_at: profile.created_at,
updated_at: profile.updated_at
})
}
} catch (err) {
setError('An unexpected error occurred')
console.error('Profile fetch error:', err)
} finally {
setLoading(false)
}
}
const handleLogout = async () => {
// Navigate to signout route which will handle the actual logout
window.history.pushState({}, '', '/signout')
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleDirectLogout = async () => {
try {
const { error } = await supabase.auth.signOut()
if (error) {
console.error('Logout error:', error)
}
onLogout()
} catch (err) {
console.error('Logout error:', err)
onLogout() // Still call onLogout to reset the UI state
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading user profile...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full">
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
<button
onClick={handleLogout}
className="mt-4 w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700"
>
Back to Login
</button>
</div>
</div>
)
}
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'
default:
return 'bg-gray-100 text-gray-800'
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="border-4 border-dashed border-gray-200 rounded-lg p-8">
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Welcome to the RBAC system</p>
</div>
<div className="flex gap-2">
<button
onClick={handleLogout}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Sign Out (/signout)
</button>
<button
onClick={handleDirectLogout}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Direct Logout
</button>
</div>
</div>
{user && (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
User Information
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Your account details and role permissions.
</p>
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user.email}
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Role</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
</span>
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 font-mono">
{user.id}
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Member since</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{new Date(user.created_at).toLocaleDateString()}
</dd>
</div>
</dl>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
return <DashboardLayout onLogout={onLogout} />
}

View File

@@ -0,0 +1,167 @@
import type { User } from '../lib/supabase'
interface DashboardHomeProps {
user: User
}
export function DashboardHome({ user }: DashboardHomeProps) {
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 getPermissionsByRole = (role: string) => {
switch (role) {
case 'admin':
return ['Full system access', 'User management', 'All modules', 'System configuration']
case 'conductor':
return ['Experiment management', 'Data collection', 'Analytics access', 'Data entry']
case 'analyst':
return ['Data analysis', 'Report generation', 'Read-only access', 'Analytics dashboard']
case 'data recorder':
return ['Data entry', 'Record management', 'Basic reporting', 'Data validation']
default:
return []
}
}
return (
<div className="p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Welcome to the RBAC system</p>
</div>
{/* User Information Card */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
User Information
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Your account details and role permissions.
</p>
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user.email}
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Roles</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<div className="flex flex-wrap gap-2">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<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>
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 font-mono">
{user.id}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Member since</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{new Date(user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</dd>
</div>
</dl>
</div>
</div>
{/* Role Permissions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{user.roles.map((role) => (
<div key={role} className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span 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 className="mt-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">Permissions</h3>
<ul className="space-y-2">
{getPermissionsByRole(role).map((permission, index) => (
<li key={index} className="flex items-center text-sm text-gray-600">
<span className="text-green-500 mr-2"></span>
{permission}
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
{/* Quick Actions */}
{user.roles.includes('admin') && (
<div className="mt-8 bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Quick Actions
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Administrative shortcuts and tools.
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
👥 Manage Users
</button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
🧪 View Experiments
</button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
📊 Analytics
</button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Settings
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Sidebar } from './Sidebar'
import { DashboardHome } from './DashboardHome'
import { UserManagement } from './UserManagement'
import { userManagement, type User } from '../lib/supabase'
interface DashboardLayoutProps {
onLogout: () => void
}
export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentView, setCurrentView] = useState('dashboard')
useEffect(() => {
fetchUserProfile()
}, [])
const fetchUserProfile = async () => {
try {
setLoading(true)
setError(null)
const currentUser = await userManagement.getCurrentUser()
if (currentUser) {
setUser(currentUser)
} else {
setError('No authenticated user found')
}
} catch (err) {
setError('Failed to fetch user profile')
console.error('Profile fetch error:', err)
} finally {
setLoading(false)
}
}
const handleLogout = async () => {
// Navigate to signout route which will handle the actual logout
window.history.pushState({}, '', '/signout')
window.dispatchEvent(new PopStateEvent('popstate'))
}
const renderCurrentView = () => {
if (!user) return null
switch (currentView) {
case 'dashboard':
return <DashboardHome user={user} />
case 'user-management':
if (user.roles.includes('admin')) {
return <UserManagement />
} else {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
Access denied. You need admin privileges to access user management.
</div>
</div>
</div>
)
}
case 'experiments':
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Experiments</h1>
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="text-sm text-blue-700">
Experiments module coming soon...
</div>
</div>
</div>
)
case 'analytics':
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Analytics</h1>
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="text-sm text-green-700">
Analytics module coming soon...
</div>
</div>
</div>
)
case 'data-entry':
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Data Entry</h1>
<div className="bg-purple-50 border border-purple-200 rounded-md p-4">
<div className="text-sm text-purple-700">
Data entry module coming soon...
</div>
</div>
</div>
)
default:
return <DashboardHome user={user} />
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading dashboard...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full">
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
<button
onClick={handleLogout}
className="mt-4 w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700"
>
Back to Login
</button>
</div>
</div>
)
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-gray-600">No user data available</div>
<button
onClick={handleLogout}
className="mt-4 px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Back to Login
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar
user={user}
currentView={currentView}
onViewChange={setCurrentView}
onLogout={handleLogout}
/>
<main className="flex-1 overflow-auto">
{renderCurrentView()}
</main>
</div>
)
}

167
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,167 @@
import { useState } from 'react'
import type { User } from '../lib/supabase'
interface SidebarProps {
user: User
currentView: string
onViewChange: (view: string) => void
onLogout: () => void
}
interface MenuItem {
id: string
name: string
icon: string
requiredRoles?: string[]
}
export function Sidebar({ user, currentView, onViewChange, onLogout }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false)
const menuItems: MenuItem[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: '🏠',
},
{
id: 'user-management',
name: 'User Management',
icon: '👥',
requiredRoles: ['admin']
},
{
id: 'experiments',
name: 'Experiments',
icon: '🧪',
requiredRoles: ['admin', 'conductor']
},
{
id: 'analytics',
name: 'Analytics',
icon: '📊',
requiredRoles: ['admin', 'conductor', 'analyst']
},
{
id: 'data-entry',
name: 'Data Entry',
icon: '📝',
requiredRoles: ['admin', 'conductor', 'data recorder']
}
]
const hasAccess = (item: MenuItem): boolean => {
if (!item.requiredRoles) return true
return item.requiredRoles.some(role => user.roles.includes(role as any))
}
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'
}
}
return (
<div className={`bg-gray-900 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} min-h-screen flex flex-col`}>
{/* Header */}
<div className="p-4 border-b border-gray-700">
<div className="flex items-center justify-between">
{!isCollapsed && (
<div>
<h1 className="text-xl font-bold">RBAC System</h1>
<p className="text-sm text-gray-400">Admin Dashboard</p>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-gray-700 transition-colors"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? '→' : '←'}
</button>
</div>
</div>
{/* User Info */}
<div className="p-4 border-b border-gray-700">
{!isCollapsed ? (
<div>
<div className="text-sm font-medium truncate">{user.email}</div>
<div className="mt-2 flex flex-wrap gap-1">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
<div className="mt-2 text-xs text-gray-400">
Status: <span className={user.status === 'active' ? 'text-green-400' : 'text-red-400'}>
{user.status}
</span>
</div>
</div>
) : (
<div className="flex justify-center">
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium">
{user.email.charAt(0).toUpperCase()}
</div>
</div>
)}
</div>
{/* Navigation Menu */}
<nav className="flex-1 p-4">
<ul className="space-y-2">
{menuItems.map((item) => {
if (!hasAccess(item)) return null
return (
<li key={item.id}>
<button
onClick={() => onViewChange(item.id)}
className={`w-full flex items-center px-3 py-2 rounded-lg transition-colors ${
currentView === item.id
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
title={isCollapsed ? item.name : undefined}
>
<span className="text-lg">{item.icon}</span>
{!isCollapsed && (
<span className="ml-3 text-sm font-medium">{item.name}</span>
)}
</button>
</li>
)
})}
</ul>
</nav>
{/* Footer Actions */}
<div className="p-4 border-t border-gray-700">
<button
onClick={onLogout}
className="w-full flex items-center px-3 py-2 rounded-lg text-gray-300 hover:bg-red-600 hover:text-white transition-colors"
title={isCollapsed ? 'Sign Out' : undefined}
>
<span className="text-lg">🚪</span>
{!isCollapsed && (
<span className="ml-3 text-sm font-medium">Sign Out</span>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import { userManagement, type User, type Role, type RoleName, type UserStatus } from '../lib/supabase'
import { CreateUserModal } from './CreateUserModal'
export function UserManagement() {
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingUser, setEditingUser] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
setError(null)
const [usersData, rolesData] = await Promise.all([
userManagement.getAllUsers(),
userManagement.getAllRoles()
])
setUsers(usersData)
setRoles(rolesData)
} catch (err) {
setError('Failed to load user data')
console.error('Load data error:', err)
} finally {
setLoading(false)
}
}
const handleStatusToggle = async (userId: string, currentStatus: UserStatus) => {
try {
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
await userManagement.updateUserStatus(userId, newStatus)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, status: newStatus } : user
))
} catch (err) {
console.error('Status update error:', err)
alert('Failed to update user status')
}
}
const handleRoleUpdate = async (userId: string, newRoles: RoleName[]) => {
try {
await userManagement.updateUserRoles(userId, newRoles)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, roles: newRoles } : user
))
setEditingUser(null)
} catch (err) {
console.error('Role update error:', err)
alert('Failed to update user roles')
}
}
const handleEmailUpdate = async (userId: string, newEmail: string) => {
try {
await userManagement.updateUserEmail(userId, newEmail)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, email: newEmail } : user
))
setEditingUser(null)
} catch (err) {
console.error('Email update error:', err)
alert('Failed to update user email')
}
}
const handleUserCreated = (newUser: User) => {
setUsers([...users, newUser])
setShowCreateModal(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'
}
}
if (loading) {
return (
<div className="p-6">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
<button
onClick={loadData}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Retry
</button>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Add New User
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">👥</span>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl"></span>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
<dd className="text-lg font-medium text-gray-900">
{users.filter(u => u.status === 'active').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">🔴</span>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt>
<dd className="text-lg font-medium text-gray-900">
{users.filter(u => u.status === 'disabled').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">👑</span>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt>
<dd className="text-lg font-medium text-gray-900">
{users.filter(u => u.roles.includes('admin')).length}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Users Table */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Users</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Click on any field to edit user details
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<UserRow
key={user.id}
user={user}
roles={roles}
isEditing={editingUser === user.id}
onEdit={() => setEditingUser(user.id)}
onCancel={() => setEditingUser(null)}
onStatusToggle={handleStatusToggle}
onRoleUpdate={handleRoleUpdate}
onEmailUpdate={handleEmailUpdate}
getRoleBadgeColor={getRoleBadgeColor}
/>
))}
</tbody>
</table>
</div>
</div>
{/* Create User Modal */}
{showCreateModal && (
<CreateUserModal
roles={roles}
onClose={() => setShowCreateModal(false)}
onUserCreated={handleUserCreated}
/>
)}
</div>
)
}
// UserRow component for inline editing
interface UserRowProps {
user: User
roles: Role[]
isEditing: boolean
onEdit: () => void
onCancel: () => void
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
onEmailUpdate: (userId: string, newEmail: string) => void
getRoleBadgeColor: (role: string) => string
}
function UserRow({
user,
roles,
isEditing,
onEdit,
onCancel,
onStatusToggle,
onRoleUpdate,
onEmailUpdate,
getRoleBadgeColor
}: UserRowProps) {
const [editEmail, setEditEmail] = useState(user.email)
const [editRoles, setEditRoles] = useState<RoleName[]>(user.roles)
const handleSave = () => {
if (editEmail !== user.email) {
onEmailUpdate(user.id, editEmail)
}
if (JSON.stringify(editRoles.sort()) !== JSON.stringify(user.roles.sort())) {
onRoleUpdate(user.id, editRoles)
}
if (editEmail === user.email && JSON.stringify(editRoles.sort()) === JSON.stringify(user.roles.sort())) {
onCancel()
}
}
const handleRoleToggle = (roleName: RoleName) => {
if (editRoles.includes(roleName)) {
setEditRoles(editRoles.filter(r => r !== roleName))
} else {
setEditRoles([...editRoles, roleName])
}
}
return (
<tr>
<td className="px-6 py-4 whitespace-nowrap">
{isEditing ? (
<input
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
) : (
<div
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
onClick={onEdit}
>
{user.email}
</div>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<div className="space-y-2">
{roles.map((role) => (
<label key={role.id} className="flex items-center">
<input
type="checkbox"
checked={editRoles.includes(role.name)}
onChange={() => handleRoleToggle(role.name)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
</label>
))}
</div>
) : (
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={onEdit}
>
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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'
}`}
>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{isEditing ? (
<div className="flex space-x-2">
<button
onClick={handleSave}
className="text-blue-600 hover:text-blue-900"
>
Save
</button>
<button
onClick={onCancel}
className="text-gray-600 hover:text-gray-900"
>
Cancel
</button>
</div>
) : (
<button
onClick={onEdit}
className="text-blue-600 hover:text-blue-900"
>
Edit
</button>
)}
</td>
</tr>
)
}

View File

@@ -7,17 +7,204 @@ const supabaseAnonKey = '[REDACTED]'
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Database types for TypeScript
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
export type UserStatus = 'active' | 'disabled'
export interface User {
id: string
email: string
role: 'admin' | 'conductor' | 'analyst'
roles: RoleName[]
status: UserStatus
created_at: string
updated_at: string
}
export interface Role {
id: string
name: 'admin' | 'conductor' | 'analyst'
name: RoleName
description: string
created_at: string
}
export interface UserRole {
id: string
user_id: string
role_id: string
assigned_at: string
assigned_by?: string
}
export interface UserProfile {
id: string
email: string
status: UserStatus
created_at: string
updated_at: string
role_id?: string // Legacy field, will be deprecated
}
export interface CreateUserRequest {
email: string
roles: RoleName[]
tempPassword?: string
}
export interface CreateUserResponse {
user_id: string
email: string
temp_password: string
roles: RoleName[]
status: UserStatus
}
// User management utility functions
export const userManagement = {
// Get all users with their roles
async getAllUsers(): Promise<User[]> {
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select(`
id,
email,
status,
created_at,
updated_at
`)
if (profilesError) throw profilesError
// Get roles for each user
const usersWithRoles = await Promise.all(
profiles.map(async (profile) => {
const { data: userRoles, error: rolesError } = await supabase
.from('user_roles')
.select(`
roles!inner (
name
)
`)
.eq('user_id', profile.id)
if (rolesError) throw rolesError
return {
...profile,
roles: userRoles.map(ur => ur.roles.name as RoleName)
}
})
)
return usersWithRoles
},
// Get all available roles
async getAllRoles(): Promise<Role[]> {
const { data, error } = await supabase
.from('roles')
.select('*')
.order('name')
if (error) throw error
return data
},
// Create a new user with roles
async createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
const { data, error } = await supabase.rpc('create_user_with_roles', {
user_email: userData.email,
role_names: userData.roles,
temp_password: userData.tempPassword
})
if (error) throw error
return data
},
// Update user status (enable/disable)
async updateUserStatus(userId: string, status: UserStatus): Promise<void> {
const { error } = await supabase
.from('user_profiles')
.update({ status })
.eq('id', userId)
if (error) throw error
},
// Update user roles
async updateUserRoles(userId: string, roleNames: RoleName[]): Promise<void> {
// First, remove all existing roles for the user
const { error: deleteError } = await supabase
.from('user_roles')
.delete()
.eq('user_id', userId)
if (deleteError) throw deleteError
// Get role IDs for the new roles
const { data: roles, error: rolesError } = await supabase
.from('roles')
.select('id, name')
.in('name', roleNames)
if (rolesError) throw rolesError
// Insert new role assignments
const roleAssignments = roles.map(role => ({
user_id: userId,
role_id: role.id
}))
const { error: insertError } = await supabase
.from('user_roles')
.insert(roleAssignments)
if (insertError) throw insertError
},
// Update user email
async updateUserEmail(userId: string, email: string): Promise<void> {
const { error } = await supabase
.from('user_profiles')
.update({ email })
.eq('id', userId)
if (error) throw error
},
// Get current user with roles
async getCurrentUser(): Promise<User | null> {
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
if (authError || !authUser) return null
const { data: profile, error: profileError } = await supabase
.from('user_profiles')
.select(`
id,
email,
status,
created_at,
updated_at
`)
.eq('id', authUser.id)
.single()
if (profileError) throw profileError
const { data: userRoles, error: rolesError } = await supabase
.from('user_roles')
.select(`
roles!inner (
name
)
`)
.eq('user_id', authUser.id)
if (rolesError) throw rolesError
return {
...profile,
roles: userRoles.map(ur => ur.roles.name as RoleName)
}
}
}