40227-vm/frontend/src/pages/modules/UserAdminPage.tsx

1134 lines
46 KiB
TypeScript

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<Exclude<RoleTenantInput, 'none' | 'guardian'>, 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 (
<span className="flex min-w-[140px] items-center gap-2">
<TenantLogo
name={name}
logoUrl={value?.logo ?? null}
className="h-7 w-7 rounded-md text-[9px]"
/>
<span className="truncate">{name}</span>
</span>
);
}
function classNamesForUser(
row: AdminUserRow,
guardianClassNamesById: ReadonlyMap<string, readonly string[]>,
): 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<string, readonly string[]>,
) {
const classNames = classNamesForUser(row, guardianClassNamesById);
if (classNames.length === 0) {
return '—';
}
return (
<span className="block min-w-[140px] truncate">
{classNames.join(', ')}
</span>
);
}
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<UserListSortField>('name');
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('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<string, string>();
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<string, RoleRow>();
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<string | null>(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<string | null>(null);
const [role, setRole] = useState<UserRole | ''>('');
const [pickedTenantId, setPickedTenantId] = useState<string | null>(null);
const [classId, setClassId] = useState('');
const [studentIds, setStudentIds] = useState<string[]>([]);
const [grantPerms, setGrantPerms] = useState<string[]>([]);
const [excludePerms, setExcludePerms] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(null);
const [createdCredential, setCreatedCredential] = useState<CreatedCredential | null>(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<string, string>();
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<string, string[]>();
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 <PageSkeleton />;
}
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 <ArrowUpDown className="h-3.5 w-3.5 text-slate-500" />;
}
if (usersSortDirection === 'asc') {
return <ArrowUp className="h-3.5 w-3.5 text-sky-300" />;
}
return <ArrowDown className="h-3.5 w-3.5 text-sky-300" />;
}
return (
<div className="space-y-6 px-3 py-4 md:px-4 md:py-5">
<ModuleHeader
title="Users"
description="Create and manage users within your scope."
icon={Users}
iconClassName="bg-gradient-to-br from-sky-500 to-sky-700"
/>
{(canCreateUsers || editingId) && (
<Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={isUserFormOpen}
aria-controls="user-admin-form"
onClick={() => setIsUserFormOpen((open) => !open)}
>
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />}
<span>{editingId ? 'Edit user' : 'Add new user'}</span>
<ChevronDown
size={16}
className={cn(
'ml-auto shrink-0 text-slate-400 transition-transform',
isUserFormOpen && 'rotate-180',
)}
/>
</button>
{editingId && (
<Button
type="button"
variant="ghost"
onClick={resetForm}
className="ml-auto h-auto px-2 py-1 text-xs text-slate-400"
>
Cancel edit
</Button>
)}
</CardTitle>
</CardHeader>
{isUserFormOpen && (
<CardContent id="user-admin-form" className="px-4 pb-4 md:px-5">
<form className="space-y-5 pt-6" onSubmit={handleSubmit}>
<div className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4 space-y-4">
<div>
<p className="text-sm font-semibold text-slate-100">User details</p>
<p className="mt-1 text-xs text-slate-300">
Basic identity fields used for login, invitations, and profile display.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
<ImageUpload
value={avatar}
onChange={setAvatar}
table="users"
field="avatar"
label="Avatar"
shape="square"
previewSize="lg"
/>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="prefix" className="text-slate-100">Title</Label>
<NativeSelect
id="prefix"
value={namePrefix}
className={formControlClassName}
onChange={(e) => setNamePrefix(e.target.value)}
>
<option value="">None</option>
{USER_NAME_PREFIX_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="firstName" className="text-slate-100">First name</Label>
<Input
id="firstName"
value={firstName}
className={formControlClassName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lastName" className="text-slate-100">Last name</Label>
<Input
id="lastName"
value={lastName}
className={formControlClassName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email" className="text-slate-100">Email</Label>
<Input
id="email"
type="email"
value={email}
className={formControlClassName}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="phoneNumber" className="text-slate-100">Phone number</Label>
<Input
id="phoneNumber"
type="tel"
value={phoneNumber}
className={formControlClassName}
onChange={(e) => setPhoneNumber(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-sky-400/50 bg-sky-950/35 p-4 space-y-4">
<div>
<p className="text-sm font-semibold text-slate-100">Access</p>
<p className="mt-1 text-xs text-sky-100/85">
Choose the user's role and the organization or location where this account should work.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="role" className="text-slate-100">Role</Label>
<NativeSelect
id="role"
value={selectedRole}
disabled={editingSuperAdmin}
className={formControlClassName}
onChange={(e) => {
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 ? (
<option value="">No available roles</option>
) : (
roleOptions.map((r) => (
<option key={r} value={r}>{getAuthRoleLabel(r)}</option>
))
)}
</NativeSelect>
{editingSuperAdmin && (
<p className="text-xs text-slate-300">
The platform has one Super Admin. This account can be updated, but its role cannot be reassigned.
</p>
)}
</div>
{needsPicker && targetLevel && (
<TenantParentPicker ownTier={tier} targetLevel={targetLevel} onChange={handlePickerChange} />
)}
{tenantInput === 'class' && (
<div className="space-y-1.5">
<Label htmlFor="class" className="text-slate-100">Class</Label>
<NativeSelect
id="class"
value={classId}
disabled={!campusForClass}
className={formControlClassName}
onChange={(e) => setClassId(e.target.value)}
>
<option value="">Select class...</option>
{(classQuery.data?.rows ?? []).map((row) => (
<option key={row.id} value={row.id}>{row.name ?? row.id}</option>
))}
</NativeSelect>
</div>
)}
{tenantInput === 'guardian' && !editingId && (
<div className="space-y-1.5">
<Label className="text-slate-100">Students</Label>
<Popover open={isStudentPickerOpen} onOpenChange={setIsStudentPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="h-12 w-full justify-between border-slate-600 bg-slate-950/80 px-3 text-left text-slate-100 hover:bg-slate-900"
>
<span className={cn('truncate', studentIds.length === 0 && 'text-slate-400')}>
{studentPickerLabel(guardianStudentOptions, studentIds)}
</span>
<ChevronDown className="ml-3 h-4 w-4 shrink-0 text-slate-400" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] border-slate-700 bg-slate-950 p-0 text-slate-100"
>
<Command className="bg-slate-950 text-slate-100">
<CommandInput placeholder="Search students..." />
<CommandList className="max-h-64">
<CommandEmpty>No students found in your scope.</CommandEmpty>
<CommandGroup>
{guardianStudentOptions.map((student) => {
const selected = studentIds.includes(student.id);
return (
<CommandItem
key={student.id}
value={`${userName(student)} ${student.email}`}
onSelect={() => {
setStudentIds((prev) =>
selected
? prev.filter((id) => id !== student.id)
: [...prev, student.id],
);
}}
className="flex cursor-pointer items-center gap-2 text-slate-100"
>
<span
className={cn(
'flex h-4 w-4 items-center justify-center rounded border border-slate-500',
selected && 'border-sky-400 bg-sky-500 text-white',
)}
aria-hidden="true"
>
{selected && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{userName(student)}</span>
<span className="block truncate text-xs text-slate-400">{student.email}</span>
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{canManageAdvancedPermissions && (
<details className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4">
<summary className="cursor-pointer text-sm font-semibold text-slate-100">
Advanced permissions (optional)
</summary>
<p className="mt-2 mb-3 text-xs text-slate-300">
Add permissions on top of the role, or exclude permissions the role would grant. The role's
base permissions apply automatically.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="grant-perms" className="text-slate-100">Add permissions</Label>
<div
id="grant-perms"
className="max-h-[26rem] min-h-[8rem] space-y-1 overflow-y-auto rounded-md border border-slate-600 bg-slate-950/80 p-2"
>
{addablePermissions.length === 0 ? (
<p className="px-2 py-1 text-xs text-slate-400">No additional permissions available.</p>
) : (
addablePermissions.map((permission) => (
<button
key={permission.id}
type="button"
onClick={() => includePermission(permission.id)}
className={cn(
'flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-slate-200 transition',
'hover:bg-emerald-500/15 hover:text-emerald-100',
)}
>
<span className="truncate">{permissionLabel(permission.name, permission.id)}</span>
<span className="ml-3 shrink-0 text-xs text-emerald-300">Add</span>
</button>
))
)}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="exclude-perms" className="text-slate-100">Exclude permissions</Label>
<div
id="exclude-perms"
className="max-h-[26rem] min-h-[8rem] space-y-1 overflow-y-auto rounded-md border border-slate-600 bg-slate-950/80 p-2"
>
{excludablePermissions.length === 0 ? (
<p className="px-2 py-1 text-xs text-slate-400">No effective permissions to exclude.</p>
) : (
excludablePermissions.map((permission) => (
<button
key={permission.id}
type="button"
onClick={() => excludePermission(permission.id)}
className={cn(
'flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-slate-100 transition',
'hover:bg-amber-500/15 hover:text-amber-100',
)}
>
<span className="truncate">{permissionLabel(permission.name, permission.id)}</span>
<span className="ml-3 shrink-0 text-xs text-amber-300">Exclude</span>
</button>
))
)}
</div>
</div>
</div>
</details>
)}
{createdCredential && (
<div className="rounded-lg border border-amber-400/50 bg-amber-950/30 p-4 text-amber-50">
<p className="text-sm font-semibold">Temporary password</p>
<p className="mt-1 text-xs text-amber-100/85">
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.
</p>
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="created-user-password" className="text-amber-50">
{createdCredential.email}
</Label>
<Input
id="created-user-password"
value={createdCredential.password}
readOnly
className="border-amber-400/50 bg-slate-950/80 font-mono text-amber-50"
/>
</div>
<Button
type="button"
variant="outline"
onClick={copyCreatedPassword}
className="self-end border-amber-300/60 text-amber-50 hover:bg-amber-400/10"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={saving || (editingId ? !canUpdateUsers : !canCreateUsers)}
>
{saving && <Loader2 size={16} className="mr-2 animate-spin" />}
{editingId ? 'Save changes' : 'Create user'}
</Button>
</div>
</form>
</CardContent>
)}
</Card>
)}
{status && (
<div
className={`rounded-lg border px-3 py-2 text-sm ${
status.type === 'success'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-red-500/30 bg-red-500/10 text-red-200'
}`}
>
{status.text}
</div>
)}
<Card>
<CardHeader className="px-4 py-4 md:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">Users ({usersTotal})</CardTitle>
{usersTotal > 0 && (
<p className="text-xs text-slate-400">
Showing {usersStart}-{usersEnd} of {usersTotal}
</p>
)}
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4 md:px-5">
<form
className="flex flex-col gap-3 rounded-lg border border-slate-700/60 bg-slate-950/35 p-3 md:flex-row md:items-center"
onSubmit={handleUsersSearchSubmit}
>
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<Input
value={usersSearchDraft}
onChange={(event) => setUsersSearchDraft(event.target.value)}
placeholder="Search by name, email, phone, school, campus, class, or role"
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Button type="submit" variant="outline" disabled={usersQuery.isFetching}>
Search
</Button>
<Button
type="button"
variant="ghost"
disabled={!hasUsersSearch && usersSearchDraft.length === 0}
onClick={clearUsersSearch}
>
<X className="mr-2 h-4 w-4" />
Clear
</Button>
</div>
</form>
{rows.length === 0 ? (
<p className="text-sm text-slate-400">
{hasUsersSearch ? 'No users match the current search.' : 'No users yet.'}
</p>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-700/60">
<table className="min-w-full divide-y divide-slate-700/60 text-sm">
<thead className="bg-slate-950/60 text-left text-xs uppercase tracking-wide text-slate-400">
<tr>
{([
['name', 'User'],
['email', 'Email'],
['phoneNumber', 'Phone'],
['school', 'School'],
['campus', 'Campus'],
['class', 'Class'],
['role', 'Role'],
] as const).map(([field, label]) => (
<th key={field} className="px-3 py-3 font-medium">
<button
type="button"
onClick={() => toggleUsersSort(field)}
className="inline-flex items-center gap-1.5 text-left text-slate-300 transition hover:text-white"
>
<span>{label}</span>
{renderSortIcon(field)}
</button>
</th>
))}
<th className="w-[88px] px-3 py-3 text-right font-medium text-slate-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50 bg-slate-950/20">
{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 (
<tr key={row.id} className="align-middle">
<td className="px-3 py-2.5">
<div className="flex min-w-[180px] items-center gap-3">
<UserAvatar
name={userName(row)}
avatarUrl={row.avatar?.[0]?.privateUrl ?? null}
className="h-9 w-9 text-[11px]"
/>
<div className="min-w-0">
<p className="truncate font-medium text-slate-100">{userName(row)}</p>
</div>
</div>
</td>
<td className="px-3 py-2.5 text-slate-300 break-all">{row.email || '—'}</td>
<td className="px-3 py-2.5 text-slate-300">{row.phoneNumber || '—'}</td>
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.school)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.campus)}</td>
<td className="px-3 py-2.5 text-slate-300">
{classCell(row, guardianClassNamesById)}
</td>
<td className="px-3 py-2.5 text-slate-300">
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1">
<Button
type="button"
variant="ghost"
disabled={!canEditRow}
onClick={() => startEdit(row)}
className="h-8 w-8 px-0"
title="Edit"
aria-label={`Edit ${userName(row)}`}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
disabled={!canDeleteRow}
onClick={() => handleDelete(row)}
className="h-8 w-8 px-0 text-red-400 hover:bg-red-500/10"
title="Delete"
aria-label={`Delete ${userName(row)}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{usersPageCount > 1 && (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-700/50 pt-4">
<p className="text-xs text-slate-400">
Page {usersPage + 1} of {usersPageCount}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={usersPage === 0 || usersQuery.isFetching}
onClick={() => setUsersPage((page) => Math.max(0, page - 1))}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={usersPage >= usersPageCount - 1 || usersQuery.isFetching}
onClick={() => setUsersPage((page) => Math.min(usersPageCount - 1, page + 1))}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}