From e9a986d4841915fca538d3126a025fc7f29d29c0 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 16:32:26 +0200 Subject: [PATCH] feat(admin): council-group resources (users, invitations, join requests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more resources land. /admin/users replaces the old participants tab, /admin/invitations replaces the old invites tab, /admin/join_requests replaces the read-only join queue. - src/admin/resources/users.ts ("People"): single resource for all users, filter chips swap visible columns (council shows member_number + focus_tags; pilots/team show role + last_seen_at). Form fields are conditional — title / pull_quote / focus_tags / cab_joined_date / member_number render only when role === cab. No ops.create (users come via invites); deactivateUser is the delete handler. - src/admin/resources/invitations.ts: form-for-create, summary-for-view. Create generates a token via generateInviteToken(), stores its hash, surfaces the magic link as a one-shot ?invite_url= block in the panel. Revoke is an action (sets expires_at = now); the row stays for audit. - src/admin/resources/join-requests.ts: form: null, review-mode panel with the user's summary + approve_as_cab / decline actions. Plumbing to support the above: - src/admin/resource-types.ts: new Resource.summary callback (read-only field pairs for review panels); OpContext.result lets ops surface ActionResults (e.g. invite-link). - src/admin/components/ResourceEditPanel.astro: review mode when an existing item is shown and resource.summary is defined; renders the ?invite_url= block above the summary with a copy-to-clipboard button. - src/admin/components/ResourceListView.astro: "+ New" suppressed when ops.create is undefined. - src/pages/admin/[resource].astro: captures ctx.result and action handler return values, propagates them via &invite_url=...; routes to the list view (not the row) when an action removes the item. - src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest, getInviteById. Deviation from the original delta: no approve_as_pilot action and no invite-link result on join-request approval. The existing join_requests schema only stores user_id — requests come from already-authenticated pilots asking for a CAB upgrade, not from strangers needing an invite. The schema change for stranger sign-ups is left for a future follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/admin/admin.css | 77 +++++ src/admin/components/ResourceEditPanel.astro | 137 ++++++-- src/admin/components/ResourceListView.astro | 2 +- src/admin/resource-types.ts | 12 + src/admin/resources/index.ts | 9 +- src/admin/resources/invitations.ts | 192 +++++++++++ src/admin/resources/join-requests.ts | 110 +++++++ src/admin/resources/users.ts | 324 +++++++++++++++++++ src/lib/db.ts | 23 ++ src/pages/admin/[resource].astro | 41 ++- 10 files changed, 884 insertions(+), 43 deletions(-) create mode 100644 src/admin/resources/invitations.ts create mode 100644 src/admin/resources/join-requests.ts create mode 100644 src/admin/resources/users.ts diff --git a/src/admin/admin.css b/src/admin/admin.css index 2047048..89f05a5 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -1338,3 +1338,80 @@ @media (max-width: 767px) { .bs-field-row { grid-template-columns: 1fr; } } + +/* ── Review panel summary (form: null resources) ────────────────── */ +.bs-summary { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.bs-summary-row { + display: grid; + grid-template-columns: 100px 1fr; + gap: var(--space-3); + align-items: baseline; +} +.bs-summary-label { + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--on-surface-muted); + margin: 0; +} +.bs-summary-value { + font-size: 14px; + line-height: 1.5; + color: var(--on-surface); + margin: 0; +} + +/* ── Invite magic-link block ────────────────────────────────────── */ +.bs-invite-result { + background: rgba(109, 140, 124, 0.10); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.bs-invite-result-label { + font-family: var(--font-sans); + font-size: 11px; + letter-spacing: var(--tracking-wide); + color: #5a7268; + margin: 0; + font-weight: 500; +} +.bs-invite-link-row { + display: flex; + align-items: center; + gap: var(--space-2); +} +.bs-invite-link { + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + background: var(--background); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--on-surface); + word-break: break-all; + line-height: 1.4; +} +.bs-copy-btn { + flex-shrink: 0; + padding: 6px 12px; + background: var(--ink); + color: var(--on-ink); + border: none; + border-radius: 999px; + font-family: var(--font-sans); + font-size: 11px; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + cursor: pointer; + transition: opacity var(--duration-fast) var(--ease-standard); +} +.bs-copy-btn:hover { opacity: 0.88; } diff --git a/src/admin/components/ResourceEditPanel.astro b/src/admin/components/ResourceEditPanel.astro index 02777be..7b70fb0 100644 --- a/src/admin/components/ResourceEditPanel.astro +++ b/src/admin/components/ResourceEditPanel.astro @@ -26,13 +26,34 @@ interface Props { const { resource, item, formValues, errors = {}, actingUserId } = Astro.props; -if (!resource.form) { - throw new Error(`ResourceEditPanel: ${resource.key} has form: null`); +const isCreate = item === null; +// Review mode = the panel is showing an existing item AND either the resource +// has no form, OR it has a summary that should be preferred over the form +// (e.g. invitations are write-once: form is for create, summary is for edit). +const isReviewMode = + !isCreate && + (resource.form === null || resource.summary !== undefined); +const singular = resource.singularLabel.toLowerCase(); +const title = isReviewMode + ? `Review ${singular}` + : isCreate + ? `New ${singular}` + : `Edit ${singular}`; + +// In review mode, the resource MUST have a summary function — that's what +// fills the panel body when the form is suppressed. +if (isReviewMode && !resource.summary) { + throw new Error( + `ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`, + ); } -const isCreate = item === null; -const singular = resource.singularLabel.toLowerCase(); -const title = isCreate ? `New ${singular}` : `Edit ${singular}`; +// And the create path needs a form. +if (isCreate && !resource.form) { + throw new Error( + `ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`, + ); +} // Initial form values: prior failed submission > existing item > defaults const seedValues: Record = { ...(item ?? {}), ...(formValues ?? {}) }; @@ -56,11 +77,19 @@ function valueFor(field: Field): unknown { return resolveDefault(field); } -const visibleFields = resource.form.fields.filter( - (f) => !f.visibleWhen || f.visibleWhen(ctx), -); +const visibleFields = resource.form + ? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx)) + : []; -const embeds = resource.form.embeds ?? []; +const embeds = resource.form?.embeds ?? []; + +// Review-mode summary +const summaryEntries = isReviewMode && item + ? resource.summary!(item) + : []; + +// One-shot invite link surfaced after create/action — read from URL +const inviteUrl = Astro.url.searchParams.get('invite_url'); // Build the close URL — drop edit/new but keep filter/q/page const closeUrl = (() => { @@ -94,34 +123,59 @@ const formAction = Astro.url.pathname + Astro.url.search;
- {visibleFields.map((field) => ( - - ))} + {inviteUrl && ( +
+

Magic link — copy and send now. It will not be shown again.

+ +
+ )} - {embeds.length > 0 && embeds.map((embed) => { - const show = !embed.visibleWhen || embed.visibleWhen(ctx); - if (!show) return null; - return ( -
-

{embed.title}

- {embed.component === 'pulse-sub-form' && ( - - )} -
- ); - })} + {isReviewMode ? ( +
+ {summaryEntries.map((entry) => ( +
+
{entry.label}
+
{entry.value}
+
+ ))} +
+ ) : ( + <> + {visibleFields.map((field) => ( + + ))} + + {embeds.length > 0 && embeds.map((embed) => { + const show = !embed.visibleWhen || embed.visibleWhen(ctx); + if (!show) return null; + return ( +
+

{embed.title}

+ {embed.component === 'pulse-sub-form' && ( + + )} +
+ ); + })} + + )}