feat(admin): council-group resources (users, invitations, join requests)

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 16:32:26 +02:00
parent dd9ea68fab
commit e9a986d484
10 changed files with 884 additions and 43 deletions

View file

@ -1338,3 +1338,80 @@
@media (max-width: 767px) { @media (max-width: 767px) {
.bs-field-row { grid-template-columns: 1fr; } .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; }

View file

@ -26,13 +26,34 @@ interface Props {
const { resource, item, formValues, errors = {}, actingUserId } = Astro.props; const { resource, item, formValues, errors = {}, actingUserId } = Astro.props;
if (!resource.form) { const isCreate = item === null;
throw new Error(`ResourceEditPanel: ${resource.key} has form: 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; // And the create path needs a form.
const singular = resource.singularLabel.toLowerCase(); if (isCreate && !resource.form) {
const title = isCreate ? `New ${singular}` : `Edit ${singular}`; throw new Error(
`ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`,
);
}
// Initial form values: prior failed submission > existing item > defaults // Initial form values: prior failed submission > existing item > defaults
const seedValues: Record<string, unknown> = { ...(item ?? {}), ...(formValues ?? {}) }; const seedValues: Record<string, unknown> = { ...(item ?? {}), ...(formValues ?? {}) };
@ -56,11 +77,19 @@ function valueFor(field: Field): unknown {
return resolveDefault(field); return resolveDefault(field);
} }
const visibleFields = resource.form.fields.filter( const visibleFields = resource.form
(f) => !f.visibleWhen || f.visibleWhen(ctx), ? 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 // Build the close URL — drop edit/new but keep filter/q/page
const closeUrl = (() => { const closeUrl = (() => {
@ -94,34 +123,59 @@ const formAction = Astro.url.pathname + Astro.url.search;
<form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form"> <form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form">
<div class="bs-panel-body"> <div class="bs-panel-body">
{visibleFields.map((field) => ( {inviteUrl && (
<FieldRenderer <section class="bs-invite-result" data-invite-block>
field={field} <p class="bs-invite-result-label">Magic link — copy and send now. It will not be shown again.</p>
value={valueFor(field)} <div class="bs-invite-link-row">
error={errors[field.key]} <code class="bs-invite-link" id="bs-invite-link">{inviteUrl}</code>
item={item} <button type="button" class="bs-copy-btn" data-copy-target="#bs-invite-link">Copy</button>
/> </div>
))} </section>
)}
{embeds.length > 0 && embeds.map((embed) => { {isReviewMode ? (
const show = !embed.visibleWhen || embed.visibleWhen(ctx); <dl class="bs-summary">
if (!show) return null; {summaryEntries.map((entry) => (
return ( <div class="bs-summary-row">
<section class="bs-embed" data-embed={embed.key}> <dt class="bs-summary-label">{entry.label}</dt>
<h3 class="bs-embed-title">{embed.title}</h3> <dd class="bs-summary-value">{entry.value}</dd>
{embed.component === 'pulse-sub-form' && ( </div>
<PulseSubForm item={item} /> ))}
)} </dl>
</section> ) : (
); <>
})} {visibleFields.map((field) => (
<FieldRenderer
field={field}
value={valueFor(field)}
error={errors[field.key]}
item={item}
/>
))}
{embeds.length > 0 && embeds.map((embed) => {
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
if (!show) return null;
return (
<section class="bs-embed" data-embed={embed.key}>
<h3 class="bs-embed-title">{embed.title}</h3>
{embed.component === 'pulse-sub-form' && (
<PulseSubForm item={item} />
)}
</section>
);
})}
</>
)}
</div> </div>
<footer class="bs-panel-foot"> <footer class="bs-panel-foot">
<div class="bs-panel-foot-left"> <div class="bs-panel-foot-left">
<button type="submit" name="_action" value="save" class="bs-panel-save"> {!isReviewMode && (
{isCreate ? `Create ${singular}` : 'Save'} <button type="submit" name="_action" value="save" class="bs-panel-save">
</button> {isCreate ? `Create ${singular}` : 'Save'}
</button>
)}
{actions.map((a) => ( {actions.map((a) => (
<button <button
type="submit" type="submit"
@ -135,7 +189,7 @@ const formAction = Astro.url.pathname + Astro.url.search;
))} ))}
</div> </div>
{!isCreate && resource.ops.delete && ( {!isCreate && !isReviewMode && resource.ops.delete && (
<button <button
type="submit" type="submit"
name="_action" name="_action"
@ -191,6 +245,23 @@ const formAction = Astro.url.pathname + Astro.url.search;
}); });
}); });
// ── Copy-to-clipboard for invite-link blocks ─────────────────────────────
document.querySelectorAll<HTMLButtonElement>('.bs-copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const sel = btn.getAttribute('data-copy-target');
const target = sel ? document.querySelector(sel) : null;
const text = target?.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
const orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = orig; }, 1400);
} catch {
// clipboard blocked — leave the link visible for manual copy
}
});
});
// ── MultiTextField add / remove ────────────────────────────────────────── // ── MultiTextField add / remove ──────────────────────────────────────────
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => { document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows'); const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');

View file

@ -92,7 +92,7 @@ function withParams(overrides: Record<string, string | number | null>): string {
return s ? `${url.pathname}?${s}` : url.pathname; return s ? `${url.pathname}?${s}` : url.pathname;
} }
const showNewButton = resource.form !== null; const showNewButton = resource.form !== null && resource.ops.create !== undefined;
const hasItems = allItems.length > 0; const hasItems = allItems.length > 0;
const hasMatches = pageItems.length > 0; const hasMatches = pageItems.length > 0;
--- ---

View file

@ -227,6 +227,12 @@ export interface OpContext {
* resources ignore this and work off the typed `data` argument. * resources ignore this and work off the typed `data` argument.
*/ */
formData?: FormData; formData?: FormData;
/**
* Set by an op or action to surface a one-shot result on the next render
* (e.g. the magic link after an invite is created). The route handler
* reads this after the op returns and propagates it via the redirect URL.
*/
result?: ActionResult;
} }
// ── CRUD operations ───────────────────────────────────────────────────────── // ── CRUD operations ─────────────────────────────────────────────────────────
@ -285,6 +291,12 @@ export interface Resource<T = Record<string, unknown>> {
list: ListConfig<T>; list: ListConfig<T>;
/** null marks the resource as read-only (no edit panel, no "+ New" button). */ /** null marks the resource as read-only (no edit panel, no "+ New" button). */
form: FormConfig | null; form: FormConfig | null;
/**
* When form is null but the resource still has actions (e.g. join_requests),
* this defines the read-only fields the review panel renders above the
* action buttons. Returns label/value pairs in display order.
*/
summary?: (item: T) => { label: string; value: string }[];
ops: ResourceOps<T>; ops: ResourceOps<T>;
actions?: ResourceAction<T>[]; actions?: ResourceAction<T>[];
notifyCount?: NotifyCount<T>; notifyCount?: NotifyCount<T>;

View file

@ -9,6 +9,9 @@ import type { ResourceGroup } from '../resource-types';
import { dispatchesResource } from './dispatches'; import { dispatchesResource } from './dispatches';
import { roadmapResource } from './roadmap'; import { roadmapResource } from './roadmap';
import { eventsResource } from './events'; import { eventsResource } from './events';
import { usersResource } from './users';
import { invitationsResource } from './invitations';
import { joinRequestsResource } from './join-requests';
export const groups: ResourceGroup[] = [ export const groups: ResourceGroup[] = [
{ {
@ -16,6 +19,10 @@ export const groups: ResourceGroup[] = [
label: 'Publishing', label: 'Publishing',
resources: [dispatchesResource, roadmapResource, eventsResource], resources: [dispatchesResource, roadmapResource, eventsResource],
}, },
{ key: 'council', label: 'The council', resources: [] }, {
key: 'council',
label: 'The council',
resources: [usersResource, invitationsResource, joinRequestsResource],
},
{ key: 'system', label: 'System', resources: [] }, { key: 'system', label: 'System', resources: [] },
]; ];

View file

@ -0,0 +1,192 @@
/* ---------------------------------------------------------------------------
* Invitations resource.
*
* Create surfaces the magic link via ctx.result.invite-link the route
* handler propagates it as ?invite_url=... and the edit panel renders a
* copy-to-clipboard block. The token itself is never stored only its
* hash so the link is shown exactly once.
*
* "Revoke" is implemented as an action (sets expires_at = now()), not as
* ops.delete, because the row stays in the table for audit history.
* ------------------------------------------------------------------------- */
import {
createInvite,
getAllInvites,
getInviteById,
revokeInvite,
type Invite,
type Role,
} from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { relativeTime } from '../../lib/format';
import { fmtDateTime } from '../../lib/markdown';
import type { Resource } from '../resource-types';
type InviteRow = Invite & { creator_name: string | null };
function deriveStatus(item: InviteRow): 'pending' | 'accepted' | 'expired' {
if (item.used_at) return 'accepted';
if (new Date(item.expires_at).getTime() < Date.now()) return 'expired';
return 'pending';
}
export const invitationsResource: Resource<InviteRow> = {
key: 'invitations',
label: 'Invitations',
pluralLabel: 'Invitations',
singularLabel: 'Invitation',
groupKey: 'council',
description: 'Magic links sent to new pilots and council members. Tokens are shown once on create.',
list: {
queryFn: () => getAllInvites(),
columns: [
{
key: 'email',
label: 'Email',
primary: true,
width: '2fr',
render: (item) => ({
title: item.email,
subtitle: `${item.name} · ${item.organisation || '—'}`,
}),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '110px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'creator_name',
label: 'Invited by',
width: '140px',
render: (item) => ({ title: item.creator_name || '—' }),
},
{
key: 'created_at',
label: 'Created',
kind: 'relative-date',
width: '110px',
},
{
key: 'status',
label: 'Status',
kind: 'pill',
width: '110px',
value: (item) => deriveStatus(item),
pillVariants: {
pending: { label: 'Pending', class: 'pill-pending' },
accepted: { label: 'Accepted', class: 'pill-accepted' },
expired: { label: 'Expired', class: 'pill-expired' },
},
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'pending', label: 'Pending', predicate: (i) => deriveStatus(i) === 'pending' },
{ key: 'accepted', label: 'Accepted', predicate: (i) => deriveStatus(i) === 'accepted' },
{ key: 'expired', label: 'Expired', predicate: (i) => deriveStatus(i) === 'expired' },
],
search: {
placeholder: 'Search by email or name…',
fields: ['email', 'name', 'organisation'],
},
defaultSort: { key: 'created_at', direction: 'desc' },
pageSize: 50,
},
form: {
fields: [
{
key: 'email',
label: 'Email',
kind: 'text',
required: true,
maxLength: 240,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
helperText: 'Where the magic link will land. Cannot be changed later.',
},
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
{ key: 'organisation', label: 'Organisation', kind: 'text', maxLength: 200 },
{
key: 'role',
label: 'Role',
kind: 'select',
required: true,
options: [
{ value: 'pilot', label: 'Pilot' },
{ value: 'cab', label: 'Council' },
],
defaultValue: 'pilot',
helperText: 'Council invites allocate a member number on signup.',
},
],
},
// Existing invites are immutable — the panel renders the summary +
// Revoke action via the review-mode pathway. Create still uses the form.
summary: (item) => {
const status = deriveStatus(item);
return [
{ label: 'Email', value: item.email },
{ label: 'Name', value: item.name },
{ label: 'Organisation', value: item.organisation || '—' },
{
label: 'Role',
value: item.role === 'cab' ? 'Council' : item.role === 'pilot' ? 'Pilot' : 'Fenja team',
},
{ label: 'Status', value: status === 'pending' ? 'Pending' : status === 'accepted' ? 'Accepted' : 'Expired' },
{ label: 'Invited by', value: item.creator_name ?? '—' },
{ label: 'Created', value: relativeTime(item.created_at) },
{ label: 'Expires', value: fmtDateTime(item.expires_at) },
];
},
ops: {
getById: (id) => getInviteById(id),
create: (data, ctx) => {
const { token, tokenHash } = generateInviteToken();
const id = createInvite({
token_hash: tokenHash,
email: String(data.email).trim().toLowerCase(),
name: String(data.name).trim(),
organisation: ((data.organisation as string) ?? '').trim(),
role: data.role as Role,
expires_at: inviteExpiresAt(),
created_by_user_id: ctx.user.id,
});
// Surface the one-shot magic link via the result mechanism — the route
// handler propagates it as ?invite_url= and the panel renders a copy
// block on the next page load.
const origin = process.env.PUBLIC_ORIGIN ?? '';
ctx.result = {
kind: 'invite-link',
url: `${origin}/invite?t=${token}`,
};
return id;
},
},
actions: [
{
key: 'revoke',
label: 'Revoke',
visibleWhen: (item) => deriveStatus(item) === 'pending',
destructive: true,
confirmText:
'Revoke this invitation? The magic link will stop working immediately.',
handler: (id) => {
revokeInvite(id);
},
},
],
};

View file

@ -0,0 +1,110 @@
/* ---------------------------------------------------------------------------
* Join requests resource read-only review surface.
*
* The existing data model: a pilot user (already in the system) requests
* promotion to council. The request joins to users for name/email/org;
* there's no separate "stranger sign-up" model. As a result, the approval
* flow upgrades the existing user's role rather than minting a fresh invite.
*
* Deviation from the original delta: no approve_as_pilot action (the
* requester is already a pilot) and no invite-link result (the user already
* exists). Stranger sign-ups would require a schema change to the
* join_requests table left for a future follow-up.
* ------------------------------------------------------------------------- */
import {
getAllJoinRequests,
getJoinRequestById,
deleteJoinRequest,
updateUserRole,
type JoinRequest,
} from '../../lib/db';
import { relativeTime } from '../../lib/format';
import type { Resource } from '../resource-types';
export const joinRequestsResource: Resource<JoinRequest> = {
key: 'join_requests',
label: 'Join requests',
pluralLabel: 'Join requests',
singularLabel: 'Join request',
groupKey: 'council',
description: 'Pilots asking to be upgraded to council. Approve to grant access, decline to dismiss.',
list: {
queryFn: () => getAllJoinRequests(),
columns: [
{
key: 'user_name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.user_name, subtitle: item.user_email }),
},
{
key: 'user_organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.user_organisation || '—' }),
},
{
key: 'created_at',
label: 'Requested',
kind: 'relative-date',
width: '120px',
},
],
search: {
placeholder: 'Search by name, email, organisation…',
fields: ['user_name', 'user_email', 'user_organisation'],
},
defaultSort: { key: 'created_at', direction: 'desc' },
pageSize: 50,
},
// Read-only: no edit form, no create flow.
form: null,
// Notify count = total pending requests (everything in the table is
// pending under the current model — there's no status column yet).
notifyCount: {
count: (items) => items.length,
},
// Review panel summary — shown when an item is clicked.
summary: (item) => [
{ label: 'Name', value: item.user_name },
{ label: 'Email', value: item.user_email },
{ label: 'Organisation', value: item.user_organisation || '—' },
{ label: 'Requested', value: relativeTime(item.created_at) },
],
ops: {
getById: (id) => getJoinRequestById(id),
// No delete in ops — declining is an action below, so the destructive
// intent is named explicitly in the panel.
},
actions: [
{
key: 'approve_as_cab',
label: 'Approve as council',
confirmText:
'Promote this pilot to council? They will gain access to council-only surfaces.',
handler: (id) => {
const req = getJoinRequestById(id);
if (!req) return;
updateUserRole(req.user_id, 'cab');
deleteJoinRequest(id);
},
},
{
key: 'decline',
label: 'Decline',
destructive: true,
confirmText: 'Decline this request? It will be removed from the queue.',
handler: (id) => {
deleteJoinRequest(id);
},
},
],
};

View file

@ -0,0 +1,324 @@
/* ---------------------------------------------------------------------------
* People (users) resource replaces the old Participants tab.
*
* Single resource for every user, regardless of role. The filter chips swap
* the visible columns (council shows member_number + focus_tags; pilots/team
* show role + last_seen_at). The edit panel's CAB-specific fields render
* only when the user is in role=cab.
*
* Creation is intentionally absent users come in through invites, not
* direct admin creation. The "+ New" button is suppressed automatically
* because ops.create is undefined.
* ------------------------------------------------------------------------- */
import {
getAllUsersPublic,
getUserPublicById,
updateUserAdminFields,
updateUserProfile,
updateUserRole,
deactivateUser,
type Role,
type UserPublic,
} from '../../lib/db';
import { parseFocusTags, readFocusTags } from '../../lib/format';
import type { Resource } from '../resource-types';
const ROLE_LABEL: Record<Role, string> = {
pilot: 'Pilot',
cab: 'Council',
fenja: 'Fenja team',
};
const ROLE_PILL_CLASS: Record<Role, string> = {
pilot: 'pill-pilot',
cab: 'pill-cab',
fenja: 'pill-fenja',
};
export const usersResource: Resource<UserPublic> = {
key: 'users',
label: 'People',
pluralLabel: 'People',
singularLabel: 'Person',
groupKey: 'council',
description: 'Everyone with an account on the portal — pilots, council, and team.',
publicRoutePattern: (item) => (item.slug ? `/members/${item.slug}` : null),
list: {
queryFn: () => getAllUsersPublic(),
// Default columns shown under "Council" (the default filter): the CAB-
// specific identity columns.
columns: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({
title: item.name,
subtitle: item.email,
}),
},
{
key: 'member_number',
label: 'Member #',
kind: 'number',
width: '100px',
value: (item) => item.member_number,
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'focus_tags',
label: 'Focus',
kind: 'tag-list',
width: '1.5fr',
value: (item) => readFocusTags(item.focus_tags),
},
],
// Pilots / Team / All show role + organisation + last seen instead.
columnsByFilter: {
pilots: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
team: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
all: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
},
filters: [
{ key: 'council', label: 'Council', predicate: (i) => i.role === 'cab', isDefault: true },
{ key: 'pilots', label: 'Pilots', predicate: (i) => i.role === 'pilot' },
{ key: 'team', label: 'Team', predicate: (i) => i.role === 'fenja' },
{ key: 'all', label: 'All', predicate: () => true },
],
search: {
placeholder: 'Search by name, email, organisation…',
fields: ['name', 'email', 'organisation'],
},
defaultSort: { key: 'name', direction: 'asc' },
pageSize: 50,
},
form: {
fields: [
// ── Always visible ───────────────────────────────────────────────
{
key: 'role',
label: 'Role',
kind: 'select',
required: true,
options: [
{ value: 'pilot', label: 'Pilot' },
{ value: 'cab', label: 'Council' },
{ value: 'fenja', label: 'Fenja team' },
],
helperText:
'Changing the role has real access consequences. Setting to Council also allocates a member number.',
},
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
{ key: 'email', label: 'Email', kind: 'text', readOnly: true },
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
helperText: 'Set at sign-up; editing is not yet supported.' },
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
// ── CAB-only ────────────────────────────────────────────────────
{
key: 'title',
label: 'Title',
kind: 'text',
maxLength: 120,
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'pull_quote',
label: 'Pull quote',
kind: 'textarea',
rows: 3,
maxLength: 240,
helperText: 'Shown on the member profile page. Two sentences max.',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'focus_tags_text',
label: 'Focus tags',
kind: 'text',
maxLength: 80,
helperText:
'Comma-separated. Up to 3 tags, 24 chars each. Normalised on save.',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'cab_joined_date',
label: 'Council joined',
kind: 'readonly',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
render: (value) => (value ? String(value) : '—'),
},
{
key: 'member_number',
label: 'Member number',
kind: 'readonly',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
render: (value) => (value ? `#${value}` : 'pending'),
},
],
},
ops: {
getById: (id) => {
const u = getUserPublicById(id);
if (!u) return null;
// Surface focus_tags as plaintext for the editor.
return {
...u,
focus_tags_text: readFocusTags(u.focus_tags).join(', '),
} as unknown as UserPublic;
},
// No ops.create — users come in via invites.
update: (id, data) => {
const current = getUserPublicById(id);
if (!current) throw new Error(`User ${id} not found`);
// Profile fields (name + bio).
const newName = String(data.name ?? current.name);
const newBio = String(data.bio ?? current.bio ?? '');
updateUserProfile(id, newName, newBio);
// Role transition — runs after profile update so member_number can be
// allocated against an up-to-date user row.
const newRole = data.role as Role;
if (newRole && newRole !== current.role) {
updateUserRole(id, newRole);
}
// CAB-specific admin fields. Only applied when the user is CAB after
// the role update; otherwise the form fields aren't visible.
const isCab = (newRole ?? current.role) === 'cab';
if (isCab) {
const tagsRaw = String(data.focus_tags_text ?? '');
updateUserAdminFields(id, {
title: ((data.title as string) ?? '').trim() || null,
pull_quote: ((data.pull_quote as string) ?? '').trim() || null,
focus_tags: parseFocusTags(tagsRaw),
});
}
},
delete: (id) => deactivateUser(id),
},
notifyCount: {
// CAB members without focus tags read as half-finished profiles —
// surface them as something to attend to.
count: (items) =>
items.filter(
(u) => u.role === 'cab' && readFocusTags(u.focus_tags).length === 0,
).length,
},
};
// Keep the role label map exported for any callers that want display copy.
export { ROLE_LABEL, ROLE_PILL_CLASS };

View file

@ -304,6 +304,15 @@ export function getAllInvites(): (Invite & { creator_name: string | null })[] {
`).all() as (Invite & { creator_name: string | null })[]; `).all() as (Invite & { creator_name: string | null })[];
} }
export function getInviteById(id: number): (Invite & { creator_name: string | null }) | null {
return db.prepare(`
SELECT i.*, u.name AS creator_name
FROM invites i
LEFT JOIN users u ON u.id = i.created_by_user_id
WHERE i.id = ?
`).get(id) as (Invite & { creator_name: string | null }) | null;
}
// ── Contributions ──────────────────────────────────────────────── // ── Contributions ────────────────────────────────────────────────
export function createContribution(data: { export function createContribution(data: {
@ -457,6 +466,20 @@ export function getAllJoinRequests(): JoinRequest[] {
`).all() as JoinRequest[]; `).all() as JoinRequest[];
} }
export function getJoinRequestById(id: number): JoinRequest | null {
return db.prepare(`
SELECT jr.id, jr.user_id, jr.created_at,
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
FROM join_requests jr
JOIN users u ON u.id = jr.user_id
WHERE jr.id = ?
`).get(id) as JoinRequest | null;
}
export function deleteJoinRequest(id: number): void {
db.prepare('DELETE FROM join_requests WHERE id = ?').run(id);
}
// ── Date helpers ───────────────────────────────────────────────── // ── Date helpers ─────────────────────────────────────────────────
/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */ /** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */

View file

@ -16,6 +16,7 @@ import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro';
import { groups } from '../../admin/resources'; import { groups } from '../../admin/resources';
import { validateForResource, type ValidationErrors } from '../../admin/validate'; import { validateForResource, type ValidationErrors } from '../../admin/validate';
import type { import type {
ActionResult,
Field, Field,
OpContext, OpContext,
Resource, Resource,
@ -123,11 +124,13 @@ if (Astro.request.method === 'POST') {
try { try {
if (editId !== null && resource.ops.update) { if (editId !== null && resource.ops.update) {
await resource.ops.update(editId, data, opCtx); await resource.ops.update(editId, data, opCtx);
return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved`); const extra = resultRedirectParam(opCtx.result);
return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved${extra}`);
} }
if (editId === null && resource.ops.create) { if (editId === null && resource.ops.create) {
const newId = await resource.ops.create(data, opCtx); const newId = await resource.ops.create(data, opCtx);
return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created`); const extra = resultRedirectParam(opCtx.result);
return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created${extra}`);
} }
return Astro.redirect(`${resourceBase}?msg=saved`); return Astro.redirect(`${resourceBase}?msg=saved`);
} catch (err) { } catch (err) {
@ -156,10 +159,19 @@ if (Astro.request.method === 'POST') {
const customAction = resource.actions?.find((a) => a.key === action); const customAction = resource.actions?.find((a) => a.key === action);
if (customAction && editId !== null) { if (customAction && editId !== null) {
try { try {
await customAction.handler(editId, opCtx); const direct = await customAction.handler(editId, opCtx);
return Astro.redirect( // Handlers may set ctx.result or return an ActionResult — accept both.
`${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}`, 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) { } catch (err) {
formError = err instanceof Error ? err.message : 'Action failed'; formError = err instanceof Error ? err.message : 'Action failed';
} }
@ -169,6 +181,14 @@ if (Astro.request.method === 'POST') {
} }
} }
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 ────────────────────────────────────────────── // ── GET / failed-POST render ──────────────────────────────────────────────
const isNew = Astro.url.searchParams.get('new') === '1'; const isNew = Astro.url.searchParams.get('new') === '1';
const editIdRaw = Astro.url.searchParams.get('edit'); const editIdRaw = Astro.url.searchParams.get('edit');
@ -180,7 +200,12 @@ const editingItem =
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null) ? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
: null; : null;
const showPanel = resource.form !== null && (isNew || editingItem !== 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 msg = Astro.url.searchParams.get('msg');
const pageTitle = `${resource.pluralLabel} — Backstage`; const pageTitle = `${resource.pluralLabel} — Backstage`;
@ -216,7 +241,7 @@ const flashKind = formError ? 'error' : 'success';
<ResourceListView resource={resource} groups={groups} /> <ResourceListView resource={resource} groups={groups} />
{showPanel && resource.form && ( {showPanel && (
<ResourceEditPanel <ResourceEditPanel
resource={resource} resource={resource}
item={editingItem} item={editingItem}