project-bifrost-platform/src/pages/admin/[resource].astro
Jonathan Hvid a520e8534e fix(admin): invite magic link is absolute, not relative
The Create Invitation flow rendered "/invite?t=…" instead of
"https://host/invite?t=…" because the origin was gated on an unset
PUBLIC_ORIGIN env var.

Solution: OpContext now carries `origin` (always set by the route
handler from Astro.url.origin), and invitations.ts builds the magic
link from it. No env vars required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:29:58 +02:00

256 lines
9.4 KiB
Text

---
/* ---------------------------------------------------------------------------
* /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 {
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<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');
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<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);
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<string, unknown> | 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_<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 && (
<ResourceEditPanel
resource={resource}
item={editingItem}
formValues={resubmitValues ?? undefined}
errors={errors}
actingUserId={user.id}
/>
)}
</AdminLayout>