1134 lines
46 KiB
TypeScript
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>
|
|
);
|
|
}
|