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 { DashboardLayout } from "./DashboardLayout"
|
||||||
import { supabase } from '../lib/supabase'
|
|
||||||
import type { User } from '../lib/supabase'
|
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard({ onLogout }: DashboardProps) {
|
export function Dashboard({ onLogout }: DashboardProps) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
return <DashboardLayout onLogout={onLogout} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
// Database types for TypeScript
|
// Database types for TypeScript
|
||||||
|
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||||
|
export type UserStatus = 'active' | 'disabled'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
role: 'admin' | 'conductor' | 'analyst'
|
roles: RoleName[]
|
||||||
|
status: UserStatus
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
id: string
|
id: string
|
||||||
name: 'admin' | 'conductor' | 'analyst'
|
name: RoleName
|
||||||
description: string
|
description: string
|
||||||
created_at: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|||||||
-- Create roles table
|
-- Create roles table
|
||||||
CREATE TABLE IF NOT EXISTS public.roles (
|
CREATE TABLE IF NOT EXISTS public.roles (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name TEXT UNIQUE NOT NULL CHECK (name IN ('admin', 'conductor', 'analyst')),
|
name TEXT UNIQUE NOT NULL CHECK (name IN ('admin', 'conductor', 'analyst', 'data recorder')),
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
@@ -46,9 +46,10 @@ CREATE TRIGGER set_updated_at_user_profiles
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION public.handle_updated_at();
|
EXECUTE FUNCTION public.handle_updated_at();
|
||||||
|
|
||||||
-- Insert the three required roles
|
-- Insert the four required roles
|
||||||
INSERT INTO public.roles (name, description) VALUES
|
INSERT INTO public.roles (name, description) VALUES
|
||||||
('admin', 'Full system access with user management capabilities'),
|
('admin', 'Full system access with user management capabilities'),
|
||||||
('conductor', 'Operational access for conducting experiments and managing data'),
|
('conductor', 'Operational access for conducting experiments and managing data'),
|
||||||
('analyst', 'Read-only access for data analysis and reporting')
|
('analyst', 'Read-only access for data analysis and reporting'),
|
||||||
|
('data recorder', 'Data entry and recording capabilities')
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|||||||
204
supabase/migrations/20250720000001_multiple_roles_support.sql
Normal file
204
supabase/migrations/20250720000001_multiple_roles_support.sql
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
-- Multiple Roles Support Migration
|
||||||
|
-- Adds support for multiple roles per user and user status management
|
||||||
|
|
||||||
|
-- Add status column to user_profiles
|
||||||
|
ALTER TABLE public.user_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' CHECK (status IN ('active', 'disabled'));
|
||||||
|
|
||||||
|
-- Create user_roles junction table for many-to-many relationship
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
assigned_by UUID REFERENCES public.user_profiles(id),
|
||||||
|
UNIQUE(user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON public.user_roles(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON public.user_roles(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_status ON public.user_profiles(status);
|
||||||
|
|
||||||
|
-- Enable RLS on user_roles table
|
||||||
|
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS policies for user_roles table
|
||||||
|
-- Users can read their own role assignments, admins can read all
|
||||||
|
CREATE POLICY "Users can read own roles, admins can read all" ON public.user_roles
|
||||||
|
FOR SELECT USING (
|
||||||
|
user_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only admins can insert role assignments
|
||||||
|
CREATE POLICY "Only admins can assign roles" ON public.user_roles
|
||||||
|
FOR INSERT WITH CHECK (public.is_admin());
|
||||||
|
|
||||||
|
-- Only admins can update role assignments
|
||||||
|
CREATE POLICY "Only admins can update role assignments" ON public.user_roles
|
||||||
|
FOR UPDATE USING (public.is_admin());
|
||||||
|
|
||||||
|
-- Only admins can delete role assignments
|
||||||
|
CREATE POLICY "Only admins can remove role assignments" ON public.user_roles
|
||||||
|
FOR DELETE USING (public.is_admin());
|
||||||
|
|
||||||
|
-- Update the get_user_role function to return multiple roles
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_user_roles()
|
||||||
|
RETURNS TEXT[] AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN ARRAY(
|
||||||
|
SELECT r.name
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid()
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Update the is_admin function to work with multiple roles
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_admin()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN 'admin' = ANY(public.get_user_roles());
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to check if user has specific role
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_role(role_name TEXT)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN role_name = ANY(public.get_user_roles());
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to migrate existing single role assignments to multiple roles
|
||||||
|
CREATE OR REPLACE FUNCTION public.migrate_single_roles_to_multiple()
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
user_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Migrate existing role assignments
|
||||||
|
FOR user_record IN
|
||||||
|
SELECT id, role_id
|
||||||
|
FROM public.user_profiles
|
||||||
|
WHERE role_id IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
-- Insert into user_roles if not already exists
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id)
|
||||||
|
VALUES (user_record.id, user_record.role_id)
|
||||||
|
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration completed: existing role assignments moved to user_roles table';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Execute the migration
|
||||||
|
SELECT public.migrate_single_roles_to_multiple();
|
||||||
|
|
||||||
|
-- Drop the migration function as it's no longer needed
|
||||||
|
DROP FUNCTION public.migrate_single_roles_to_multiple();
|
||||||
|
|
||||||
|
-- Function to generate secure temporary password
|
||||||
|
CREATE OR REPLACE FUNCTION public.generate_temp_password()
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
|
||||||
|
result TEXT := '';
|
||||||
|
i INTEGER;
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 1..12 LOOP
|
||||||
|
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
|
||||||
|
END LOOP;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to create user with roles (for admin use)
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_user_with_roles(
|
||||||
|
user_email TEXT,
|
||||||
|
role_names TEXT[],
|
||||||
|
temp_password TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
new_user_id UUID;
|
||||||
|
role_record RECORD;
|
||||||
|
generated_password TEXT;
|
||||||
|
result JSON;
|
||||||
|
BEGIN
|
||||||
|
-- Only admins can create users
|
||||||
|
IF NOT public.is_admin() THEN
|
||||||
|
RAISE EXCEPTION 'Only administrators can create users';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate that at least one role is provided
|
||||||
|
IF array_length(role_names, 1) IS NULL OR array_length(role_names, 1) = 0 THEN
|
||||||
|
RAISE EXCEPTION 'At least one role must be assigned to the user';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generate password if not provided
|
||||||
|
IF temp_password IS NULL THEN
|
||||||
|
generated_password := public.generate_temp_password();
|
||||||
|
ELSE
|
||||||
|
generated_password := temp_password;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generate new user ID
|
||||||
|
new_user_id := uuid_generate_v4();
|
||||||
|
|
||||||
|
-- Insert into auth.users (simulating user creation)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
new_user_id,
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
user_email,
|
||||||
|
crypt(generated_password, gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert user profile
|
||||||
|
INSERT INTO public.user_profiles (id, email, status)
|
||||||
|
VALUES (new_user_id, user_email, 'active');
|
||||||
|
|
||||||
|
-- Assign roles
|
||||||
|
FOR role_record IN
|
||||||
|
SELECT id FROM public.roles WHERE name = ANY(role_names)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
VALUES (new_user_id, role_record.id, auth.uid());
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Return result
|
||||||
|
result := json_build_object(
|
||||||
|
'user_id', new_user_id,
|
||||||
|
'email', user_email,
|
||||||
|
'temp_password', generated_password,
|
||||||
|
'roles', role_names,
|
||||||
|
'status', 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
161
supabase/migrations/20250720000002_fix_role_id_constraint.sql
Normal file
161
supabase/migrations/20250720000002_fix_role_id_constraint.sql
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
-- Fix role_id constraint in user_profiles table
|
||||||
|
-- Make role_id nullable since we now use user_roles junction table
|
||||||
|
|
||||||
|
-- Remove the NOT NULL constraint from role_id column
|
||||||
|
ALTER TABLE public.user_profiles
|
||||||
|
ALTER COLUMN role_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Update the RLS helper functions to work with the new multiple roles system
|
||||||
|
-- Replace the old get_user_role function that relied on single role_id
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_user_role()
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Return the first role found (for backward compatibility)
|
||||||
|
-- In practice, use get_user_roles() for multiple roles
|
||||||
|
RETURN (
|
||||||
|
SELECT r.name
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid()
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Update is_admin function to use the new multiple roles system
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_admin()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid() AND r.name = 'admin'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Add a function to check if user has any of the specified roles
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_any_role(role_names TEXT[])
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid() AND r.name = ANY(role_names)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Update the create_user_with_roles function to handle potential errors better
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_user_with_roles(
|
||||||
|
user_email TEXT,
|
||||||
|
role_names TEXT[],
|
||||||
|
temp_password TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
new_user_id UUID;
|
||||||
|
role_record RECORD;
|
||||||
|
generated_password TEXT;
|
||||||
|
result JSON;
|
||||||
|
role_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Only admins can create users
|
||||||
|
IF NOT public.is_admin() THEN
|
||||||
|
RAISE EXCEPTION 'Only administrators can create users';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate that at least one role is provided
|
||||||
|
IF array_length(role_names, 1) IS NULL OR array_length(role_names, 1) = 0 THEN
|
||||||
|
RAISE EXCEPTION 'At least one role must be assigned to the user';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate that all provided roles exist
|
||||||
|
SELECT COUNT(*) INTO role_count
|
||||||
|
FROM public.roles
|
||||||
|
WHERE name = ANY(role_names);
|
||||||
|
|
||||||
|
IF role_count != array_length(role_names, 1) THEN
|
||||||
|
RAISE EXCEPTION 'One or more specified roles do not exist';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check if user already exists
|
||||||
|
IF EXISTS (SELECT 1 FROM auth.users WHERE email = user_email) THEN
|
||||||
|
RAISE EXCEPTION 'User with email % already exists', user_email;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generate password if not provided
|
||||||
|
IF temp_password IS NULL THEN
|
||||||
|
generated_password := public.generate_temp_password();
|
||||||
|
ELSE
|
||||||
|
generated_password := temp_password;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generate new user ID
|
||||||
|
new_user_id := uuid_generate_v4();
|
||||||
|
|
||||||
|
-- Insert into auth.users (simulating user creation)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
new_user_id,
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
user_email,
|
||||||
|
crypt(generated_password, gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert user profile (without role_id since it's now nullable)
|
||||||
|
INSERT INTO public.user_profiles (id, email, status)
|
||||||
|
VALUES (new_user_id, user_email, 'active');
|
||||||
|
|
||||||
|
-- Assign roles through the user_roles junction table
|
||||||
|
FOR role_record IN
|
||||||
|
SELECT id FROM public.roles WHERE name = ANY(role_names)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
VALUES (new_user_id, role_record.id, auth.uid());
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Return result
|
||||||
|
result := json_build_object(
|
||||||
|
'user_id', new_user_id,
|
||||||
|
'email', user_email,
|
||||||
|
'temp_password', generated_password,
|
||||||
|
'roles', role_names,
|
||||||
|
'status', 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
-- Clean up any partial inserts
|
||||||
|
DELETE FROM public.user_roles WHERE user_id = new_user_id;
|
||||||
|
DELETE FROM public.user_profiles WHERE id = new_user_id;
|
||||||
|
DELETE FROM auth.users WHERE id = new_user_id;
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
Reference in New Issue
Block a user