project-bifrost-platform/src/admin/resources/invitations.ts
Jonathan Hvid a520e8534e fix(admin): invite magic link is absolute, not relative
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>
2026-05-13 10:29:58 +02:00

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);
},
},
],
};