/* --------------------------------------------------------------------------- * 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 = { 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); }, }, ], };