feat(m3): RBAC — atomic perms, groups, users, admin SPA pages

Permission catalogue (services/permissions_seed.py)
- 31 atomic codes across 10 families: user.*, group.*, invitation.*,
  test_template.*, scenario_template.*, mission.* (incl.
  mission.write_red_fields + mission.write_blue_fields),
  detection_level.{read,update}, setting.{read,update}, mitre.sync.
- Default bindings: admin = all 31; redteam = 8 (catalogue read + mission.
  {read,create,update,archive,write_red_fields} + detection_level.read);
  blueteam = 5 (catalogue read + mission.{read,write_blue_fields} +
  detection_level.read).
- Seed runs at boot AND after /setup so a freshly truncated DB (via
  /diag/reset) gets the bindings back via the bootstrap path. Idempotent +
  additive (never removes a perm from a system group).

Users admin (services/users.py + api/users.py)
- list (q + is_active filter + pagination), get, patch (display_name /
  locale / is_active with tri-state sentinel for clear-vs-unset),
  soft-delete, set groups.
- Last-admin protection on update (deactivate), delete, and group-strip
  (refusing to remove the admin group from the last active admin).

Groups admin (services/groups.py + api/groups.py)
- Full CRUD with system-group protection (no rename, no delete on
  admin/redteam/blueteam).
- PUT /groups/{id}/permissions sets the perm list.
- Admin system group's perm set is locked to the full catalogue
  (SystemGroupProtected → 409) — preserves the bypass invariant even if a
  future refactor moves to perm-based checks.

Permissions read-only (api/permissions.py)
- GET /permissions returns the catalogue (admin or group.read holders).

/diag/reset extension
- After truncate + token mint, the limiter is also reset (limiter.reset())
  so the Playwright suite doesn't hit 10/min budgets across spec files.
  Guarded by limiter.enabled to no-op in APP_ENV=test.

Rate-limit scope (core/rate_limit.py)
- enabled = APP_ENV in ("prod", "staging"). A staging deployment serves
  humans, so it gets the limits too. Dev/test stay unthrottled for
  Playwright ergonomics. Spec §6 NF-security is an operator-facing
  requirement.

Frontend chrome
- components/RequireAdmin.tsx + ui/Modal.tsx (reusable centered dialog
  with accessible name + Escape + backdrop-click).
- Layout.tsx shows Admin nav links only when is_admin === true. Server
  remains the arbiter — non-admins hitting /admin/* get redirected to /.

Frontend pages
- pages/AdminUsersPage.tsx, AdminGroupsPage.tsx, AdminInvitationsPage.tsx
  with edit modals using TanStack Query mutations + multi-select for perms
  grouped by family + copy-once invitation URL display.
- lib/admin.ts: shared types + query keys + groupPermsByFamily helper.
- lib/api.ts: apiPatch / apiPut / apiDelete added.

Playwright config (e2e/playwright.config.ts)
- workers: 1 + fullyParallel: false: spec files share the live Postgres,
  so concurrent /diag/reset calls clobber each other. Intra-file order
  preserved via test.describe.configure({ mode: 'serial' }).

Testing
- backend/tests/test_rbac.py: 15 integration tests (39 backend total — 1
  health + 8 schema + 15 auth + 15 RBAC).
- e2e/tests/m3-rbac.spec.ts: 8 Playwright tests covering DoD §10 #2/#3
  (28 e2e total — 8 M0 + 4 M1 + 8 M2 + 8 M3).
- tasks/testing-m3.md.

DoD: make test-api → 39 passed, make e2e → 28 passed. Spec-reviewer pass
applied (admin perm invariant + staging rate-limit scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-11 06:17:07 +02:00
parent 700b563297
commit bb23bf3928
15 changed files with 2634 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/lib/auth';
/** Server still arbitrates — this is a UI gate so non-admins don't see admin routes. */
export function RequireAdmin({ children }: { children: ReactNode }) {
const { state } = useAuth();
if (state.loading) {
return <p className="font-mono text-xs text-text-dim p-8">Loading session</p>;
}
if (!state.user) {
return <Navigate to="/login" replace />;
}
if (!state.user.is_admin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { type Accent } from '@/lib/cn';
interface ModalProps {
open: boolean;
title: string;
accent?: Accent;
onClose: () => void;
children: ReactNode;
/** Optional name to give the dialog role for screen readers / Playwright. */
testid?: string;
}
/**
* Centered modal with a backdrop. Closes on Escape and on backdrop click.
* The accessible name comes from the SectionHeader's `highlight`, so the dialog
* can be located via `getByRole('dialog', { name: ... })`.
*/
export function Modal({ open, title, accent = 'cyan', onClose, children, testid }: ModalProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="presentation"
>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid={testid}
className="w-full max-w-2xl rounded-lg border border-border bg-bg-base p-6 shadow-2xl"
>
<div className="flex items-start justify-between gap-4">
<SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" />
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
</Button>
</div>
{children}
</div>
</div>
);
}

72
frontend/src/lib/admin.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Shared types + query keys for admin pages (users / groups / invitations).
* Keeps the React Query cache coherent across the 3 admin pages.
*/
export interface AdminUser {
id: string;
email: string;
display_name: string | null;
locale: string;
is_active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
groups: Array<{ id: string; name: string }>;
}
export interface AdminUserListResponse {
items: AdminUser[];
total: number;
limit: number;
offset: number;
}
export interface AdminGroup {
id: string;
name: string;
description: string | null;
is_system: boolean;
members_count: number;
permissions: string[];
created_at: string;
updated_at: string;
}
export interface AdminGroupListResponse {
items: AdminGroup[];
total: number;
}
export interface AdminPermission {
id: string;
code: string;
description: string | null;
}
export interface AdminInvitation {
id: string;
email_hint: string | null;
expires_at: string;
groups: string[];
}
export const adminKeys = {
users: ['admin', 'users'] as const,
user: (id: string) => ['admin', 'users', id] as const,
groups: ['admin', 'groups'] as const,
group: (id: string) => ['admin', 'groups', id] as const,
permissions: ['admin', 'permissions'] as const,
invitations: ['admin', 'invitations'] as const,
};
/** Group permission codes by family for the multi-select UI. */
export function groupPermsByFamily(codes: string[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const code of codes) {
const [family] = code.split('.', 1);
(out[family] ??= []).push(code);
}
for (const family of Object.keys(out)) out[family].sort();
return out;
}

View File

@@ -0,0 +1,348 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
adminKeys,
groupPermsByFamily,
type AdminGroup,
type AdminGroupListResponse,
type AdminPermission,
} from '@/lib/admin';
function usePermissions() {
return useQuery({
queryKey: adminKeys.permissions,
queryFn: () => apiGet<{ items: AdminPermission[] }>('/permissions'),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminGroupsPage() {
const groups = useGroups();
const perms = usePermissions();
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [creating, setCreating] = useState(false);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Groups"
accent="purple"
description="Compose custom groups; combine atomic permissions to express any role."
/>
<div className="mb-6 flex items-center justify-between">
<span className="font-mono text-2xs text-text-dim">
{groups.data ? `${groups.data.total} group${groups.data.total === 1 ? '' : 's'}` : ''}
</span>
<Button accent="purple" onClick={() => setCreating(true)} data-testid="create-group">
+ New group
</Button>
</div>
{(groups.isError || perms.isError) && (
<Alert accent="red">Failed to load groups or permissions.</Alert>
)}
<div className="grid gap-3" data-testid="groups-table">
{(groups.isLoading || perms.isLoading) && (
<p className="font-mono text-xs text-text-dim">Loading</p>
)}
{groups.data?.items.map((g) => (
<Card key={g.id} accent={g.is_system ? 'yellow' : 'purple'} title={g.name} sub={g.description ?? '—'}>
<div className="flex flex-wrap items-center gap-2">
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
<Tag accent="cyan">{g.members_count} member{g.members_count === 1 ? '' : 's'}</Tag>
<Tag accent="orange">{g.permissions.length} perm{g.permissions.length === 1 ? '' : 's'}</Tag>
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => setEditing(g)} data-testid={`edit-group-${g.name}`}>
Edit
</Button>
</div>
</div>
</Card>
))}
{groups.data && groups.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">No groups yet.</p>
)}
</div>
{creating && perms.data && (
<GroupCreateModal allPerms={perms.data.items} onClose={() => setCreating(false)} />
)}
{editing && perms.data && (
<GroupEditModal
group={editing}
allPerms={perms.data.items}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
interface PermsMultiSelectProps {
allPerms: AdminPermission[];
selected: Set<string>;
onToggle: (code: string) => void;
}
function PermsMultiSelect({ allPerms, selected, onToggle }: PermsMultiSelectProps) {
const byFamily = useMemo(
() => groupPermsByFamily(allPerms.map((p) => p.code)),
[allPerms],
);
return (
<div className="max-h-72 overflow-y-auto rounded border border-border bg-bg-card p-3" data-testid="perms-multiselect">
{Object.entries(byFamily).map(([family, codes]) => (
<div key={family} className="mb-3 last:mb-0">
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
{family}
</p>
<div className="flex flex-wrap gap-2">
{codes.map((code) => (
<label
key={code}
className="inline-flex items-center gap-2 rounded border border-border px-2 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={selected.has(code)}
onChange={() => onToggle(code)}
data-testid={`perm-${code}`}
/>
<span className="font-mono text-2xs">{code}</span>
</label>
))}
</div>
</div>
))}
</div>
);
}
function GroupCreateModal({
allPerms,
onClose,
}: {
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const createGroup = useMutation({
mutationFn: () =>
apiPost<AdminGroup>('/groups', {
name: name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: (groupId: string) =>
apiPut(`/groups/${groupId}/permissions`, { codes: Array.from(selected) }),
});
async function save() {
setError(null);
try {
const g = await createGroup.mutateAsync();
if (selected.size) await setPerms.mutateAsync(g.id);
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
return (
<Modal open onClose={onClose} title="new group" accent="purple" testid="group-create-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
hint="Lower-case-with-dashes recommended (e.g. pentest-2026-Q2)"
required
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-create-save">
Create group
</Button>
</div>
</div>
</Modal>
);
}
function GroupEditModal({
group,
allPerms,
onClose,
}: {
group: AdminGroup;
allPerms: AdminPermission[];
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(group.name);
const [description, setDescription] = useState(group.description ?? '');
const [selected, setSelected] = useState<Set<string>>(new Set(group.permissions));
const [error, setError] = useState<string | null>(null);
const patchGroup = useMutation({
mutationFn: () =>
apiPatch(`/groups/${group.id}`, {
name: group.is_system ? undefined : name.trim(),
description: description.trim() || null,
}),
});
const setPerms = useMutation({
mutationFn: () =>
apiPut(`/groups/${group.id}/permissions`, { codes: Array.from(selected) }),
});
const del = useMutation({
mutationFn: () => apiDelete(`/groups/${group.id}`),
});
async function save() {
setError(null);
try {
await patchGroup.mutateAsync();
await setPerms.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
async function handleDelete() {
if (!confirm(`Soft-delete group "${group.name}"?`)) return;
try {
await del.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.groups });
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Delete failed');
}
}
}
return (
<Modal open onClose={onClose} title={group.name} accent="purple" testid="group-edit-modal">
<div className="space-y-4">
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={group.is_system}
hint={group.is_system ? 'System groups cannot be renamed.' : undefined}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Permissions
</p>
<PermsMultiSelect
allPerms={allPerms}
selected={selected}
onToggle={(code) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
})
}
/>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex items-center justify-between gap-3 pt-2">
{!group.is_system && (
<Button accent="rose" onClick={handleDelete}>
Delete group
</Button>
)}
<div className="ml-auto flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="purple" onClick={save} data-testid="group-edit-save">
Save changes
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,233 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import {
adminKeys,
type AdminGroupListResponse,
type AdminInvitation,
} from '@/lib/admin';
function useInvitations() {
return useQuery({
queryKey: adminKeys.invitations,
queryFn: () => apiGet<AdminInvitation[]>('/invitations'),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminInvitationsPage() {
const invs = useInvitations();
const groups = useGroups();
const [creating, setCreating] = useState(false);
const [showLink, setShowLink] = useState<string | null>(null);
const qc = useQueryClient();
const revoke = useMutation({
mutationFn: (id: string) => apiPost(`/invitations/${id}/revoke`),
onSuccess: () => qc.invalidateQueries({ queryKey: adminKeys.invitations }),
});
function buildLink(token: string): string {
return `${window.location.origin}/register?token=${encodeURIComponent(token)}`;
}
return (
<>
<SectionHeader
prefix="Admin"
highlight="Invitations"
accent="green"
description="Issue one-shot URLs to onboard new operators. Links expire after 7 days by default."
/>
<div className="mb-6 flex items-center justify-between">
<span className="font-mono text-2xs text-text-dim">
{invs.data ? `${invs.data.length} active invitation${invs.data.length === 1 ? '' : 's'}` : ''}
</span>
<Button accent="green" onClick={() => setCreating(true)} data-testid="create-invitation">
+ New invitation
</Button>
</div>
{invs.isError && <Alert accent="red">Failed to load invitations.</Alert>}
<div className="grid gap-3" data-testid="invitations-table">
{invs.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{invs.data?.map((inv) => (
<Card
key={inv.id}
accent="green"
title={inv.email_hint ?? '(no email hint)'}
sub={`expires ${new Date(inv.expires_at).toLocaleString()}`}
>
<div className="flex flex-wrap items-center gap-2">
{inv.groups.map((g) => (
<Tag key={g} accent="purple">
{g}
</Tag>
))}
{inv.groups.length === 0 && <Tag accent="orange">no pre-assigned groups</Tag>}
<Button
accent="rose"
className="ml-auto"
onClick={() => {
if (!confirm(`Revoke invitation for ${inv.email_hint ?? '(no email)'}?`)) return;
revoke.mutate(inv.id);
}}
data-testid={`revoke-${inv.id}`}
>
Revoke
</Button>
</div>
</Card>
))}
{invs.data && invs.data.length === 0 && (
<p className="font-mono text-xs text-text-dim">No active invitations.</p>
)}
</div>
{creating && groups.data && (
<InvitationCreateModal
allGroups={groups.data.items}
onClose={() => setCreating(false)}
onCreated={(token) => setShowLink(buildLink(token))}
/>
)}
{showLink && (
<Modal open onClose={() => setShowLink(null)} title="invitation link" accent="green">
<div className="space-y-3">
<p className="font-mono text-xs text-text-dim">
Copy this URL and send it to the invitee. It will be shown <strong>only once</strong>.
</p>
<code
className="block break-all rounded border border-green bg-bg-card p-3 font-mono text-2xs text-text-bright"
data-testid="invitation-link"
>
{showLink}
</code>
<div className="flex justify-end gap-2">
<Button
accent="green"
onClick={async () => {
await navigator.clipboard.writeText(showLink);
}}
>
Copy
</Button>
<Button variant="ghost" onClick={() => setShowLink(null)}>
Close
</Button>
</div>
</div>
</Modal>
)}
</>
);
}
function InvitationCreateModal({
allGroups,
onClose,
onCreated,
}: {
allGroups: AdminGroupListResponse['items'];
onClose: () => void;
onCreated: (token: string) => void;
}) {
const qc = useQueryClient();
const [emailHint, setEmailHint] = useState('');
const [groupIds, setGroupIds] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
apiPost<{ id: string; token: string; expires_at: string }>('/invitations', {
email_hint: emailHint.trim() || null,
group_ids: groupIds,
}),
});
async function save() {
setError(null);
try {
const r = await create.mutateAsync();
await qc.invalidateQueries({ queryKey: adminKeys.invitations });
onClose();
onCreated(r.token);
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Create failed');
}
}
}
return (
<Modal open onClose={onClose} title="new invitation" accent="green" testid="invitation-create-modal">
<div className="space-y-4">
<TextField
label="Email hint"
placeholder="alice@metamorph.local"
value={emailHint}
onChange={(e) => setEmailHint(e.target.value)}
hint="Optional — purely informative, shown in the admin list."
/>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Pre-assigned groups
</p>
<div className="flex flex-wrap gap-2" data-testid="invitation-groups">
{allGroups.map((g) => {
const checked = groupIds.includes(g.id);
return (
<label
key={g.id}
className="inline-flex items-center gap-2 rounded border border-border bg-bg-card px-3 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setGroupIds((prev) =>
e.target.checked ? [...prev, g.id] : prev.filter((id) => id !== g.id),
)
}
data-testid={`invitation-group-${g.name}`}
/>
<span className="font-mono text-xs">{g.name}</span>
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
</label>
);
})}
</div>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="green" onClick={save} data-testid="invitation-create-save">
Generate link
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,260 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPut,
} from '@/lib/api';
import {
adminKeys,
type AdminGroupListResponse,
type AdminUser,
type AdminUserListResponse,
} from '@/lib/admin';
function useUsers(q: string) {
return useQuery({
queryKey: [...adminKeys.users, q],
queryFn: () => apiGet<AdminUserListResponse>(`/users${q ? `?q=${encodeURIComponent(q)}` : ''}`),
});
}
function useGroups() {
return useQuery({
queryKey: adminKeys.groups,
queryFn: () => apiGet<AdminGroupListResponse>('/groups'),
});
}
export function AdminUsersPage() {
const [q, setQ] = useState('');
const [editing, setEditing] = useState<AdminUser | null>(null);
const { data, isLoading, isError, error } = useUsers(q);
const groupsQuery = useGroups();
return (
<>
<SectionHeader
prefix="Admin"
highlight="Users"
accent="cyan"
description="Manage operator accounts, group memberships, and active status."
/>
<div className="mb-6 flex items-end gap-3">
<TextField
label="Search"
placeholder="email or display name"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-sm"
/>
<span className="font-mono text-2xs text-text-dim mb-3">
{data ? `${data.total} user${data.total === 1 ? '' : 's'}` : ''}
</span>
</div>
{isError && (
<Alert accent="red">
{(error instanceof ApiError && (error.payload as { error?: string })?.error) ||
'Failed to load users.'}
</Alert>
)}
<div className="grid gap-3" data-testid="users-table">
{isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{data?.items.map((u) => (
<Card
key={u.id}
accent={u.is_active ? 'cyan' : 'rose'}
title={u.email}
sub={u.display_name ?? '—'}
>
<div className="flex flex-wrap items-center gap-2">
{u.groups.map((g) => (
<Tag key={g.id} accent="purple">
{g.name}
</Tag>
))}
{!u.is_active && <Tag accent="rose">DISABLED</Tag>}
<div className="ml-auto flex gap-2">
<Button
accent="cyan"
onClick={() => setEditing(u)}
data-testid={`edit-user-${u.email}`}
>
Edit
</Button>
</div>
</div>
</Card>
))}
{data && data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">No users match.</p>
)}
</div>
{editing && groupsQuery.data && (
<UserEditModal
user={editing}
allGroups={groupsQuery.data.items}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
interface UserEditModalProps {
user: AdminUser;
allGroups: AdminGroupListResponse['items'];
onClose: () => void;
}
function UserEditModal({ user, allGroups, onClose }: UserEditModalProps) {
const qc = useQueryClient();
const [displayName, setDisplayName] = useState(user.display_name ?? '');
const [locale, setLocale] = useState(user.locale);
const [isActive, setIsActive] = useState(user.is_active);
const [groupIds, setGroupIds] = useState<string[]>(user.groups.map((g) => g.id));
const [error, setError] = useState<string | null>(null);
const invalidate = () =>
Promise.all([
qc.invalidateQueries({ queryKey: adminKeys.users }),
qc.invalidateQueries({ queryKey: adminKeys.groups }),
]);
const updateMeta = useMutation({
mutationFn: () =>
apiPatch(`/users/${user.id}`, {
display_name: displayName.trim() || null,
locale,
is_active: isActive,
}),
onSuccess: invalidate,
});
const updateGroups = useMutation({
mutationFn: () => apiPut(`/users/${user.id}/groups`, { group_ids: groupIds }),
onSuccess: invalidate,
});
const softDelete = useMutation({
mutationFn: () => apiDelete(`/users/${user.id}`),
onSuccess: () => invalidate().then(onClose),
});
async function handleSave() {
setError(null);
try {
await updateMeta.mutateAsync();
await updateGroups.mutateAsync();
onClose();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Save failed');
}
}
}
async function handleDelete() {
setError(null);
if (!confirm(`Soft-delete user ${user.email}? They will be deactivated and hidden.`)) return;
try {
await softDelete.mutateAsync();
} catch (e) {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setError(e instanceof Error ? e.message : 'Delete failed');
}
}
}
return (
<Modal open onClose={onClose} title={user.email} accent="cyan" testid="user-edit-modal">
<div className="space-y-4">
<TextField
label="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<TextField
label="Locale"
value={locale}
onChange={(e) => setLocale(e.target.value)}
hint="ISO-639-1 (fr or en)"
/>
<label className="flex items-center gap-2 font-mono text-xs text-text-dim">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<span>Account active</span>
</label>
<div>
<p className="font-mono text-3xs uppercase tracking-wider2 text-text-dim mb-2">
Groups
</p>
<div className="flex flex-wrap gap-2" data-testid="group-checkboxes">
{allGroups.map((g) => {
const checked = groupIds.includes(g.id);
return (
<label
key={g.id}
className="inline-flex items-center gap-2 rounded border border-border bg-bg-card px-3 py-1 cursor-pointer hover:border-cyan"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setGroupIds((prev) =>
e.target.checked ? [...prev, g.id] : prev.filter((id) => id !== g.id),
)
}
data-testid={`group-checkbox-${g.name}`}
/>
<span className="font-mono text-xs">{g.name}</span>
{g.is_system && <Tag accent="yellow">SYSTEM</Tag>}
</label>
);
})}
</div>
</div>
{error && <Alert accent="red">{error}</Alert>}
<div className="flex items-center justify-between gap-3 pt-2">
<Button accent="rose" onClick={handleDelete}>
Soft-delete user
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button accent="cyan" onClick={handleSave} data-testid="user-save">
Save changes
</Button>
</div>
</div>
</div>
</Modal>
);
}