can successfully add new users

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

View File

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

View File

@@ -1,209 +1,9 @@
import { useState, useEffect } from 'react' import { 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>
)
} }

View File

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

View File

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

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

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

View File

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

View File

@@ -7,17 +7,204 @@ const supabaseAnonKey = '[REDACTED]'
export const supabase = createClient(supabaseUrl, supabaseAnonKey) 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)
}
}
}

View File

@@ -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;

View 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;

View 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;