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:
348
frontend/src/pages/AdminGroupsPage.tsx
Normal file
348
frontend/src/pages/AdminGroupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
frontend/src/pages/AdminInvitationsPage.tsx
Normal file
233
frontend/src/pages/AdminInvitationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
frontend/src/pages/AdminUsersPage.tsx
Normal file
260
frontend/src/pages/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user