Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions e2e/tests/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
19 changes: 13 additions & 6 deletions web/src/app/layout/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Layers,
ShieldCheck,
Users,
UsersRound,
} from 'lucide-react'

import type { ScopeName } from '@/lib/auth/scopes'
Expand All @@ -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[] = [
Expand All @@ -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',
Expand Down
85 changes: 85 additions & 0 deletions web/src/app/pages/admin/admin-layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6" data-testid="admin-layout">
<header>
<h1 className="text-3xl font-semibold tracking-tight">Administration</h1>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Manage users, groups, background jobs, and runtime state.
</p>
</header>
<nav
className="flex flex-wrap gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-card)]/40 p-1"
data-testid="admin-tabs"
>
{visible.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cn(
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
isActive
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)]'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)]/40 hover:text-[var(--color-accent-foreground)]',
)
}
data-testid={`admin-tab-${tab.label.toLowerCase().replace(/\s+/g, '-')}`}
>
<tab.icon className="size-4" />
<span>{tab.label}</span>
</NavLink>
))}
</nav>
<Outlet />
</div>
)
}
Loading
Loading