--- /* --------------------------------------------------------------------------- * /admin/ — 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= 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 { ActionResult, 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 }, origin: Astro.url.origin, }; // ── Form-data → typed record (driven by the field configs) ──────────────── function parseFormData( formData: FormData, fields: Field[], ): Record { const out: Record = {}; 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 | null = null; // ── POST dispatch ──────────────────────────────────────────────────────── if (Astro.request.method === 'POST') { const formData = await Astro.request.formData(); const action = String(formData.get('_action') ?? 'save'); opCtx.formData = formData; 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 | 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); const extra = resultRedirectParam(opCtx.result); return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved${extra}`); } if (editId === null && resource.ops.create) { const newId = await resource.ops.create(data, opCtx); const extra = resultRedirectParam(opCtx.result); return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created${extra}`); } 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 { const direct = await customAction.handler(editId, opCtx); // Handlers may set ctx.result or return an ActionResult — accept both. const result = (direct as ActionResult | undefined) ?? opCtx.result; const extra = resultRedirectParam(result); // Some actions remove the item entirely (e.g. decline). Land on the // list view in that case so we don't 404 trying to re-fetch the row. const stillExists = resource.ops.getById ? (await resource.ops.getById(editId)) !== null : true; const target = stillExists ? `${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}${extra}` : `${resourceBase}?msg=action_${encodeURIComponent(action)}${extra}`; return Astro.redirect(target); } catch (err) { formError = err instanceof Error ? err.message : 'Action failed'; } } else { return new Response('Unknown action', { status: 400 }); } } } function resultRedirectParam(r: ActionResult | undefined): string { if (!r) return ''; if (r.kind === 'invite-link') { return `&invite_url=${encodeURIComponent(r.url)}`; } return ''; } // ── 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 | null) : null; // Panel renders when: // - editing/creating a form-bearing resource, OR // - reviewing an item from a form-null resource that has a summary (e.g. join_requests) const showPanel = resource.form !== null ? (isNew || editingItem !== null) : (editingItem !== null && resource.summary !== undefined); const msg = Astro.url.searchParams.get('msg'); const pageTitle = `${resource.pluralLabel} — Backstage`; // Friendly flash text. Anything action_ is rendered as // " 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)[rawMsg] ?? null; } const flash = formError ?? flashTextFor(msg); const flashKind = formError ? 'error' : 'success'; --- {flash && (
{flash}
)} {showPanel && ( )}