"use client"; import { useState, useMemo } from "react"; import type { PermissionKey } from "@nexus/shared"; import { SystemRole, ROLE_DEFAULT_PERMISSIONS, MILLISECONDS_PER_DAY, type PermissionOverrides, } from "@nexus/shared"; import { trpc } from "~/lib/trpc/client.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { InviteUserModal } from "./InviteUserModal.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { UserEditModal, type EditState } from "./UserEditModal.js"; import { UserCreateModal, type CreateState } from "./UserCreateModal.js"; const SYSTEM_ROLE_LABELS: Record = { [SystemRole.ADMIN]: "Admin", [SystemRole.MANAGER]: "Manager", [SystemRole.CONTROLLER]: "Controller", [SystemRole.USER]: "User", [SystemRole.VIEWER]: "Viewer", }; const ROLE_BADGE_COLORS: Record = { [SystemRole.ADMIN]: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400", [SystemRole.MANAGER]: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400", [SystemRole.CONTROLLER]: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400", [SystemRole.USER]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", [SystemRole.VIEWER]: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500", }; // Lower = more privileged (sort asc = most privileged first) const ROLE_ORDER: Record = { ADMIN: 0, MANAGER: 1, CONTROLLER: 2, USER: 3, VIEWER: 4, }; type UserRow = { id: string; name: string | null; email: string; systemRole: string; createdAt: Date; lastLoginAt: Date | null; lastActiveAt: Date | null; permissionOverrides: PermissionOverrides | null; totpEnabled: boolean; isActive: boolean; }; const EMPTY_CREATE: CreateState = { name: "", email: "", password: "", systemRole: SystemRole.USER, }; export function UsersClient() { const [selectedUserId, setSelectedUserId] = useState(null); const [editState, setEditState] = useState(null); const [createState, setCreateState] = useState(null); const [actionError, setActionError] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null); const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>( null, ); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordError, setPasswordError] = useState(null); const [passwordSuccess, setPasswordSuccess] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>( null, ); const utils = trpc.useUtils(); const { data: users, isLoading } = trpc.user.list.useQuery(undefined, { staleTime: 10_000, }); const { data: roleConfigs } = trpc.systemRoleConfig.list.useQuery(undefined, { staleTime: 60_000, }); const roleDefaultsMap = useMemo(() => { if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS; const map: Record = {}; for (const c of roleConfigs) { map[c.role] = c.defaultPermissions as string[]; } return map as Record; }, [roleConfigs]); const { data: activeData } = trpc.user.activeCount.useQuery(undefined, { staleTime: 30_000, refetchInterval: 30_000, }); const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery( { userId: selectedUserId ?? "" }, { enabled: !!selectedUserId }, ); const updateRoleMutation = trpc.user.updateRole.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); await utils.user.getEffectivePermissions.invalidate(); }, onError: (err) => setActionError(err.message), }); const setPermissionsMutation = trpc.user.setPermissions.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); await utils.user.getEffectivePermissions.invalidate(); }, onError: (err) => setActionError(err.message), }); const createUserMutation = trpc.user.create.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setCreateState(null); setActionError(null); }, onError: (err) => setActionError(err.message), }); const autoLinkMutation = trpc.user.autoLinkAllByEmail.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); }, onError: (err) => setActionError(err.message), }); const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); await utils.user.getEffectivePermissions.invalidate(); if (editState) { setEditState({ ...editState, granted: new Set(), denied: new Set(), chapterIds: "" }); } }, onError: (err) => setActionError(err.message), }); const setPasswordMutation = trpc.user.setPassword.useMutation({ onSuccess: () => { setPasswordSuccess(true); setNewPassword(""); setConfirmPassword(""); setPasswordError(null); setTimeout(() => { setPasswordTarget(null); setPasswordSuccess(false); }, 1500); }, onError: (err) => setPasswordError(err.message), }); const updateNameMutation = trpc.user.updateName.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setEditingName(null); }, onError: (err) => setActionError(err.message), }); const disableTotpMutation = trpc.user.disableTotp.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setActionError(null); }, onError: (err) => setActionError(err.message), }); const deactivateMutation = trpc.user.deactivate.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setActionError(null); }, onError: (err) => setActionError(err.message), }); const reactivateMutation = trpc.user.reactivate.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setActionError(null); }, onError: (err) => setActionError(err.message), }); const deleteMutation = trpc.user.delete.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); setDeleteTarget(null); setActionError(null); }, onError: (err) => { setActionError(err.message); setDeleteTarget(null); }, }); function openSetPassword(user: UserRow) { setPasswordTarget({ userId: user.id, userName: user.name ?? user.email }); setNewPassword(""); setConfirmPassword(""); setPasswordError(null); setPasswordSuccess(false); } function closeSetPassword() { setPasswordTarget(null); setNewPassword(""); setConfirmPassword(""); setPasswordError(null); setPasswordSuccess(false); } async function handleSetPassword() { if (!passwordTarget) return; if (newPassword.length < 8) { setPasswordError("Password must be at least 8 characters"); return; } if (newPassword !== confirmPassword) { setPasswordError("Passwords do not match"); return; } setPasswordError(null); await setPasswordMutation.mutateAsync({ userId: passwordTarget.userId, password: newPassword, }); } function openEdit(user: UserRow) { const role = (user.systemRole as SystemRole) ?? SystemRole.USER; const overrides = user.permissionOverrides as PermissionOverrides | null; setSelectedUserId(user.id); setEditState({ userId: user.id, systemRole: role, granted: new Set(overrides?.granted ?? []), denied: new Set(overrides?.denied ?? []), chapterIds: (overrides?.chapterIds ?? []).join(", "), }); setActionError(null); } function closeEdit() { setSelectedUserId(null); setEditState(null); setActionError(null); } async function handleSaveRole() { if (!editState) return; setActionError(null); await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole, }); } async function handleSavePermissions() { if (!editState) return; setActionError(null); const granted = Array.from(editState.granted); const denied = Array.from(editState.denied); const chapterIds = editState.chapterIds .split(",") .map((s) => s.trim()) .filter(Boolean); const overrides: PermissionOverrides = { ...(granted.length > 0 ? { granted: granted as unknown as PermissionKey[] } : {}), ...(denied.length > 0 ? { denied: denied as unknown as PermissionKey[] } : {}), ...(chapterIds.length > 0 ? { chapterIds } : {}), }; const hasOverrides = granted.length > 0 || denied.length > 0 || chapterIds.length > 0; await setPermissionsMutation.mutateAsync({ userId: editState.userId, overrides: hasOverrides ? overrides : null, }); } async function handleReset() { if (!editState) return; setActionError(null); await resetPermissionsMutation.mutateAsync({ userId: editState.userId }); } const allUsers = (users ?? []) as unknown as UserRow[]; const filteredUsers = allUsers.filter((u) => { if (search) { const q = search.toLowerCase(); if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false; } if (roleFilter && u.systemRole !== roleFilter) return false; return true; }); const usersViewPrefs = useViewPrefs("users"); const { sorted, sortField, sortDir, toggle } = useTableSort(filteredUsers, { initialField: usersViewPrefs.savedSort?.field ?? null, initialDir: usersViewPrefs.savedSort?.dir ?? null, onSortChange: (field, dir) => { usersViewPrefs.setSavedSort(field && dir ? { field, dir } : null); }, }); function handleSort(field: string) { if (field === "systemRole") { toggle("systemRole", (u) => ROLE_ORDER[u.systemRole] ?? 99); } else { toggle(field as keyof UserRow); } } const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null; async function handleCreateUser() { if (!createState) return; setActionError(null); await createUserMutation.mutateAsync({ name: createState.name, email: createState.email, password: createState.password, systemRole: createState.systemRole, }); } const isPending = updateRoleMutation.isPending || setPermissionsMutation.isPending || resetPermissionsMutation.isPending || createUserMutation.isPending || setPasswordMutation.isPending; function clearAll() { setSearch(""); setRoleFilter(""); } const chips = [ ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []), ]; function isOnline(user: UserRow) { if (!user.lastActiveAt) return false; return Date.now() - new Date(user.lastActiveAt).getTime() < 5 * 60 * 1000; } function formatRelativeTime(date: Date | null) { if (!date) return "Never"; const d = new Date(date); const diff = Date.now() - d.getTime(); if (diff < 60_000) return "Just now"; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < MILLISECONDS_PER_DAY) return `${Math.floor(diff / 3_600_000)}h ago`; return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); } return (

User Management

Manage user roles and permission overrides

{activeData && (
{activeData.count} online
)}
{/* Filters */}
setSearch(e.target.value)} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100" />
{chips.length > 0 && (
)} {actionError && (
{actionError}
)} {/* User Table */}
{isLoading && ( )} {!isLoading && sorted.length === 0 && ( )} {sorted.map((user) => ( ))}
Status Actions
Loading…
No users found.
{user.name ?? } {user.email} {SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
{!user.isActive ? ( Inactive ) : isOnline(user) ? ( Online ) : ( Offline )} {user.totpEnabled && ( MFA )}
{formatRelativeTime(user.lastLoginAt)} {new Date(user.createdAt).toLocaleDateString("en-GB")}
{user.totpEnabled && ( )} {user.isActive ? ( ) : ( )}
{/* Set Password Modal */}

Set Password for {passwordTarget?.userName}

{passwordError && (
{passwordError}
)}
setNewPassword(e.target.value)} placeholder="Min. 8 characters" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" autoComplete="new-password" /> {newPassword.length > 0 && newPassword.length < 8 && (

{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "} needed

)}
setConfirmPassword(e.target.value)} placeholder="Repeat password" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" autoComplete="new-password" /> {confirmPassword.length > 0 && newPassword !== confirmPassword && (

Passwords do not match

)}
setInviteOpen(false)} /> {/* Delete Confirmation Modal */} {deleteTarget && (

Delete User

Are you sure you want to permanently delete {deleteTarget.userName} ?

This will permanently remove their account, sessions, vacation records, and notifications. Audit history entries will be retained but anonymised. This action cannot be undone.

)} {/* Create User Modal */} {createState && ( void handleCreateUser()} onClose={() => { setCreateState(null); setActionError(null); }} /> )} {/* Edit Modal */} {editState && selectedUser && ( void handleSaveRole()} onSavePermissions={() => void handleSavePermissions()} onReset={() => void handleReset()} onClose={closeEdit} onEditingNameChange={setEditingName} onSaveName={(userId, name) => updateNameMutation.mutate({ id: userId, name })} currentName={(allUsers.find((u) => u.id === editState.userId)?.name ?? "") as string} /> )}
); }