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:
@@ -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"
|
||||
>
|
||||
×
|
||||
@@ -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">×</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">
|
||||
×
|
||||
</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">×</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"
|
||||
|
||||
Reference in New Issue
Block a user