feat(admin): /admin/[resource] dynamic route + POST dispatch

The production route every Backstage resource lives under. Resolves
the resource from the URL segment against the registry, gates on
user.role === 'fenja', and renders the AdminLayout shell with the
ResourceListView + (optionally) ResourceEditPanel.

POST dispatch keyed by _action:
- save: parses formdata per field.kind (multi-text/multi-select-async
  use getAll(), number coerces, others coerce to string), validates
  via validateForResource, then routes to ops.update(id) when
  ?edit=<id> is set or ops.create() when ?new=1. Redirects with
  ?msg=saved | ?msg=created. On failure, re-renders the panel with
  errors + the submitted values.
- delete: calls ops.delete(id), redirects with ?msg=deleted.
- <action.key>: looks up the action in resource.actions and runs its
  handler, redirects with ?msg=action_<key>.

404s when the resource key isn't in the registry — most keys won't
resolve until steps 8-10 land. A small .bs-flash banner above the
list surfaces the ?msg= text (or the error message after a failed
save).

Old /admin (?tab=...) continues to work alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 16:15:17 +02:00
parent 09a10061b2
commit 3aaa21e6af
2 changed files with 251 additions and 0 deletions

View file

@ -1299,3 +1299,27 @@
@media (max-width: 767px) { @media (max-width: 767px) {
.bs-panel { width: 100vw; max-width: 100vw; } .bs-panel { width: 100vw; max-width: 100vw; }
} }
/* ===========================================================================
* Flash banner step 7
*
* Lives above the list view, surfaces the ?msg= flash after a redirect.
* Two variants: success (default) and error (after a save failure).
* ========================================================================= */
.bs-flash {
padding: 10px var(--space-5);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wide);
margin-bottom: var(--space-4);
}
.bs-flash.success {
background: rgba(109, 140, 124, 0.10);
color: #5a7268;
}
.bs-flash.error {
background: rgba(185, 107, 88, 0.10);
color: var(--pigment-terracotta);
}

View file

@ -0,0 +1,227 @@
---
/* ---------------------------------------------------------------------------
* /admin/<resource> — the production dynamic admin route.
*
* Resolves the resource from the URL segment, gates on user.role === 'fenja',
* dispatches POSTs (save / delete / action key) through validateForResource
* and resource.ops, redirects with a ?msg=<key> flash on success.
*
* 404s when the resource key is not registered — step 8 onward populates
* the registry; until then most resource keys won't resolve.
* ------------------------------------------------------------------------- */
import AdminLayout from '../../admin/components/AdminLayout.astro';
import ResourceListView from '../../admin/components/ResourceListView.astro';
import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro';
import { groups } from '../../admin/resources';
import { validateForResource, type ValidationErrors } from '../../admin/validate';
import type {
Field,
OpContext,
Resource,
} from '../../admin/resource-types';
// ── Auth gate ─────────────────────────────────────────────────────────────
const user = Astro.locals.user;
if (user.role !== 'fenja') return Astro.redirect('/');
// ── Resolve resource from URL segment ─────────────────────────────────────
const resourceKey = Astro.params.resource;
const allResources = groups.flatMap((g) => g.resources);
const resource = allResources.find((r) => r.key === resourceKey) as
| Resource
| undefined;
if (!resource) {
return new Response('Resource not found', { status: 404 });
}
const resourceBase = `/admin/${resource.key}`;
const opCtx: OpContext = { user: { id: user.id, role: user.role } };
// ── Form-data → typed record (driven by the field configs) ────────────────
function parseFormData(
formData: FormData,
fields: Field[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of fields) {
if (field.readOnly) continue;
switch (field.kind) {
case 'multi-text': {
out[field.key] = formData
.getAll(field.key)
.map((v) => String(v))
.filter((v) => v.trim() !== '');
break;
}
case 'multi-select-async': {
out[field.key] = formData.getAll(field.key).map((v) => {
const s = String(v);
const n = Number(s);
return Number.isFinite(n) && s !== '' ? n : s;
});
break;
}
case 'number': {
const v = formData.get(field.key);
if (v == null || v === '') {
out[field.key] = null;
} else {
const n = Number(v);
out[field.key] = Number.isFinite(n) ? n : v;
}
break;
}
default: {
const v = formData.get(field.key);
out[field.key] = v == null ? '' : String(v);
}
}
}
return out;
}
// ── State that survives a failed POST (so the panel re-fills) ─────────────
let errors: ValidationErrors = {};
let formError: string | null = null;
let resubmitValues: Record<string, unknown> | null = null;
// ── POST dispatch ────────────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const action = String(formData.get('_action') ?? 'save');
const editIdParam = Astro.url.searchParams.get('edit');
const editId =
editIdParam && Number.isFinite(Number(editIdParam))
? Number(editIdParam)
: null;
// ── save ──────────────────────────────────────────────────────────────
if (action === 'save') {
if (!resource.form) {
return new Response('Resource is read-only', { status: 403 });
}
const data = parseFormData(formData, resource.form.fields);
const existingItem =
editId !== null && resource.ops.getById
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
: null;
errors = validateForResource({
resource,
data,
item: existingItem,
actingUserId: user.id,
});
if (Object.keys(errors).length === 0) {
try {
if (editId !== null && resource.ops.update) {
await resource.ops.update(editId, data, opCtx);
return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved`);
}
if (editId === null && resource.ops.create) {
const newId = await resource.ops.create(data, opCtx);
return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created`);
}
return Astro.redirect(`${resourceBase}?msg=saved`);
} catch (err) {
formError = err instanceof Error ? err.message : 'Save failed';
resubmitValues = data;
}
} else {
resubmitValues = data;
}
}
// ── delete ────────────────────────────────────────────────────────────
else if (action === 'delete') {
if (editId !== null && resource.ops.delete) {
try {
await resource.ops.delete(editId, opCtx);
return Astro.redirect(`${resourceBase}?msg=deleted`);
} catch (err) {
formError = err instanceof Error ? err.message : 'Delete failed';
}
}
}
// ── custom action ─────────────────────────────────────────────────────
else {
const customAction = resource.actions?.find((a) => a.key === action);
if (customAction && editId !== null) {
try {
await customAction.handler(editId, opCtx);
return Astro.redirect(
`${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}`,
);
} catch (err) {
formError = err instanceof Error ? err.message : 'Action failed';
}
} else {
return new Response('Unknown action', { status: 400 });
}
}
}
// ── GET / failed-POST render ──────────────────────────────────────────────
const isNew = Astro.url.searchParams.get('new') === '1';
const editIdRaw = Astro.url.searchParams.get('edit');
const editId =
editIdRaw && Number.isFinite(Number(editIdRaw)) ? Number(editIdRaw) : null;
const editingItem =
editId !== null && resource.ops.getById
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
: null;
const showPanel = resource.form !== null && (isNew || editingItem !== null);
const msg = Astro.url.searchParams.get('msg');
const pageTitle = `${resource.pluralLabel} — Backstage`;
// Friendly flash text. Anything action_<key> is rendered as
// "<action.label> done." using the resource's action label.
function flashTextFor(rawMsg: string | null): string | null {
if (!rawMsg) return null;
if (formError) return formError;
if (rawMsg.startsWith('action_')) {
const key = rawMsg.slice('action_'.length);
const action = resource!.actions?.find((a) => a.key === key);
return action ? `${action.label}.` : null;
}
return ({
saved: 'Saved.',
created: 'Created.',
deleted: 'Deleted.',
} as Record<string, string>)[rawMsg] ?? null;
}
const flash = formError ?? flashTextFor(msg);
const flashKind = formError ? 'error' : 'success';
---
<AdminLayout
title={pageTitle}
groups={groups}
activeResourceKey={resource.key}
>
{flash && (
<div class:list={['bs-flash', flashKind]} role="status">{flash}</div>
)}
<ResourceListView resource={resource} groups={groups} />
{showPanel && resource.form && (
<ResourceEditPanel
resource={resource}
item={editingItem}
formValues={resubmitValues ?? undefined}
errors={errors}
actingUserId={user.id}
/>
)}
</AdminLayout>