"use client"; import { useState, useMemo } from "react"; import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; const ALL_PERMISSION_KEYS = Object.values(PermissionKey); const PERMISSION_LABELS: Record = { viewCosts: "View Costs", exportData: "Export Data", importData: "Import Data", approveVacations: "Approve Vacations", manageBlueprints: "Manage Blueprints", viewAllResources: "View All Resources", manageResources: "Manage Resources", manageProjects: "Manage Projects", manageAllocations: "Manage Allocations", manageRoles: "Manage Roles", manageUsers: "Manage Users", }; 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; }; type EditState = { userId: string; systemRole: SystemRole; granted: Set; denied: Set; chapterIds: string; }; type CreateState = { name: string; email: string; password: string; systemRole: SystemRole; }; 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 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, }); // Build dynamic role defaults map from DB config (fallback to hardcoded) 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), }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema 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), }); 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); } function toggleGranted(key: string) { if (!editState) return; const next = new Set(editState.granted); const nextDenied = new Set(editState.denied); if (next.has(key)) { next.delete(key); } else { next.add(key); nextDenied.delete(key); } setEditState({ ...editState, granted: next, denied: nextDenied }); } function toggleDenied(key: string) { if (!editState) return; const next = new Set(editState.denied); const nextGranted = new Set(editState.granted); if (next.has(key)) { next.delete(key); } else { next.add(key); nextGranted.delete(key); } setEditState({ ...editState, denied: next, granted: nextGranted }); } 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[]; // Client-side filtering 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 < 86_400_000) 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} {isOnline(user) ? ( Online ) : ( Offline )} {formatRelativeTime(user.lastLoginAt)} {new Date(user.createdAt).toLocaleDateString("en-GB")}
{/* 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

)}
{/* Create User Modal */} {createState && (

Create User

{actionError && (
{actionError}
)}
setCreateState({ ...createState, name: e.target.value })} placeholder="Max Mustermann" 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" />
setCreateState({ ...createState, email: e.target.value })} placeholder="user@example.com" 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" />
setCreateState({ ...createState, password: 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" />
)} {/* Edit Modal */} {editState && selectedUser && (
{/* Modal Header */}

Edit User

{selectedUser.name ?? selectedUser.email}

{/* Modal Body */}
{/* User Name */}

Display Name

{editingName?.userId === editState.userId ? (
setEditingName({ ...editingName, name: e.target.value })} className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" autoFocus onKeyDown={(e) => { if (e.key === "Enter" && editingName.name.trim()) { updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() }); } if (e.key === "Escape") setEditingName(null); }} />
) : (
{(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"}
)}
{/* System Role */}

System Role

{/* Permissions */}

Permissions

Role default Extra grant × Denied
{ALL_PERMISSION_KEYS.map((key) => { const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []); const isRoleDefault = roleDefaults.has(key as PermissionKey); const isGranted = editState.granted.has(key); const isDenied = editState.denied.has(key); // Determine display state let state: "default" | "granted" | "denied" | "off"; if (isDenied) state = "denied"; else if (isGranted) state = "granted"; else if (isRoleDefault) state = "default"; else state = "off"; function cycleState() { if (!editState) return; const nextGranted = new Set(editState.granted); const nextDenied = new Set(editState.denied); if (isRoleDefault) { // Role default: off → denied → off if (isDenied) { nextDenied.delete(key); } else { nextDenied.add(key); nextGranted.delete(key); } } else { // Non-default: off → granted → off if (isGranted) { nextGranted.delete(key); } else { nextGranted.add(key); nextDenied.delete(key); } } setEditState({ ...editState, granted: nextGranted, denied: nextDenied }); } const stateStyles = { default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800", denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700", }; const checkStyles = { default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40", granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40", denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40", off: "text-gray-400 border-gray-300 dark:border-gray-600", }; return ( ); })}
{/* Chapter Scope */}
setEditState({ ...editState, chapterIds: e.target.value })} placeholder="e.g. chapter-1, chapter-2" 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" />
{/* Modal Footer */}
)}
); }