can successfully add new users
This commit is contained in:
243
src/components/CreateUserModal.tsx
Normal file
243
src/components/CreateUserModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
167
src/components/DashboardHome.tsx
Normal file
167
src/components/DashboardHome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/DashboardLayout.tsx
Normal file
162
src/components/DashboardLayout.tsx
Normal 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
167
src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
421
src/components/UserManagement.tsx
Normal file
421
src/components/UserManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user