import type { FormEvent } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowDown, ArrowUp, ArrowUpDown, Check, ChevronDown, Copy, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ModuleHeader } from '@/components/ui/module-header'; import { NativeSelect } from '@/components/ui/native-select'; import { PageSkeleton } from '@/components/ui/page-skeleton'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker'; import { ImageUpload } from '@/components/common/ImageUpload'; import { TenantLogo } from '@/components/common/TenantLogo'; import { UserAvatar } from '@/components/common/UserAvatar'; import { useScopeContext } from '@/contexts/scope-context'; import { useAuth } from '@/contexts/useAuth'; import { useTenantChildren } from '@/business/scope/queries'; import { getAuthRoleLabel } from '@/business/auth/selectors'; import { useIamCapabilities } from '@/business/iam-capabilities/hooks'; import { usePermissions } from '@/hooks/usePermissions'; import { getRoleTenantInput, type RoleTenantInput, } from '@/business/user-admin/selectors'; import { createOwnerWithOrganization, createUser, deleteUser, linkGuardianStudent, listGuardianStudents, listPermissions, listRoles, listUsers, updateUser, type AdminUserRow, type RoleRow, type SaveUserData, } from '@/business/user-admin/api'; import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users'; import { getErrorMessage } from '@/shared/errors/errorMessages'; import type { UserRole } from '@/shared/types/app'; import type { ParentLevel } from '@/business/tenant-create/selectors'; import { cn } from '@/lib/utils'; const USER_LIST_PAGE_SIZE = 10; const USER_LIST_FETCH_LIMIT = 1000; interface StatusMessage { readonly type: 'success' | 'error'; readonly text: string; } interface CreatedCredential { readonly email: string; readonly password: string; } type UserListSortField = | 'name' | 'email' | 'phoneNumber' | 'school' | 'campus' | 'class' | 'role'; type UserListSortDirection = 'asc' | 'desc'; const TENANT_INPUT_TARGET: Record, ParentLevel> = { org: 'organization', school: 'school', campus: 'campus', class: 'campus', }; function userName(row: AdminUserRow): string { return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email; } function locationName(value?: { name?: string | null } | null): string { return value?.name?.trim() || '—'; } function locationCell(value?: { name?: string | null; logo?: string | null } | null) { const name = value?.name?.trim(); if (!name) { return '—'; } return ( {name} ); } function classNamesForUser( row: AdminUserRow, guardianClassNamesById: ReadonlyMap, ): readonly string[] { const names: string[] = []; if (row.class?.name) { names.push(row.class.name); } for (const enrollment of row.class_enrollments_student ?? []) { const className = enrollment.class?.name; if (className && !names.includes(className)) { names.push(className); } } for (const className of guardianClassNamesById.get(row.id) ?? []) { if (!names.includes(className)) { names.push(className); } } return names; } function classCell( row: AdminUserRow, guardianClassNamesById: ReadonlyMap, ) { const classNames = classNamesForUser(row, guardianClassNamesById); if (classNames.length === 0) { return '—'; } return ( {classNames.join(', ')} ); } function sortText(value: string | null | undefined): string { return value?.trim().toLocaleLowerCase() || ''; } function permissionLabel(name: string | null | undefined, id: string): string { return (name?.trim() || id).replace(/_/g, ' '); } function studentPickerLabel(students: readonly AdminUserRow[], selectedIds: readonly string[]): string { if (selectedIds.length === 0) return 'Select students...'; const names = students .filter((student) => selectedIds.includes(student.id)) .map(userName); if (names.length === 0) return `${selectedIds.length} selected`; if (names.length <= 2) return names.join(', '); return `${names.slice(0, 2).join(', ')} +${names.length - 2}`; } export default function UserAdminPage() { const { tier, ownTenant } = useScopeContext(); const { user } = useAuth(); const queryClient = useQueryClient(); const permissions = usePermissions(); const capabilitiesQuery = useIamCapabilities(); const rolesQuery = useQuery({ queryKey: ['roles'], queryFn: listRoles }); const permissionsQuery = useQuery({ queryKey: ['permissions'], queryFn: listPermissions }); const guardianStudentsQuery = useQuery({ queryKey: ['guardian-students'], queryFn: () => listGuardianStudents(), }); const [usersPage, setUsersPage] = useState(0); const [usersSearchDraft, setUsersSearchDraft] = useState(''); const [usersSearch, setUsersSearch] = useState(''); const [usersSortField, setUsersSortField] = useState('name'); const [usersSortDirection, setUsersSortDirection] = useState('asc'); const [isUserFormOpen, setIsUserFormOpen] = useState(false); const [isStudentPickerOpen, setIsStudentPickerOpen] = useState(false); const usersQuery = useQuery({ queryKey: ['admin-users', usersSearch], queryFn: () => listUsers({ query: usersSearch || undefined, limit: USER_LIST_FETCH_LIMIT, page: 0, }), }); const manageableRoles = useMemo( () => capabilitiesQuery.data?.manageableRoleNames ?? [], [capabilitiesQuery.data], ); const createRoleOptions = useMemo( () => manageableRoles.filter((item) => item !== 'super_admin'), [manageableRoles], ); const currentRoleName = user?.app_role?.name ?? null; const canCreateUsers = permissions.has('CREATE_USERS'); const canUpdateUsers = permissions.has('UPDATE_USERS'); const canDeleteUsers = permissions.has('DELETE_USERS'); const canEditPermissions = canCreateUsers || canUpdateUsers; // role name → id (for create/update payloads + student lookup). const roleIdByName = useMemo(() => { const map = new Map(); for (const r of rolesQuery.data?.rows ?? []) { if (r.name) map.set(r.name, r.id); } return map; }, [rolesQuery.data]); const roleById = useMemo(() => { const map = new Map(); for (const roleItem of rolesQuery.data?.rows ?? []) { map.set(roleItem.id, roleItem); } return map; }, [rolesQuery.data]); const allPermissions = useMemo( () => [...(permissionsQuery.data?.rows ?? [])].sort((a, b) => permissionLabel(a.name, a.id).localeCompare(permissionLabel(b.name, b.id)), ), [permissionsQuery.data], ); const [editingId, setEditingId] = useState(null); const [namePrefix, setNamePrefix] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phoneNumber, setPhoneNumber] = useState(''); const [email, setEmail] = useState(''); const [avatar, setAvatar] = useState(null); const [role, setRole] = useState(''); const [pickedTenantId, setPickedTenantId] = useState(null); const [classId, setClassId] = useState(''); const [studentIds, setStudentIds] = useState([]); const [grantPerms, setGrantPerms] = useState([]); const [excludePerms, setExcludePerms] = useState([]); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(null); const [createdCredential, setCreatedCredential] = useState(null); const editingSuperAdmin = editingId !== null && role === 'super_admin'; const roleOptions = editingSuperAdmin ? ['super_admin' as UserRole] : createRoleOptions; const selectedRole = editingSuperAdmin ? ('super_admin' as UserRole) : (role || roleOptions[0] || ''); const selectedRoleId = selectedRole ? (roleIdByName.get(selectedRole) ?? null) : null; const selectedRoleRecord = selectedRoleId ? roleById.get(selectedRoleId) ?? null : null; const rolePermissionIds = useMemo( () => new Set((selectedRoleRecord?.permissions ?? []).map((item) => item.id)), [selectedRoleRecord], ); const effectivePermissionIds = useMemo(() => { const ids = new Set(rolePermissionIds); for (const id of grantPerms) ids.add(id); for (const id of excludePerms) ids.delete(id); return ids; }, [excludePerms, grantPerms, rolePermissionIds]); const addablePermissions = useMemo( () => allPermissions.filter((permission) => !effectivePermissionIds.has(permission.id)), [allPermissions, effectivePermissionIds], ); const excludablePermissions = useMemo( () => allPermissions.filter((permission) => effectivePermissionIds.has(permission.id)), [allPermissions, effectivePermissionIds], ); const tenantInput = selectedRole ? getRoleTenantInput(selectedRole) : 'none'; const canManageAdvancedPermissions = canEditPermissions && selectedRole !== 'super_admin' && selectedRole !== 'student' && selectedRole !== 'guardian'; const canAutoCreateOwnerOrganization = selectedRole === 'owner' && capabilitiesQuery.data?.canCreateOwnerWithOrganization === true; const targetLevel = tenantInput === 'none' || tenantInput === 'guardian' ? null : TENANT_INPUT_TARGET[tenantInput]; // Picker is only needed when the creator's own tier is above the target level. const needsPicker = targetLevel !== null && tier !== targetLevel && !canAutoCreateOwnerOrganization; const resolvedTenantId = needsPicker ? pickedTenantId : (ownTenant?.id ?? null); // Class options = children of the chosen/own campus. const campusForClass = tenantInput === 'class' ? (needsPicker ? pickedTenantId : ownTenant?.id ?? null) : null; const classQuery = useTenantChildren( campusForClass ? { level: 'campus', id: campusForClass } : null, { enabled: Boolean(campusForClass) }, ); // Students for guardian linking (role 'student' in the actor's scope). const studentsQuery = useQuery({ queryKey: ['admin-users', 'students', 'student'], queryFn: () => listUsers({ app_role: 'student' }), enabled: tenantInput === 'guardian', }); const guardianStudentOptions = studentsQuery.data?.rows ?? []; const handlePickerChange = useCallback((id: string | null) => { setPickedTenantId(id); setClassId(''); }, []); const handleUsersSearchSubmit = useCallback((event: FormEvent) => { event.preventDefault(); setUsersPage(0); setUsersSearch(usersSearchDraft.trim()); }, [usersSearchDraft]); const clearUsersSearch = useCallback(() => { setUsersSearchDraft(''); setUsersSearch(''); setUsersPage(0); }, []); const toggleUsersSort = useCallback((field: UserListSortField) => { setUsersPage(0); if (field === usersSortField) { setUsersSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); return; } setUsersSortField(field); setUsersSortDirection('asc'); }, [usersSortField]); const includePermission = useCallback((permissionId: string) => { setExcludePerms((current) => current.filter((id) => id !== permissionId)); if (!rolePermissionIds.has(permissionId)) { setGrantPerms((current) => current.includes(permissionId) ? current : [...current, permissionId], ); return; } setGrantPerms((current) => current.filter((id) => id !== permissionId)); }, [rolePermissionIds]); const excludePermission = useCallback((permissionId: string) => { if (rolePermissionIds.has(permissionId)) { setExcludePerms((current) => current.includes(permissionId) ? current : [...current, permissionId], ); } else { setGrantPerms((current) => current.filter((id) => id !== permissionId)); setExcludePerms((current) => current.filter((id) => id !== permissionId)); } }, [rolePermissionIds]); const fetchedRows = usersQuery.data?.rows; const classNameByStudentId = useMemo(() => { const map = new Map(); for (const row of fetchedRows ?? []) { const className = classNamesForUser(row, new Map()).join(', '); if (row.app_role?.name === 'student' && className) { map.set(row.id, className); } } return map; }, [fetchedRows]); const guardianClassNamesById = useMemo(() => { const map = new Map(); for (const link of guardianStudentsQuery.data?.rows ?? []) { const className = classNameByStudentId.get(link.studentId); if (!className) continue; const current = map.get(link.guardianId) ?? []; if (!current.includes(className)) { current.push(className); map.set(link.guardianId, current); } } return map; }, [classNameByStudentId, guardianStudentsQuery.data]); const sortedRows = useMemo(() => { const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', }); const valueForField = (row: AdminUserRow, field: UserListSortField): string => { switch (field) { case 'name': return sortText(userName(row)); case 'email': return sortText(row.email); case 'phoneNumber': return sortText(row.phoneNumber); case 'school': return sortText(locationName(row.school)); case 'campus': return sortText(locationName(row.campus)); case 'class': return sortText(classNamesForUser(row, guardianClassNamesById).join(' ')); case 'role': return sortText( row.app_role?.name ? getAuthRoleLabel(row.app_role.name as UserRole) : '—', ); } }; return [...(fetchedRows ?? [])].sort((left, right) => { const result = collator.compare( valueForField(left, usersSortField), valueForField(right, usersSortField), ); if (result !== 0) { return usersSortDirection === 'asc' ? result : -result; } return collator.compare(sortText(userName(left)), sortText(userName(right))); }); }, [fetchedRows, guardianClassNamesById, usersSortDirection, usersSortField]); const usersTotal = usersQuery.data?.count ?? sortedRows.length; const rows = useMemo(() => { const start = usersPage * USER_LIST_PAGE_SIZE; return sortedRows.slice(start, start + USER_LIST_PAGE_SIZE); }, [sortedRows, usersPage]); function resetForm() { setEditingId(null); setNamePrefix(''); setFirstName(''); setLastName(''); setPhoneNumber(''); setEmail(''); setAvatar(null); setRole(''); setPickedTenantId(null); setClassId(''); setStudentIds([]); setGrantPerms([]); setExcludePerms([]); setIsUserFormOpen(false); setCreatedCredential(null); } function startEdit(row: AdminUserRow) { setIsUserFormOpen(true); setEditingId(row.id); setNamePrefix(row.name_prefix ?? ''); setFirstName(row.firstName ?? ''); setLastName(row.lastName ?? ''); setPhoneNumber(row.phoneNumber ?? ''); setEmail(row.email); setAvatar(row.avatar?.[0]?.privateUrl ?? null); const roleName = row.app_role?.name; setRole( roleName === 'super_admin' || (roleName && manageableRoles.includes(roleName as UserRole)) ? (roleName as UserRole) : '', ); setPickedTenantId(null); setClassId(''); setStudentIds([]); setGrantPerms((row.custom_permissions ?? []).map((p) => p.id)); setExcludePerms((row.custom_permissions_filter ?? []).map((p) => p.id)); setStatus(null); setCreatedCredential(null); } function buildTenantPayload(): SaveUserData { if (tenantInput === 'org' && resolvedTenantId) return { organizations: resolvedTenantId }; if (tenantInput === 'school' && resolvedTenantId) return { schoolId: resolvedTenantId }; if (tenantInput === 'campus' && resolvedTenantId) return { campusId: resolvedTenantId }; if (tenantInput === 'class') { return { ...(resolvedTenantId ? { campusId: resolvedTenantId } : {}), ...(classId ? { classId } : {}), }; } return {}; } async function handleSubmit(event: FormEvent) { event.preventDefault(); setStatus(null); setCreatedCredential(null); if (!selectedRole) return; if (needsPicker && !resolvedTenantId) { setStatus({ type: 'error', text: 'Please choose a tenant for this user.' }); return; } if (tenantInput === 'class' && !classId) { setStatus({ type: 'error', text: 'Please choose a class.' }); return; } const roleId = roleIdByName.get(selectedRole); const data: SaveUserData = { name_prefix: namePrefix === '' ? null : namePrefix, firstName: firstName.trim(), lastName: lastName.trim(), phoneNumber: phoneNumber.trim() || null, email: email.trim(), avatar, app_role: roleId ?? null, custom_permissions: canManageAdvancedPermissions ? grantPerms : [], custom_permissions_filter: canManageAdvancedPermissions ? excludePerms : [], ...buildTenantPayload(), }; setSaving(true); try { let returnedTemporaryPassword: string | undefined; if (editingId) { await updateUser(editingId, data); } else { const { id, temporaryPassword } = selectedRole === 'owner' && !data.organizations ? await createOwnerWithOrganization(data) : await createUser(data); returnedTemporaryPassword = temporaryPassword; if (tenantInput === 'guardian' && id && studentIds.length > 0) { await Promise.all(studentIds.map((sid) => linkGuardianStudent(id, sid))); } if (temporaryPassword) { setCreatedCredential({ email: data.email ?? email.trim(), password: temporaryPassword }); } } await queryClient.invalidateQueries({ queryKey: ['admin-users'] }); await queryClient.invalidateQueries({ queryKey: ['guardian-students'] }); setUsersPage(0); if (editingId) { setStatus({ type: 'success', text: 'User updated.' }); resetForm(); } else if (returnedTemporaryPassword) { setStatus({ type: 'success', text: 'User created.' }); } else { setStatus({ type: 'success', text: 'User created (invite sent).' }); resetForm(); } } catch (error) { setStatus({ type: 'error', text: getErrorMessage(error, 'Could not save user') }); } finally { setSaving(false); } } function copyCreatedPassword() { if (!createdCredential) return; void navigator.clipboard?.writeText(createdCredential.password); setStatus({ type: 'success', text: 'Temporary password copied. Give it to the created user.', }); } async function handleDelete(row: AdminUserRow) { setStatus(null); try { await deleteUser(row.id); await queryClient.invalidateQueries({ queryKey: ['admin-users'] }); if (editingId === row.id) resetForm(); if (rows.length === 1 && usersPage > 0) setUsersPage((page) => page - 1); } catch (error) { setStatus({ type: 'error', text: getErrorMessage(error, 'Could not delete user') }); } } if (rolesQuery.isLoading || usersQuery.isLoading || capabilitiesQuery.isLoading) { return ; } const usersPageCount = Math.max(1, Math.ceil(usersTotal / USER_LIST_PAGE_SIZE)); const usersStart = usersTotal === 0 ? 0 : usersPage * USER_LIST_PAGE_SIZE + 1; const usersEnd = Math.min(usersStart + rows.length - 1, usersTotal); const formControlClassName = 'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-sky-400 focus-visible:ring-offset-slate-950'; const hasUsersSearch = usersSearch.length > 0; function renderSortIcon(field: UserListSortField) { if (usersSortField !== field) { return ; } if (usersSortDirection === 'asc') { return ; } return ; } return (
{(canCreateUsers || editingId) && ( {editingId && ( )} {isUserFormOpen && (

User details

Basic identity fields used for login, invitations, and profile display.

setNamePrefix(e.target.value)} > {USER_NAME_PREFIX_OPTIONS.map((o) => ( ))}
setFirstName(e.target.value)} />
setLastName(e.target.value)} />
setEmail(e.target.value)} required />
setPhoneNumber(e.target.value)} />

Access

Choose the user's role and the organization or location where this account should work.

{ setRole(e.target.value as UserRole); setPickedTenantId(null); setClassId(''); setStudentIds([]); if (e.target.value === 'student' || e.target.value === 'guardian') { setGrantPerms([]); setExcludePerms([]); } setStatus(null); }} > {roleOptions.length === 0 ? ( ) : ( roleOptions.map((r) => ( )) )} {editingSuperAdmin && (

The platform has one Super Admin. This account can be updated, but its role cannot be reassigned.

)}
{needsPicker && targetLevel && ( )} {tenantInput === 'class' && (
setClassId(e.target.value)} > {(classQuery.data?.rows ?? []).map((row) => ( ))}
)} {tenantInput === 'guardian' && !editingId && (
No students found in your scope. {guardianStudentOptions.map((student) => { const selected = studentIds.includes(student.id); return ( { setStudentIds((prev) => selected ? prev.filter((id) => id !== student.id) : [...prev, student.id], ); }} className="flex cursor-pointer items-center gap-2 text-slate-100" > {userName(student)} {student.email} ); })}
)}
{canManageAdvancedPermissions && (
Advanced permissions (optional)

Add permissions on top of the role, or exclude permissions the role would grant. The role's base permissions apply automatically.

{addablePermissions.length === 0 ? (

No additional permissions available.

) : ( addablePermissions.map((permission) => ( )) )}
{excludablePermissions.length === 0 ? (

No effective permissions to exclude.

) : ( excludablePermissions.map((permission) => ( )) )}
)} {createdCredential && (

Temporary password

Mailer is not configured, so this password is shown only once here. Copy it now and give it to the created user so they can sign in and change it from their profile.

)}
)}
)} {status && (
{status.text}
)}
Users ({usersTotal}) {usersTotal > 0 && (

Showing {usersStart}-{usersEnd} of {usersTotal}

)}
setUsersSearchDraft(event.target.value)} placeholder="Search by name, email, phone, school, campus, class, or role" className="pl-9" />
{rows.length === 0 ? (

{hasUsersSearch ? 'No users match the current search.' : 'No users yet.'}

) : (
{([ ['name', 'User'], ['email', 'Email'], ['phoneNumber', 'Phone'], ['school', 'School'], ['campus', 'Campus'], ['class', 'Class'], ['role', 'Role'], ] as const).map(([field, label]) => ( ))} {rows.map((row) => { const roleName = row.app_role?.name; const isSuperAdminRow = roleName === 'super_admin'; const manageable = roleName ? manageableRoles.includes(roleName as UserRole) : true; const canEditRow = canUpdateUsers && (manageable || (isSuperAdminRow && currentRoleName === 'super_admin')); const canDeleteRow = canDeleteUsers && manageable && !isSuperAdminRow; return ( ); })}
Actions

{userName(row)}

{row.email || '—'} {row.phoneNumber || '—'} {locationCell(row.school)} {locationCell(row.campus)} {classCell(row, guardianClassNamesById)} {roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
)} {usersPageCount > 1 && (

Page {usersPage + 1} of {usersPageCount}

)}
); }