349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|