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>
256 lines
9.4 KiB
Text
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>
|