chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)

- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:49:29 +02:00
parent 605fd7cea1
commit 82acc56b8d
38 changed files with 2901 additions and 1251 deletions
+241 -69
View File
@@ -1,7 +1,13 @@
"use client";
import { useState, useMemo } from "react";
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, MILLISECONDS_PER_DAY, type PermissionOverrides } from "@capakraken/shared";
import {
SystemRole,
PermissionKey,
ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY,
type PermissionOverrides,
} from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js";
@@ -99,13 +105,17 @@ export function UsersClient() {
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 [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 [deleteTarget, setDeleteTarget] = useState<{ userId: string; userName: string } | null>(
null,
);
const utils = trpc.useUtils();
@@ -145,8 +155,7 @@ export function UsersClient() {
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
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
@@ -322,7 +331,10 @@ export function UsersClient() {
async function handleSaveRole() {
if (!editState) return;
setActionError(null);
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
await updateRoleMutation.mutateAsync({
id: editState.userId,
systemRole: editState.systemRole,
});
}
async function handleSavePermissions() {
@@ -358,7 +370,8 @@ export function UsersClient() {
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 (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q))
return false;
}
if (roleFilter && u.systemRole !== roleFilter) return false;
return true;
@@ -408,7 +421,9 @@ export function UsersClient() {
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
...(roleFilter
? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }]
: []),
];
function isOnline(user: UserRow) {
@@ -431,9 +446,7 @@ export function UsersClient() {
<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>
<p className="app-page-subtitle mt-1">Manage user roles and permission overrides</p>
</div>
<div className="flex items-center gap-3">
{activeData && (
@@ -449,16 +462,25 @@ export function UsersClient() {
)}
<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);
})}
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" />
<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>
@@ -468,17 +490,30 @@ export function UsersClient() {
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" />
<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); }}
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" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Create User
</button>
@@ -501,7 +536,9 @@ export function UsersClient() {
>
<option value="">All Roles</option>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
<option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))}
</select>
</div>
@@ -530,13 +567,54 @@ export function UsersClient() {
<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>
<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>
@@ -566,7 +644,8 @@ export function UsersClient() {
<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]
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ??
ROLE_BADGE_COLORS[SystemRole.USER]
}`}
>
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
@@ -591,7 +670,10 @@ export function UsersClient() {
</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">
<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>
)}
@@ -611,8 +693,18 @@ export function UsersClient() {
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
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>
@@ -628,8 +720,18 @@ export function UsersClient() {
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
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>
@@ -645,7 +747,11 @@ export function UsersClient() {
<button
type="button"
onClick={() => {
if (confirm(`Deactivate ${user.name ?? user.email}? They will be logged out immediately and cannot log in until reactivated.`)) {
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 });
}
}}
@@ -668,7 +774,9 @@ export function UsersClient() {
)}
<button
type="button"
onClick={() => setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })}
onClick={() =>
setDeleteTarget({ userId: user.id, userName: user.name ?? user.email })
}
className="app-action-delete"
title="Permanently delete user"
>
@@ -711,7 +819,8 @@ export function UsersClient() {
/>
{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
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""}{" "}
needed
</p>
)}
</div>
@@ -729,9 +838,7 @@ export function UsersClient() {
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>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Passwords do not match</p>
)}
</div>
</div>
@@ -747,7 +854,11 @@ export function UsersClient() {
<button
type="button"
onClick={() => void handleSetPassword()}
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
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"}
@@ -770,12 +881,13 @@ export function UsersClient() {
</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>?
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.
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">
@@ -810,7 +922,10 @@ export function UsersClient() {
</h2>
<button
type="button"
onClick={() => { setCreateState(null); setActionError(null); }}
onClick={() => {
setCreateState(null);
setActionError(null);
}}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
@@ -839,7 +954,8 @@ export function UsersClient() {
<div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
Email{" "}
<InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
</label>
<input
type="email"
@@ -852,7 +968,8 @@ export function UsersClient() {
<div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
Password{" "}
<InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
</label>
<input
type="password"
@@ -866,15 +983,20 @@ export function UsersClient() {
<div>
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
Role{" "}
<InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
</label>
<select
value={createState.systemRole}
onChange={(e) => setCreateState({ ...createState, systemRole: e.target.value as SystemRole })}
onChange={(e) =>
setCreateState({ ...createState, systemRole: e.target.value as SystemRole })
}
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"
>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
<option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))}
</select>
</div>
@@ -883,7 +1005,10 @@ export function UsersClient() {
<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={() => { setCreateState(null); setActionError(null); }}
onClick={() => {
setCreateState(null);
setActionError(null);
}}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
@@ -891,7 +1016,12 @@ export function UsersClient() {
<button
type="button"
onClick={() => void handleCreateUser()}
disabled={isPending || !createState.name.trim() || !createState.email.trim() || createState.password.length < 8}
disabled={
isPending ||
!createState.name.trim() ||
!createState.email.trim() ||
createState.password.length < 8
}
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"
>
{createUserMutation.isPending ? "Creating..." : "Create User"}
@@ -941,14 +1071,22 @@ export function UsersClient() {
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && editingName.name.trim()) {
updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() });
updateNameMutation.mutate({
id: editingName.userId,
name: editingName.name.trim(),
});
}
if (e.key === "Escape") setEditingName(null);
}}
/>
<button
type="button"
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })}
onClick={() =>
updateNameMutation.mutate({
id: editingName.userId,
name: editingName.name.trim(),
})
}
disabled={!editingName.name.trim() || updateNameMutation.isPending}
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
@@ -984,7 +1122,8 @@ export function UsersClient() {
{/* System Role */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
System Role{" "}
<InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
</h3>
<div className="flex items-center gap-3">
<select
@@ -1014,17 +1153,25 @@ export function UsersClient() {
{/* Permissions */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
Permissions{" "}
<InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
</h3>
<div className="flex gap-1.5 mb-3 text-[11px]">
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" />{" "}
Role default
</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" />{" "}
Extra grant
</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">&times;</span></span> Denied
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative">
<span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">
&times;
</span>
</span>{" "}
Denied
</span>
</div>
<div className="space-y-1">
@@ -1067,8 +1214,10 @@ export function UsersClient() {
}
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",
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",
};
@@ -1087,28 +1236,50 @@ export function UsersClient() {
onClick={cycleState}
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
>
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
<span
className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}
>
{state === "default" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
{state === "granted" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
)}
{state === "denied" && (
<span className="text-xs font-bold leading-none">&times;</span>
)}
</span>
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
<span
className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}
>
{PERMISSION_LABELS[key] ?? key}
</span>
{state === "default" && (
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">
Role
</span>
)}
{state === "granted" && (
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">
Extra
</span>
)}
{state === "denied" && (
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">
Denied
</span>
)}
</button>
);
@@ -1118,7 +1289,8 @@ export function UsersClient() {
{/* Chapter Scope */}
<div className="mt-4">
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
Chapter Scope (comma-separated IDs, leave blank for all){" "}
<InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
</label>
<input
type="text"