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:
parent
dd9ea68fab
commit
e9a986d484
10 changed files with 884 additions and 43 deletions
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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: [] },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
192
src/admin/resources/invitations.ts
Normal file
192
src/admin/resources/invitations.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
110
src/admin/resources/join-requests.ts
Normal file
110
src/admin/resources/join-requests.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
324
src/admin/resources/users.ts
Normal file
324
src/admin/resources/users.ts
Normal 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 };
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue