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('/groups'), }); } export function AdminGroupsPage() { const groups = useGroups(); const perms = usePermissions(); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); return ( <>
{groups.data ? `${groups.data.total} group${groups.data.total === 1 ? '' : 's'}` : ''}
{(groups.isError || perms.isError) && ( Failed to load groups or permissions. )}
{(groups.isLoading || perms.isLoading) && (

Loading…

)} {groups.data?.items.map((g) => (
{g.is_system && SYSTEM} {g.members_count} member{g.members_count === 1 ? '' : 's'} {g.permissions.length} perm{g.permissions.length === 1 ? '' : 's'}
))} {groups.data && groups.data.items.length === 0 && (

No groups yet.

)}
{creating && perms.data && ( setCreating(false)} /> )} {editing && perms.data && ( setEditing(null)} /> )} ); } interface PermsMultiSelectProps { allPerms: AdminPermission[]; selected: Set; onToggle: (code: string) => void; } function PermsMultiSelect({ allPerms, selected, onToggle }: PermsMultiSelectProps) { const byFamily = useMemo( () => groupPermsByFamily(allPerms.map((p) => p.code)), [allPerms], ); return (
{Object.entries(byFamily).map(([family, codes]) => (

{family}

{codes.map((code) => ( ))}
))}
); } function GroupCreateModal({ allPerms, onClose, }: { allPerms: AdminPermission[]; onClose: () => void; }) { const qc = useQueryClient(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [selected, setSelected] = useState>(new Set()); const [error, setError] = useState(null); const createGroup = useMutation({ mutationFn: () => apiPost('/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 (
setName(e.target.value)} hint="Lower-case-with-dashes recommended (e.g. pentest-2026-Q2)" required /> setDescription(e.target.value)} />

Permissions

setSelected((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }) } />
{error && {error}}
); } 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>(new Set(group.permissions)); const [error, setError] = useState(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 (
setName(e.target.value)} disabled={group.is_system} hint={group.is_system ? 'System groups cannot be renamed.' : undefined} /> setDescription(e.target.value)} />

Permissions

setSelected((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }) } />
{error && {error}}
{!group.is_system && ( )}
); }