From 56a5a08bee42f0cf93ac22bca3462a709d057170 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 16:07:29 +0000 Subject: [PATCH] Milestone 18f: Admin UI (Users / User Groups / Jobs / AppInfo) + Grants tab Co-Authored-By: Bryce Anglin --- e2e/tests/web.spec.ts | 111 ++++ web/src/app/layout/nav-items.ts | 19 +- web/src/app/pages/admin/admin-layout.tsx | 85 +++ web/src/app/pages/admin/app-info-page.tsx | 227 ++++++++ web/src/app/pages/admin/job-create-dialog.tsx | 157 +++++ web/src/app/pages/admin/jobs-page.tsx | 370 ++++++++++++ web/src/app/pages/admin/user-edit-dialog.tsx | 156 +++++ .../pages/admin/user-group-edit-dialog.tsx | 220 +++++++ web/src/app/pages/admin/user-groups-page.tsx | 187 ++++++ web/src/app/pages/admin/users-page.tsx | 237 ++++++++ web/src/app/pages/collections/detail.tsx | 10 +- web/src/app/pages/collections/grants-tab.tsx | 263 +++++++++ web/src/app/router.tsx | 48 +- web/src/lib/api/hooks.ts | 357 ++++++++++++ web/src/lib/api/index.ts | 539 ++++++++++++++++++ 15 files changed, 2957 insertions(+), 29 deletions(-) create mode 100644 web/src/app/pages/admin/admin-layout.tsx create mode 100644 web/src/app/pages/admin/app-info-page.tsx create mode 100644 web/src/app/pages/admin/job-create-dialog.tsx create mode 100644 web/src/app/pages/admin/jobs-page.tsx create mode 100644 web/src/app/pages/admin/user-edit-dialog.tsx create mode 100644 web/src/app/pages/admin/user-group-edit-dialog.tsx create mode 100644 web/src/app/pages/admin/user-groups-page.tsx create mode 100644 web/src/app/pages/admin/users-page.tsx create mode 100644 web/src/app/pages/collections/grants-tab.tsx diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 862b026..15d18e4 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -261,4 +261,115 @@ test.describe('Web SPA', () => { /Will insert: 0/, ) }) + + test('admin layout renders with the four tabs (M18f)', async ({ page }) => { + await page.goto(`${urls.web}/admin/users`) + await expect(page.getByTestId('admin-layout')).toBeVisible() + const tabs = page.getByTestId('admin-tabs') + await expect(tabs).toBeVisible() + await expect(tabs.getByTestId('admin-tab-users')).toBeVisible() + await expect(tabs.getByTestId('admin-tab-user-groups')).toBeVisible() + await expect(tabs.getByTestId('admin-tab-jobs')).toBeVisible() + await expect(tabs.getByTestId('admin-tab-app-info')).toBeVisible() + }) + + test('admin users page lists demo users (M18f)', async ({ page }) => { + await page.goto(`${urls.web}/admin/users`) + await expect(page.getByTestId('admin-users-page')).toBeVisible() + await expect(page.getByTestId('users-table')).toBeVisible() + await expect(page.getByTestId('new-user-button')).toBeVisible() + // The signed-in admin and at least one other demo user should be present. + await expect( + page.getByTestId('users-table').getByText('admin', { exact: true }), + ).toBeVisible({ timeout: 10_000 }) + }) + + test('admin user-groups page renders empty state when none exist (M18f)', async ({ + page, + }) => { + await page.goto(`${urls.web}/admin/user-groups`) + await expect(page.getByTestId('admin-user-groups-page')).toBeVisible() + await expect(page.getByTestId('user-groups-table')).toBeVisible() + await expect(page.getByTestId('new-user-group-button')).toBeVisible() + }) + + test('admin jobs page shows task registry (M18f)', async ({ page }) => { + await page.goto(`${urls.web}/admin/jobs`) + await expect(page.getByTestId('admin-jobs-page')).toBeVisible() + await expect(page.getByTestId('jobs-table')).toBeVisible() + await expect(page.getByTestId('job-tasks-list')).toBeVisible({ + timeout: 10_000, + }) + // The built-in 'noop' task should be in the registry. + await expect( + page.getByTestId('job-tasks-list').getByText('noop'), + ).toBeVisible({ timeout: 10_000 }) + }) + + test('admin can create + run + delete a job (M18f)', async ({ page }) => { + await page.goto(`${urls.web}/admin/jobs`) + await expect(page.getByTestId('admin-jobs-page')).toBeVisible() + await expect(page.getByTestId('job-tasks-list').getByText('noop')).toBeVisible({ + timeout: 10_000, + }) + + // Open the create dialog. + await page.getByTestId('new-job-button').click() + const dialog = page.getByTestId('job-create-dialog') + await expect(dialog).toBeVisible() + const jobName = `e2e-job-${Date.now()}` + await dialog.getByTestId('job-name-input').fill(jobName) + // 'noop' is the default selected task in the dropdown. + await dialog.getByTestId('job-create-submit').click() + await expect(dialog).not.toBeVisible({ timeout: 10_000 }) + + // Find the new row and click Run. + const row = page + .getByTestId('jobs-table') + .getByRole('row') + .filter({ hasText: jobName }) + await expect(row).toBeVisible({ timeout: 10_000 }) + await row.getByRole('button', { name: new RegExp(`run ${jobName}`, 'i') }).click() + + // Run history populates and the output card lands. + await expect(page.getByTestId('job-runs-list')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('job-run-output')).toBeVisible({ + timeout: 10_000, + }) + }) + + test('admin app-info page shows build + counts cards (M18f)', async ({ + page, + }) => { + await page.goto(`${urls.web}/admin/app-info`) + await expect(page.getByTestId('admin-app-info-page')).toBeVisible() + await expect(page.getByTestId('appinfo-build')).toBeVisible({ + timeout: 10_000, + }) + await expect(page.getByTestId('appinfo-counts')).toBeVisible() + await expect(page.getByTestId('appinfo-runtime')).toBeVisible() + await expect(page.getByTestId('appinfo-postgres')).toBeVisible() + await expect(page.getByTestId('appinfo-tables')).toBeVisible() + }) + + test('collection grants tab lists owner grant (M18f)', async ({ page }) => { + // Create a collection so we have a fresh Owner grant on it. + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-grants-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.getByTestId('collection-tab-grants').click() + await expect(page.getByTestId('collection-grants-tab')).toBeVisible() + await expect(page.getByTestId('grants-table')).toBeVisible() + // Add-grant form should be present (admin has Manage+ on its own collection). + await expect(page.getByTestId('add-grant-form')).toBeVisible() + // The creator gets an Owner grant on creation. + await expect( + page.getByTestId('grants-table').getByText('Owner'), + ).toBeVisible({ timeout: 10_000 }) + }) }) diff --git a/web/src/app/layout/nav-items.ts b/web/src/app/layout/nav-items.ts index ee34fd3..36ee1ba 100644 --- a/web/src/app/layout/nav-items.ts +++ b/web/src/app/layout/nav-items.ts @@ -10,6 +10,7 @@ import { Layers, ShieldCheck, Users, + UsersRound, } from 'lucide-react' import type { ScopeName } from '@/lib/auth/scopes' @@ -36,12 +37,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [ icon: BookOpen, scope: 'stig-manager:stig:read', }, - { - to: '/jobs', - label: 'Jobs', - icon: Briefcase, - scope: 'stig-manager:op:read', - }, ] as const export const ADMIN_NAV: readonly NavItem[] = [ @@ -51,6 +46,18 @@ export const ADMIN_NAV: readonly NavItem[] = [ icon: Users, scope: 'stig-manager:user:read', }, + { + to: '/admin/user-groups', + label: 'User Groups', + icon: UsersRound, + scope: 'stig-manager:user:read', + }, + { + to: '/admin/jobs', + label: 'Jobs', + icon: Briefcase, + scope: 'stig-manager:op:read', + }, { to: '/admin/app-info', label: 'App info', diff --git a/web/src/app/pages/admin/admin-layout.tsx b/web/src/app/pages/admin/admin-layout.tsx new file mode 100644 index 0000000..7387fab --- /dev/null +++ b/web/src/app/pages/admin/admin-layout.tsx @@ -0,0 +1,85 @@ +// /admin/* shell. Renders a sub-nav for the admin pages above the +// per-page content. Visibility of each link respects the same scope +// rules as the sidebar so we don't show links the user can't follow. + +import { Briefcase, ClipboardList, Users, UsersRound } from 'lucide-react' +import { NavLink, Outlet } from 'react-router-dom' + +import { useAuth } from '@/lib/auth/auth-context' +import { hasScope, type ScopeName } from '@/lib/auth/scopes' +import { cn } from '@/lib/utils' + +interface AdminNavItem { + to: string + label: string + icon: typeof Users + scope: ScopeName +} + +const ADMIN_TABS: readonly AdminNavItem[] = [ + { + to: '/admin/users', + label: 'Users', + icon: Users, + scope: 'stig-manager:user:read', + }, + { + to: '/admin/user-groups', + label: 'User Groups', + icon: UsersRound, + scope: 'stig-manager:user:read', + }, + { + to: '/admin/jobs', + label: 'Jobs', + icon: Briefcase, + scope: 'stig-manager:op:read', + }, + { + to: '/admin/app-info', + label: 'App info', + icon: ClipboardList, + scope: 'stig-manager:op:read', + }, +] + +export function AdminLayout() { + const { status } = useAuth() + const user = status.kind === 'signed-in' ? status.user : null + const visible = ADMIN_TABS.filter((t) => hasScope(user, t.scope)) + + return ( +
+
+

Administration

+

+ Manage users, groups, background jobs, and runtime state. +

+
+ + +
+ ) +} diff --git a/web/src/app/pages/admin/app-info-page.tsx b/web/src/app/pages/admin/app-info-page.tsx new file mode 100644 index 0000000..e95dcac --- /dev/null +++ b/web/src/app/pages/admin/app-info-page.tsx @@ -0,0 +1,227 @@ +// /admin/app-info — runtime / counts / postgres / request metrics + +// AppData tables list. +// +// Auto-refreshes every 15s while the page is open. + +import { Loader2 } from 'lucide-react' + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { useAppDataTables, useAppInfoDetail } from '@/lib/api/hooks' + +export function AppInfoPage() { + const info = useAppInfoDetail() + const tables = useAppDataTables() + + return ( +
+ {info.isLoading && ( +

+ Loading app info… +

+ )} + {info.isError && ( +

+ Failed to load appinfo: {(info.error as Error).message} +

+ )} + +
+ + + Build + API binary metadata. + + + + + + + + + + + + Row counts + Top-level data totals. + + + + + + + + + + + + + + + + Runtime + Go runtime stats. + + + + + + + + + + + + + + + Postgres + Database state. + + + + + + + + + + + + Requests + + Per-route counters since process start. + + + + + + + + + +
+ + {/* AppData tables list */} + + + AppData tables + + Tables visible to the AppData export, with row + byte totals. + + + + {tables.isLoading && ( +

+ Loading tables… +

+ )} + {tables.isError && ( +

+ Failed to load tables: {(tables.error as Error).message} +

+ )} + {!tables.isLoading && (tables.data?.length ?? 0) === 0 && ( +

+ No tables reported. +

+ )} + {(tables.data?.length ?? 0) > 0 && ( +
+ + + + + + + + + + {(tables.data ?? []).map((t, i) => ( + + + + + + ))} + +
TableRowsData length
{t.name ?? '?'}{fmt(t.rows)} + {fmtBytes(t.dataLength)} +
+
+ )} +
+
+
+ ) +} + +function KV({ + label, + value, + mono = false, +}: { + label: string + value: string + mono?: boolean +}) { + return ( +
+ {label} + {value} +
+ ) +} + +function fmt(v: number | undefined | null): string { + if (v === undefined || v === null) return '—' + return Number(v).toLocaleString() +} + +function fmtBytes(v: number | undefined | null): string { + if (v === undefined || v === null) return '—' + if (v < 1024) return `${v} B` + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KiB` + if (v < 1024 * 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MiB` + return `${(v / 1024 / 1024 / 1024).toFixed(2)} GiB` +} + +function fmtDuration(seconds: number | undefined | null): string { + if (seconds === undefined || seconds === null) return '—' + if (seconds < 60) return `${seconds.toFixed(0)}s` + if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m` + if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h` + return `${(seconds / 86400).toFixed(1)}d` +} diff --git a/web/src/app/pages/admin/job-create-dialog.tsx b/web/src/app/pages/admin/job-create-dialog.tsx new file mode 100644 index 0000000..120da92 --- /dev/null +++ b/web/src/app/pages/admin/job-create-dialog.tsx @@ -0,0 +1,157 @@ +// Create a new Job. Pick one task from the registry and give it a +// name. Schedule (event) configuration is intentionally not exposed +// in this milestone — manual `POST /jobs/{jobId}/runs` only. + +import { Loader2 } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { useCreateJob, type JobTask } from '@/lib/api/hooks' + +interface JobCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tasks: JobTask[] +} + +export function JobCreateDialog({ + open, + onOpenChange, + tasks, +}: JobCreateDialogProps) { + const create = useCreateJob() + const [name, setName] = React.useState('') + const [description, setDescription] = React.useState('') + const [taskName, setTaskName] = React.useState('') + const [error, setError] = React.useState(null) + + React.useEffect(() => { + if (open) { + setName('') + setDescription('') + setTaskName(tasks[0]?.name ?? '') + setError(null) + create.reset() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const busy = create.isPending + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + const trimmedName = name.trim() + if (!trimmedName) { + setError('Job name is required.') + return + } + if (!taskName) { + setError('Pick at least one task.') + return + } + try { + await create.mutateAsync({ + name: trimmedName, + description: description || null, + tasks: [taskName], + }) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Create failed.') + } + } + + return ( + + + + New Job + + Pick a task from the registry and give the job a name. You can run + it ad-hoc from the Jobs table. + + +
+
+ + setName(e.target.value)} + data-testid="job-name-input" + /> +
+
+ +