feat(m5): admin SPA pages for the template catalogue

- AdminTestsPage with filters (q, tactic, opsec, tag), modal-based CRUD,
  markdown textareas for procedure/result/detection, embedded MitreTagPicker
  for tagging.
- AdminScenariosPage with @dnd-kit/sortable drag-and-drop on the ordered
  test list, two-step save (PATCH metadata + PUT tests), catalogue picker
  excluding soft-deleted items.
- lib/templates.ts typed client + queryKey factory.
- MarkdownField helper (textarea with markdown hint label).
- Layout adds Tests + Scenarios admin nav links; App.tsx routes both
  behind RequireAdmin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 19:57:41 +02:00
parent b8fd99a5f4
commit 2781ce4117
7 changed files with 1042 additions and 1 deletions

View File

@@ -42,6 +42,8 @@ export function Layout() {
{navItem('/admin/users', 'Users')}
{navItem('/admin/groups', 'Groups')}
{navItem('/admin/invitations', 'Invitations')}
{navItem('/admin/tests', 'Tests')}
{navItem('/admin/scenarios', 'Scenarios')}
</>
)}
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
@@ -69,7 +71,7 @@ export function Layout() {
<Outlet />
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · design system from tasks/design.md
</footer>
</div>
</div>

View File

@@ -0,0 +1,45 @@
import { useId, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface MarkdownFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
label: string;
value: string;
onChange: (next: string) => void;
rows?: number;
hint?: string;
}
/**
* Markdown-content textarea. We deliberately keep it textarea-only (no fancy
* WYSIWYG editor) — markdown lives well in plain text and the saved blob is
* rendered to HTML at display time (M6/M7 mission pages). The label exposes
* "markdown" so the user knows the field accepts MD syntax.
*/
export function MarkdownField({ label, value, onChange, rows = 6, hint, id, className, ...rest }: MarkdownFieldProps) {
const fallbackId = useId();
const inputId = id ?? fallbackId;
return (
<div className="block">
<label
htmlFor={inputId}
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
>
{label} <span className="text-text-dim/60">· markdown</span>
</label>
<textarea
id={inputId}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
'focus:border-cyan focus:outline-none',
className,
)}
{...rest}
/>
{hint && <p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>}
</div>
);
}