Files
Nexus/apps/web/src/components/admin/UsersClient.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

894 lines
35 KiB
TypeScript

"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, string> = {
[SystemRole.ADMIN]: "Admin",
[SystemRole.MANAGER]: "Manager",
[SystemRole.CONTROLLER]: "Controller",
[SystemRole.USER]: "User",
[SystemRole.VIEWER]: "Viewer",
};
const ROLE_BADGE_COLORS: Record<SystemRole, string> = {
[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<string, number> = {
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<string | null>(null);
const [editState, setEditState] = useState<EditState | null>(null);
const [createState, setCreateState] = useState<CreateState | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
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<string | null>(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<string, string[]> = {};
for (const c of roleConfigs) {
map[c.role] = c.defaultPermissions as string[];
}
return map as Record<SystemRole, string[]>;
}, [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 (
<div className="app-page">
<div className="app-page-header mb-6">
<div>
<h1 className="app-page-title">User Management</h1>
<p className="app-page-subtitle mt-1">Manage user roles and permission overrides</p>
</div>
<div className="flex items-center gap-3">
{activeData && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-3 py-2 text-sm">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="font-medium text-green-700 dark:text-green-400">
{activeData.count} online
</span>
</div>
)}
<button
type="button"
onClick={() =>
void autoLinkMutation.mutateAsync().then((r) => {
setActionError(
r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`,
);
if (r.linked > 0) setActionError(null);
})
}
disabled={autoLinkMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
title="Auto-link user accounts to resources by matching email addresses"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
</button>
<button
type="button"
onClick={() => setInviteOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-brand-300 dark:border-brand-600 px-4 py-2 text-sm font-medium text-brand-700 dark:text-brand-300 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
Invite User
</button>
<button
type="button"
onClick={() => {
setCreateState({ ...EMPTY_CREATE });
setActionError(null);
}}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Create User
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => 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"
/>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as SystemRole | "")}
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 bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All Roles</option>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))}
</select>
</div>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* User Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked."
/>
<SortableColumnHeader
label="Email"
field="email"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email."
/>
<SortableColumnHeader
label="Role"
field="systemRole"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
align="center"
tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog."
tooltipWidth="w-80"
/>
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">
Status
</th>
<SortableColumnHeader
label="Last Login"
field="lastLoginAt"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="When the user last signed in."
/>
<SortableColumnHeader
label="Created"
field="createdAt"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
tooltip="Account creation date."
/>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
No users found.
</td>
</tr>
)}
{sorted.map((user) => (
<tr
key={user.id}
className={`border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors ${!user.isActive ? "opacity-60" : ""}`}
>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{user.name ?? <span className="italic text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email}</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ??
ROLE_BADGE_COLORS[SystemRole.USER]
}`}
>
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1.5">
{!user.isActive ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
Inactive
</span>
) : isOnline(user) ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
Offline
</span>
)}
{user.totpEnabled && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400"
title="TOTP MFA enabled"
>
MFA
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{formatRelativeTime(user.lastLoginAt)}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{new Date(user.createdAt).toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openSetPassword(user)}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
title="Set password"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
Password
</button>
{user.totpEnabled && (
<button
type="button"
onClick={() => {
if (confirm(`Disable MFA for ${user.name ?? user.email}?`)) {
void disableTotpMutation.mutateAsync({ userId: user.id });
}
}}
disabled={disableTotpMutation.isPending}
className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
title="Disable TOTP MFA for this user"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Disable MFA
</button>
)}
<button
type="button"
onClick={() => openEdit(user)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
{user.isActive ? (
<button
type="button"
onClick={() => {
if (
confirm(
`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`,
)
) {
void deactivateMutation.mutateAsync({ userId: user.id });
}
}}
disabled={deactivateMutation.isPending}
className="text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
title="Deactivate user — blocks login and revokes sessions"
>
Deactivate
</button>
) : (
<button
type="button"
onClick={() => void reactivateMutation.mutateAsync({ userId: user.id })}
disabled={reactivateMutation.isPending}
className="text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 font-medium"
title="Reactivate user — allows login again"
>
Reactivate
</button>
)}
<button
type="button"
onClick={() =>
setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })
}
className="app-action-delete"
title="Permanently delete user"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Set Password Modal */}
<AnimatedModal open={!!passwordTarget} onClose={closeSetPassword} maxWidth="max-w-md">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Set Password for {passwordTarget?.userName}
</h2>
</div>
<div className="px-6 py-5 space-y-4">
{passwordError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
{passwordError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "}
needed
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Passwords do not match</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={closeSetPassword}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSetPassword()}
disabled={
setPasswordMutation.isPending ||
newPassword.length < 8 ||
newPassword !== confirmPassword
}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
</button>
</div>
</AnimatedModal>
<SuccessToast show={passwordSuccess} message="Password updated successfully" />
<InviteUserModal open={inviteOpen} onClose={() => setInviteOpen(false)} />
{/* Delete Confirmation Modal */}
{deleteTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Delete User
</h2>
</div>
<div className="px-6 py-5 space-y-3">
<p className="text-sm text-gray-700 dark:text-gray-300">
Are you sure you want to permanently delete <strong>{deleteTarget.userName}</strong>
?
</p>
<p className="text-sm text-red-600 dark:text-red-400">
This will permanently remove their account, sessions, vacation records, and
notifications. Audit history entries will be retained but anonymised. This action
cannot be undone.
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setDeleteTarget(null)}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => void deleteMutation.mutateAsync({ userId: deleteTarget.userId })}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-700 text-white font-medium transition-colors disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete permanently"}
</button>
</div>
</div>
</div>
)}
{/* Create User Modal */}
{createState && (
<UserCreateModal
state={createState}
actionError={actionError}
isPending={isPending}
createPending={createUserMutation.isPending}
onChange={setCreateState}
onSubmit={() => void handleCreateUser()}
onClose={() => {
setCreateState(null);
setActionError(null);
}}
/>
)}
{/* Edit Modal */}
{editState && selectedUser && (
<UserEditModal
editState={editState}
selectedUserName={selectedUser.name ?? selectedUser.email}
editingName={editingName}
roleDefaultsMap={roleDefaultsMap}
isPending={isPending}
updateRolePending={updateRoleMutation.isPending}
setPermissionsPending={setPermissionsMutation.isPending}
resetPermissionsPending={resetPermissionsMutation.isPending}
updateNamePending={updateNameMutation.isPending}
onEditStateChange={setEditState}
onSaveRole={() => 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}
/>
)}
</div>
);
}