diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 0567caf..587533a 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -345,6 +345,93 @@ test.describe('Web SPA', () => { ).toContainText('host-ckl-1') }) + test('asset review workspace renders 3-pane layout and respects URL state (M23)', async ({ + page, + request, + }) => { + // Make sure the SPA has rehydrated storageState so we can read + // the OIDC token for the API hops below. + await page.goto(urls.web) + const token = await readAccessToken(page) + + // Import the sample XCCDF so TEST_OS_STIG/V2R3 exists in the + // library. The newly-created asset will be mapped to it via the + // create-asset form. + const xccdfPath = join( + process.cwd(), + '..', + 'api', + 'internal', + 'xccdf', + 'testdata', + 'sample.xccdf.xml', + ) + const xccdf = readFileSync(xccdfPath) + const imp = await request.post(`${urls.api}/api/stigs?clobber=true`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + importFile: { + name: 'TEST_OS_STIG.xml', + mimeType: 'application/xml', + buffer: xccdf, + }, + }, + }) + expect(imp.status(), await imp.text()).toBe(200) + + // Create a fresh collection. + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-workspace-coll-${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 }) + const collectionId = page.url().match(/\/collections\/(\d+)/)?.[1] + expect(collectionId).toBeTruthy() + + // Create an asset. The dialog's STIG checkbox is sourced from + // `useSTIGs()` (library-wide) so TEST_OS_STIG will be selectable + // once the import above completes. + await page.getByTestId('collection-tab-assets').click() + await page.getByTestId('new-asset-button').click() + const adialog = page.getByTestId('new-asset-dialog') + await adialog.getByTestId('asset-name-input').fill('e2e-workspace-host') + // Pick the STIG from the multi-select if available; otherwise the + // form will create the asset with no STIGs and the workspace will + // show the empty-STIGs state, which is still a valid smoke test. + const stigCheckbox = adialog.getByTestId('asset-stig-TEST_OS_STIG') + if (await stigCheckbox.isVisible().catch(() => false)) { + await stigCheckbox.check() + } + await adialog.getByTestId('new-asset-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+\/assets\/\d+$/, { + timeout: 10_000, + }) + const assetId = page.url().match(/\/assets\/(\d+)/)?.[1] + expect(assetId).toBeTruthy() + + // Click the workspace CTA on the asset detail page. + const openWorkspace = page.getByTestId('asset-open-workspace') + await expect(openWorkspace).toBeVisible() + await openWorkspace.click() + await expect(page).toHaveURL( + new RegExp(`/collections/${collectionId}/assets/${assetId}/workspace`), + ) + + // Three panes are visible. + await expect(page.getByTestId('review-workspace')).toBeVisible() + await expect(page.getByTestId('workspace-rule-list')).toBeVisible() + await expect(page.getByTestId('workspace-rule-detail')).toBeVisible() + await expect(page.getByTestId('workspace-review-pane')).toBeVisible() + // STIG picker is visible regardless of whether anything was + // mapped. + await expect(page.getByTestId('workspace-stig-select')).toBeVisible() + // Search input gets focus when '/' is pressed. + await page.keyboard.press('/') + await expect(page.getByTestId('workspace-search')).toBeFocused() + }) + 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() diff --git a/web/src/app/pages/assets/detail.tsx b/web/src/app/pages/assets/detail.tsx index 6221856..7936b49 100644 --- a/web/src/app/pages/assets/detail.tsx +++ b/web/src/app/pages/assets/detail.tsx @@ -5,7 +5,7 @@ // to show its rules + each rule's current review result. Clicking a // rule navigates to the single-rule review editor. -import { ArrowLeft, Loader2, Pencil, Trash2 } from 'lucide-react' +import { ArrowLeft, ClipboardCheck, Loader2, Pencil, Trash2 } from 'lucide-react' import * as React from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' @@ -122,26 +122,39 @@ export function AssetDetailPage() {

)} - {canEdit && ( -
- - -
- )} + Open Review Workspace + + + {canEdit && ( + <> + + + + )} +
diff --git a/web/src/app/pages/assets/workspace.tsx b/web/src/app/pages/assets/workspace.tsx new file mode 100644 index 0000000..7b949f7 --- /dev/null +++ b/web/src/app/pages/assets/workspace.tsx @@ -0,0 +1,1042 @@ +// /collections/:collectionId/assets/:assetId/workspace +// +// Three-pane review workspace for an Asset. +// +// Left ~30% Rule list for the selected STIG (filter + sort + row select) +// Middle ~40% Selected rule's RuleDetail (vuln discussion, check, fix, ccis) +// Right ~30% Review Resources tabs (History / Other Assets / Status text) +// stacked above the Evaluation form +// +// State that survives reload is encoded in the URL as +// ?stig=&rule=. Selecting a row pushes the +// `rule` param; selecting a STIG pushes both. +// +// Keyboard shortcuts (only when the rule list has focus or no input is +// focused — text inputs/textareas swallow the keys naturally): +// +// j / ArrowDown next rule +// k / ArrowUp prev rule +// f result -> fail +// p result -> pass +// n result -> notapplicable +// u result -> notchecked +// Ctrl+Enter save the current evaluation +// Ctrl+Shift+Enter save + advance to next unreviewed rule +// / focus the search box + +import { + ArrowLeft, + ChevronLeft, + ChevronRight, + Loader2, +} from 'lucide-react' +import * as React from 'react' +import { Link, useParams, useSearchParams } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + useAsset, + useAssetStigs, + useCurrentUser, + usePutReview, + useReview, + useReviewHistory, + useReviewsByAsset, + useRuleByRuleId, + useRulesByRevision, + type Review, + type ReviewResult, + type ReviewStatusLabel, + type Rule, +} from '@/lib/api/hooks' +import { atLeast, roleForCollection } from '@/lib/auth/roles' + +const RESULT_OPTIONS: ReviewResult[] = [ + 'fail', + 'pass', + 'notapplicable', + 'notchecked', + 'unknown', + 'error', + 'notselected', + 'informational', + 'fixed', +] + +const STATUS_OPTIONS: { value: ReviewStatusLabel | ''; label: string }[] = [ + { value: '', label: 'Keep current (saved)' }, + { value: 'saved', label: 'Saved' }, + { value: 'submitted', label: 'Submitted' }, +] + +type StatusFilter = '' | 'unreviewed' | ReviewStatusLabel + +export function AssetReviewWorkspacePage() { + const { collectionId, assetId } = useParams<{ + collectionId: string + assetId: string + }>() + const [params, setParams] = useSearchParams() + const me = useCurrentUser() + const role = roleForCollection(me.data, collectionId ?? '') + const canEdit = atLeast(role, 2) + + const asset = useAsset(assetId) + const stigs = useAssetStigs(assetId) + const reviews = useReviewsByAsset(collectionId, assetId) + + // STIG selection. Default to the first assigned STIG if the URL + // has none. + const stigParam = params.get('stig') ?? '' + const selectedStig = + stigs.data?.find((s) => s.benchmarkId === stigParam) ?? + stigs.data?.[0] + + React.useEffect(() => { + if (selectedStig && !stigParam) { + setParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('stig', selectedStig.benchmarkId) + return next + }, + { replace: true }, + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedStig?.benchmarkId, stigParam]) + + // Rules for the selected STIG (basic projection — full detail is + // loaded per-rule on selection). + const rules = useRulesByRevision( + selectedStig?.benchmarkId, + selectedStig?.revisionStr ?? (selectedStig ? 'latest' : undefined), + ) + + const reviewsByRule = React.useMemo(() => { + const m = new Map() + for (const r of reviews.data ?? []) { + const id = r.ruleId ?? r.ruleIds?.[0] + if (id) m.set(id, r) + } + return m + }, [reviews.data]) + + // ---- Rule list filters ------------------------------------------ + const [search, setSearch] = React.useState('') + const [severity, setSeverity] = React.useState<'' | 'high' | 'medium' | 'low'>('') + const [statusFilter, setStatusFilter] = React.useState('') + const [resultFilter, setResultFilter] = React.useState<'' | ReviewResult>('') + const searchRef = React.useRef(null) + + const filteredRules = React.useMemo(() => { + if (!rules.data) return [] as Rule[] + const needle = search.trim().toLowerCase() + return rules.data.filter((r) => { + if (severity && r.severity !== severity) return false + const rev = reviewsByRule.get(r.ruleId) + if (statusFilter === 'unreviewed' && rev) return false + if ( + statusFilter !== '' && + statusFilter !== 'unreviewed' && + rev?.status?.label !== statusFilter + ) + return false + if (resultFilter && rev?.result !== resultFilter) return false + if (!needle) return true + return ( + r.ruleId.toLowerCase().includes(needle) || + r.title.toLowerCase().includes(needle) || + (r.groupId?.toLowerCase().includes(needle) ?? false) + ) + }) + }, [rules.data, search, severity, statusFilter, resultFilter, reviewsByRule]) + + // ---- Selected rule ---------------------------------------------- + const ruleParam = params.get('rule') ?? '' + const selectedRuleId = React.useMemo(() => { + if (ruleParam && filteredRules.some((r) => r.ruleId === ruleParam)) { + return ruleParam + } + return filteredRules[0]?.ruleId ?? '' + }, [ruleParam, filteredRules]) + + // Whenever the URL `rule` param drifts out of the filtered set, + // fall back to the first row but don't loop on it. + React.useEffect(() => { + if (!selectedRuleId) return + if (selectedRuleId !== ruleParam) { + setParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('rule', selectedRuleId) + return next + }, + { replace: true }, + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedRuleId]) + + function selectRule(ruleId: string) { + setParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('rule', ruleId) + return next + }, + { replace: false }, + ) + } + + // Auto-scroll the selected row into view. + React.useEffect(() => { + const el = document.querySelector( + `[data-testid="ws-rule-row-${selectedRuleId}"]`, + ) + if (el) (el as HTMLElement).scrollIntoView({ block: 'nearest' }) + }, [selectedRuleId]) + + const ruleDetail = useRuleByRuleId(selectedRuleId || undefined) + const review = useReview(collectionId, assetId, selectedRuleId || undefined) + const history = useReviewHistory(collectionId, { + assetId, + ruleId: selectedRuleId || undefined, + }) + + // ---- Evaluation form state -------------------------------------- + const [result, setResult] = React.useState('notchecked') + const [detail, setDetail] = React.useState('') + const [comment, setComment] = React.useState('') + const [status, setStatus] = React.useState('') + const [savedAt, setSavedAt] = React.useState(null) + const [error, setError] = React.useState(null) + const put = usePutReview() + + React.useEffect(() => { + if (review.isPending) return + if (review.data) { + setResult(review.data.result) + setDetail(review.data.detail ?? '') + setComment(review.data.comment ?? '') + setStatus('') + } else { + setResult('notchecked') + setDetail('') + setComment('') + setStatus('') + } + setSavedAt(null) + setError(null) + }, [review.isPending, review.data, selectedRuleId]) + + async function save(advance: boolean) { + if (!collectionId || !assetId || !selectedRuleId) return + setError(null) + setSavedAt(null) + try { + await put.mutateAsync({ + collectionId, + assetId, + ruleId: selectedRuleId, + body: { + result, + detail, + comment, + ...(status ? { status } : {}), + }, + }) + setSavedAt(new Date().toLocaleTimeString()) + if (advance) { + const idx = filteredRules.findIndex( + (r) => r.ruleId === selectedRuleId, + ) + const nextRule = + filteredRules.slice(idx + 1).find((r) => !reviewsByRule.has(r.ruleId)) ?? + filteredRules[idx + 1] + if (nextRule) selectRule(nextRule.ruleId) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save review.') + } + } + + // ---- Keyboard shortcuts ----------------------------------------- + React.useEffect(() => { + if (!canEdit) return + function onKey(e: KeyboardEvent) { + const tgt = e.target as HTMLElement | null + const tag = tgt?.tagName ?? '' + const isText = + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + (tgt?.isContentEditable ?? false) + + if (e.ctrlKey && e.key === 'Enter') { + e.preventDefault() + void save(e.shiftKey) + return + } + if (isText) return + + if (e.key === '/') { + e.preventDefault() + searchRef.current?.focus() + return + } + const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp' + if (e.key === 'j' || e.key === 'k' || arrow) { + e.preventDefault() + const idx = filteredRules.findIndex((r) => r.ruleId === selectedRuleId) + const dir = e.key === 'j' || e.key === 'ArrowDown' ? 1 : -1 + const nxt = filteredRules[Math.min( + Math.max(idx + dir, 0), + filteredRules.length - 1, + )] + if (nxt) selectRule(nxt.ruleId) + return + } + if (e.key === 'f') setResult('fail') + else if (e.key === 'p') setResult('pass') + else if (e.key === 'n') setResult('notapplicable') + else if (e.key === 'u') setResult('notchecked') + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredRules, selectedRuleId, canEdit, result, detail, comment, status]) + + // ---- Render ----------------------------------------------------- + if (!collectionId || !assetId) return null + + if (asset.isLoading) { + return ( +
+ Loading workspace… +
+ ) + } + + if (asset.isError || !asset.data) { + return ( +
+ + Back to Collection + +

Asset not found.

+
+ ) + } + + const counts = countByResult(filteredRules, reviewsByRule) + + return ( +
+ {/* Breadcrumb + STIG picker -------------------------------- */} +
+ + {asset.data.name} + + / + + {selectedStig && ( + + {filteredRules.length} of {rules.data?.length ?? 0} rules ·{' '} + {counts.fail} fail ·{' '} + {counts.pass} pass ·{' '} + {counts.na} N/A ·{' '} + {counts.unreviewed} unreviewed + + )} +
+ + {/* Three-pane body ----------------------------------------- */} +
+ + + + + save(false)} + onSaveAdvance={() => save(true)} + onPrev={() => { + const i = filteredRules.findIndex((r) => r.ruleId === selectedRuleId) + if (i > 0) selectRule(filteredRules[i - 1].ruleId) + }} + onNext={() => { + const i = filteredRules.findIndex((r) => r.ruleId === selectedRuleId) + if (i >= 0 && i < filteredRules.length - 1) + selectRule(filteredRules[i + 1].ruleId) + }} + /> +
+
+ ) +} + +// ---- Rule list pane -------------------------------------------------- + +function RuleListPane({ + rules, + all, + loading, + selectedRuleId, + reviewsByRule, + onSelect, + search, + setSearch, + searchRef, + severity, + setSeverity, + statusFilter, + setStatusFilter, + resultFilter, + setResultFilter, +}: { + rules: Rule[] + all: Rule[] | null + loading: boolean + selectedRuleId: string + reviewsByRule: Map + onSelect: (ruleId: string) => void + search: string + setSearch: (s: string) => void + searchRef: React.RefObject + severity: '' | 'high' | 'medium' | 'low' + setSeverity: (s: '' | 'high' | 'medium' | 'low') => void + statusFilter: StatusFilter + setStatusFilter: (s: StatusFilter) => void + resultFilter: '' | ReviewResult + setResultFilter: (s: '' | ReviewResult) => void +}) { + return ( + + +
+ setSearch(e.target.value)} + data-testid="workspace-search" + /> +
+
+ setSeverity('')}>All sev + setSeverity('high')}>CAT I + setSeverity('medium')}>CAT II + setSeverity('low')}>CAT III +
+
+ setStatusFilter('')}>Any status + setStatusFilter('unreviewed')}>Unreviewed + setStatusFilter('saved')}>Saved + setStatusFilter('submitted')}>Submitted +
+
+ setResultFilter('')}>Any result + setResultFilter('fail')}>Fail + setResultFilter('pass')}>Pass + setResultFilter('notapplicable')}>N/A +
+
+ + {loading && ( +

+ Loading rules… +

+ )} + {!loading && all && all.length === 0 && ( +

+ This STIG has no rules. +

+ )} + {!loading && all && all.length > 0 && rules.length === 0 && ( +

+ No rules match the current filter. +

+ )} + {!loading && rules.length > 0 && ( +
    + {rules.map((r) => { + const rev = reviewsByRule.get(r.ruleId) + const selected = r.ruleId === selectedRuleId + return ( +
  • onSelect(r.ruleId)} + > +
    + + + {r.groupId ?? r.ruleId} + + + + +
    +

    {r.title}

    +
  • + ) + })} +
+ )} +
+
+ ) +} + +// ---- Rule detail pane ------------------------------------------------ + +function RuleDetailPane({ + ruleId, + loading, + detail, +}: { + ruleId: string + loading: boolean + detail: import('@/lib/api').RuleDetail | null +}) { + return ( + + + + {ruleId || 'No rule selected'} + + {detail?.title && ( +

+ {detail.title} +

+ )} +
+ + {loading && ( +

+ Loading rule detail… +

+ )} + {!loading && detail && ( + <> + {detail.detail?.vulnDiscussion && ( +
+

{detail.detail.vulnDiscussion}

+
+ )} + {detail.check?.content && ( +
+
+                  {detail.check.content}
+                
+
+ )} + {detail.fix?.text && ( +
+
+                  {detail.fix.text}
+                
+
+ )} + {detail.ccis && detail.ccis.length > 0 && ( +
+
    + {detail.ccis.map((c) => ( +
  • + {c.cci} +
  • + ))} +
+
+ )} + + )} + {!loading && !detail && ruleId && ( +

+ No detail available for this rule. +

+ )} +
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +// ---- Review pane (history + evaluation form) ------------------------ + +type ReviewPaneTab = 'history' | 'other' | 'status' + +function ReviewPane({ + ruleId, + canEdit, + review, + history, + historyLoading, + result, + setResult, + detail, + setDetail, + comment, + setComment, + status, + setStatus, + savedAt, + error, + pending, + onSave, + onSaveAdvance, + onPrev, + onNext, +}: { + ruleId: string + canEdit: boolean + review: Review | null + history: import('@/lib/api').ReviewHistoryAsset[] + historyLoading: boolean + result: ReviewResult + setResult: (r: ReviewResult) => void + detail: string + setDetail: (s: string) => void + comment: string + setComment: (s: string) => void + status: ReviewStatusLabel | '' + setStatus: (s: ReviewStatusLabel | '') => void + savedAt: string | null + error: string | null + pending: boolean + onSave: () => void + onSaveAdvance: () => void + onPrev: () => void + onNext: () => void +}) { + const [tab, setTab] = React.useState('history') + // Reset to history when rule changes; keeps the panel showing the + // most useful info first. + React.useEffect(() => { + setTab('history') + }, [ruleId]) + + return ( +
+ + +
+ setTab('history')}>History + setTab('other')}>Other Assets + setTab('status')}>Status Text +
+
+ + {tab === 'history' && ( + + )} + {tab === 'other' && ( +

+ Cross-asset view for this rule is coming soon (M23b). Use the + Reviews tab on the Collection for now. +

+ )} + {tab === 'status' && ( +
+

+ Status reviewer note (if any) on the current review. +

+

+ {review?.status?.text || '— none —'} +

+
+ )} +
+
+ + + + Evaluation +
+ + +
+
+ +
+ + +
+ +
+ +