"use client"; import { useState, useMemo } from "react"; import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.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 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), }); 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; 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")}
{/* 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 */}
{/* 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 */}
)}
); }