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>
192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
/* ---------------------------------------------------------------------------
|
|
* 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. ctx.origin comes from Astro.url.origin
|
|
// so the link is always absolute and clickable.
|
|
ctx.result = {
|
|
kind: 'invite-link',
|
|
url: `${ctx.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);
|
|
},
|
|
},
|
|
],
|
|
};
|