diff --git a/src/components/CreateUserModal.tsx b/src/components/CreateUserModal.tsx new file mode 100644 index 0000000..6f91d32 --- /dev/null +++ b/src/components/CreateUserModal.tsx @@ -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({ + email: '', + roles: [], + tempPassword: '' + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [generatedPassword, setGeneratedPassword] = useState(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 ( +
+
+
+ {/* Header */} +
+

Create New User

+ +
+ + {/* Form */} +
+ {/* Email */} +
+ + 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 + /> +
+ + {/* Roles */} +
+ +
+ {roles.map((role) => ( + + ))} +
+ + {/* Selected roles preview */} + {formData.roles.length > 0 && ( +
+
Selected roles:
+
+ {formData.roles.map((role) => ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ))} +
+
+ )} +
+ + {/* Password */} +
+ +
+ 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 + /> + + +
+

+ User will need to change this password on first login +

+
+ + {/* Error */} + {error && ( +
+
{error}
+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+
+ ) +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index d9989bc..a665656 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,209 +1,9 @@ -import { useState, useEffect } from 'react' -import { supabase } from '../lib/supabase' -import type { User } from '../lib/supabase' +import { DashboardLayout } from "./DashboardLayout" interface DashboardProps { onLogout: () => void } export function Dashboard({ onLogout }: DashboardProps) { - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(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 ( -
-
-
-

Loading user profile...

-
-
- ) - } - - if (error) { - return ( -
-
-
-
{error}
-
- -
-
- ) - } - - 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 ( -
-
-
-
-
-
-

Dashboard

-

Welcome to the RBAC system

-
-
- - -
-
- - {user && ( -
-
-

- User Information -

-

- Your account details and role permissions. -

-
-
-
-
-
Email
-
- {user.email} -
-
-
-
Role
-
- - {user.role.charAt(0).toUpperCase() + user.role.slice(1)} - -
-
-
-
User ID
-
- {user.id} -
-
-
-
Member since
-
- {new Date(user.created_at).toLocaleDateString()} -
-
-
-
-
- )} -
-
-
-
- ) + return } diff --git a/src/components/DashboardHome.tsx b/src/components/DashboardHome.tsx new file mode 100644 index 0000000..553e163 --- /dev/null +++ b/src/components/DashboardHome.tsx @@ -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 ( +
+
+

Dashboard

+

Welcome to the RBAC system

+
+ + {/* User Information Card */} +
+
+

+ User Information +

+

+ Your account details and role permissions. +

+
+
+
+
+
Email
+
+ {user.email} +
+
+
+
Roles
+
+
+ {user.roles.map((role) => ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ))} +
+
+
+
+
Status
+
+ + {user.status.charAt(0).toUpperCase() + user.status.slice(1)} + +
+
+
+
User ID
+
+ {user.id} +
+
+
+
Member since
+
+ {new Date(user.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
+
+
+
+
+ + {/* Role Permissions */} +
+ {user.roles.map((role) => ( +
+
+
+
+ + {role.charAt(0).toUpperCase() + role.slice(1)} + +
+
+
+

Permissions

+
    + {getPermissionsByRole(role).map((permission, index) => ( +
  • + โœ“ + {permission} +
  • + ))} +
+
+
+
+ ))} +
+ + {/* Quick Actions */} + {user.roles.includes('admin') && ( +
+
+

+ Quick Actions +

+

+ Administrative shortcuts and tools. +

+
+
+
+ + + + +
+
+
+ )} +
+ ) +} diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..0b0b164 --- /dev/null +++ b/src/components/DashboardLayout.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 + case 'user-management': + if (user.roles.includes('admin')) { + return + } else { + return ( +
+
+
+ Access denied. You need admin privileges to access user management. +
+
+
+ ) + } + case 'experiments': + return ( +
+

Experiments

+
+
+ Experiments module coming soon... +
+
+
+ ) + case 'analytics': + return ( +
+

Analytics

+
+
+ Analytics module coming soon... +
+
+
+ ) + case 'data-entry': + return ( +
+

Data Entry

+
+
+ Data entry module coming soon... +
+
+
+ ) + default: + return + } + } + + if (loading) { + return ( +
+
+
+

Loading dashboard...

+
+
+ ) + } + + if (error) { + return ( +
+
+
+
{error}
+
+ +
+
+ ) + } + + if (!user) { + return ( +
+
+
No user data available
+ +
+
+ ) + } + + return ( +
+ +
+ {renderCurrentView()} +
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..5154aee --- /dev/null +++ b/src/components/Sidebar.tsx @@ -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 ( +
+ {/* Header */} +
+
+ {!isCollapsed && ( +
+

RBAC System

+

Admin Dashboard

+
+ )} + +
+
+ + {/* User Info */} +
+ {!isCollapsed ? ( +
+
{user.email}
+
+ {user.roles.map((role) => ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ))} +
+
+ Status: + {user.status} + +
+
+ ) : ( +
+
+ {user.email.charAt(0).toUpperCase()} +
+
+ )} +
+ + {/* Navigation Menu */} + + + {/* Footer Actions */} +
+ +
+
+ ) +} diff --git a/src/components/UserManagement.tsx b/src/components/UserManagement.tsx new file mode 100644 index 0000000..a315a24 --- /dev/null +++ b/src/components/UserManagement.tsx @@ -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([]) + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editingUser, setEditingUser] = useState(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 ( +
+
+
+

Loading users...

+
+
+ ) + } + + if (error) { + return ( +
+
+
{error}
+
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+

User Management

+

Manage user accounts, roles, and permissions

+
+ +
+
+ + {/* Stats */} +
+
+
+
+
+ ๐Ÿ‘ฅ +
+
+
+
Total Users
+
{users.length}
+
+
+
+
+
+ +
+
+
+
+ โœ… +
+
+
+
Active Users
+
+ {users.filter(u => u.status === 'active').length} +
+
+
+
+
+
+ +
+
+
+
+ ๐Ÿ”ด +
+
+
+
Disabled Users
+
+ {users.filter(u => u.status === 'disabled').length} +
+
+
+
+
+
+ +
+
+
+
+ ๐Ÿ‘‘ +
+
+
+
Admins
+
+ {users.filter(u => u.roles.includes('admin')).length} +
+
+
+
+
+
+
+ + {/* Users Table */} +
+
+

Users

+

+ Click on any field to edit user details +

+
+
+ + + + + + + + + + + + {users.map((user) => ( + setEditingUser(user.id)} + onCancel={() => setEditingUser(null)} + onStatusToggle={handleStatusToggle} + onRoleUpdate={handleRoleUpdate} + onEmailUpdate={handleEmailUpdate} + getRoleBadgeColor={getRoleBadgeColor} + /> + ))} + +
+ Email + + Roles + + Status + + Created + + Actions +
+
+
+ + {/* Create User Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onUserCreated={handleUserCreated} + /> + )} +
+ ) +} + +// 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(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 ( + + + {isEditing ? ( + 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" + /> + ) : ( +
+ {user.email} +
+ )} + + + {isEditing ? ( +
+ {roles.map((role) => ( + + ))} +
+ ) : ( +
+ {user.roles.map((role) => ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ))} +
+ )} + + + + + + {new Date(user.created_at).toLocaleDateString()} + + + {isEditing ? ( +
+ + +
+ ) : ( + + )} + + + ) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 3d0abd6..da643da 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -7,17 +7,204 @@ const supabaseAnonKey = '[REDACTED]' export const supabase = createClient(supabaseUrl, supabaseAnonKey) // Database types for TypeScript +export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder' +export type UserStatus = 'active' | 'disabled' + export interface User { id: string email: string - role: 'admin' | 'conductor' | 'analyst' + roles: RoleName[] + status: UserStatus created_at: string updated_at: string } export interface Role { id: string - name: 'admin' | 'conductor' | 'analyst' + name: RoleName description: string created_at: string } + +export interface UserRole { + id: string + user_id: string + role_id: string + assigned_at: string + assigned_by?: string +} + +export interface UserProfile { + id: string + email: string + status: UserStatus + created_at: string + updated_at: string + role_id?: string // Legacy field, will be deprecated +} + +export interface CreateUserRequest { + email: string + roles: RoleName[] + tempPassword?: string +} + +export interface CreateUserResponse { + user_id: string + email: string + temp_password: string + roles: RoleName[] + status: UserStatus +} + +// User management utility functions +export const userManagement = { + // Get all users with their roles + async getAllUsers(): Promise { + 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 { + 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 { + 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 { + 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 { + // 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 { + const { error } = await supabase + .from('user_profiles') + .update({ email }) + .eq('id', userId) + + if (error) throw error + }, + + // Get current user with roles + async getCurrentUser(): Promise { + 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) + } + } +} diff --git a/supabase/migrations/20250719000001_rbac_schema.sql b/supabase/migrations/20250719000001_rbac_schema.sql index 479f8c3..9f91091 100644 --- a/supabase/migrations/20250719000001_rbac_schema.sql +++ b/supabase/migrations/20250719000001_rbac_schema.sql @@ -7,7 +7,7 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Create roles table CREATE TABLE IF NOT EXISTS public.roles ( 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, created_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 EXECUTE FUNCTION public.handle_updated_at(); --- Insert the three required roles +-- Insert the four required roles INSERT INTO public.roles (name, description) VALUES ('admin', 'Full system access with user management capabilities'), ('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; diff --git a/supabase/migrations/20250720000001_multiple_roles_support.sql b/supabase/migrations/20250720000001_multiple_roles_support.sql new file mode 100644 index 0000000..c364d6f --- /dev/null +++ b/supabase/migrations/20250720000001_multiple_roles_support.sql @@ -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; diff --git a/supabase/migrations/20250720000002_fix_role_id_constraint.sql b/supabase/migrations/20250720000002_fix_role_id_constraint.sql new file mode 100644 index 0000000..406f007 --- /dev/null +++ b/supabase/migrations/20250720000002_fix_role_id_constraint.sql @@ -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;