import { createPortal } from 'react-dom'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { InviteLinkSummary, InviteRedemption, InviteStatus } from '@backspace/shared'; import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { Modal } from '../../ui/Modal'; interface RegistrationDraft { registrationOpen: boolean; federatedRegistrationOpen: boolean; } function formatRelative(ms: number): string { const diff = Date.now() - ms; const days = Math.floor(diff / 86_400_000); if (days >= 1) return `${days}d ago`; const hours = Math.floor(diff / 3_600_000); if (hours >= 1) return `${hours}h ago`; const mins = Math.floor(diff / 60_000); if (mins >= 1) return `${mins}m ago`; return 'just now'; } function formatExpiry(invite: InviteLinkSummary): string { if (invite.status === 'revoked' && invite.revokedAt) { return `Revoked ${new Date(invite.revokedAt).toLocaleDateString()}`; } if (invite.status === 'expired' && invite.expiresAt) { return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; } if (invite.status === 'exhausted') { return 'Exhausted'; } if (invite.expiresAt === null) return 'No expiration'; const remaining = invite.expiresAt - Date.now(); if (remaining <= 0) return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; const days = Math.floor(remaining / 86_400_000); if (days >= 1) return `Expires in ${days} day${days === 1 ? '' : 's'}`; const hours = Math.floor(remaining / 3_600_000); return `Expires in ${hours}h`; } function inviteStatusDotColor(status: InviteStatus): string { switch (status) { case 'active': return 'bg-status-online'; case 'expired': return 'bg-accent-rose'; case 'exhausted': return 'bg-accent-amber'; case 'revoked': return 'bg-txt-tertiary'; } } function inviteStatusPillColor(status: InviteStatus): string { switch (status) { case 'expired': return 'bg-accent-rose/15 text-accent-rose'; case 'exhausted': return 'bg-accent-amber/15 text-accent-amber'; case 'revoked': return 'bg-white/5 text-txt-tertiary'; case 'active': return ''; // never rendered for active } } function inviteStatusLabel(status: InviteStatus): string { switch (status) { case 'active': return 'Active'; case 'expired': return 'Expired'; case 'exhausted': return 'Exhausted'; case 'revoked': return 'Revoked'; } } // --------------------------------------------------------------------------- // Sort / filter types // --------------------------------------------------------------------------- type ActiveSort = 'recent' | 'oldest' | 'name' | 'mostUsed' | 'expiringSoonest'; type ArchivedSort = 'recent' | 'oldest' | 'name'; type ArchivedStatus = 'expired' | 'exhausted' | 'revoked'; // --------------------------------------------------------------------------- // Sort / filter pure functions // Sort/filter applied client-side. At ~500+ invites in a single bucket, // move to server-side: add `?sort=` and `?status=` params to /admin/invites, // page through results. Today the test instances have <50 invites total. // --------------------------------------------------------------------------- function sortInvites( list: InviteLinkSummary[], sortKey: ActiveSort | ArchivedSort, ): InviteLinkSummary[] { const arr = [...list]; switch (sortKey) { case 'recent': return arr.sort((a, b) => b.createdAt - a.createdAt); case 'oldest': return arr.sort((a, b) => a.createdAt - b.createdAt); case 'name': return arr.sort((a, b) => a.name.localeCompare(b.name)); case 'mostUsed': return arr.sort((a, b) => b.usedCount - a.usedCount); case 'expiringSoonest': return arr.sort((a, b) => { // null expiresAt (no expiration) sorts last if (a.expiresAt === null && b.expiresAt === null) return 0; if (a.expiresAt === null) return 1; if (b.expiresAt === null) return -1; return a.expiresAt - b.expiresAt; }); } } function filterInvitesByStatus( list: InviteLinkSummary[], statuses: Set, ): InviteLinkSummary[] { // ArchivedStatus excludes 'active' by construction — this filter is only // applied on the archived tab where all invites have a non-active status. return list.filter((inv) => statuses.has(inv.status as ArchivedStatus)); } // --------------------------------------------------------------------------- // FilterDropdown // --------------------------------------------------------------------------- interface FilterDropdownProps { view: 'active' | 'archived'; activeSort: ActiveSort; onActiveSortChange: (s: ActiveSort) => void; archivedSort: ArchivedSort; onArchivedSortChange: (s: ArchivedSort) => void; archivedStatusFilter: Set; onArchivedStatusToggle: (s: ArchivedStatus) => void; } function FilterDropdown({ view, activeSort, onActiveSortChange, archivedSort, onArchivedSortChange, archivedStatusFilter, onArchivedStatusToggle, }: FilterDropdownProps) { const [open, setOpen] = useState(false); const activeSortOptions: Array<{ key: ActiveSort; label: string }> = [ { key: 'recent', label: 'Most recent' }, { key: 'oldest', label: 'Oldest' }, { key: 'name', label: 'Name (A–Z)' }, { key: 'mostUsed', label: 'Most used' }, { key: 'expiringSoonest', label: 'Expiring soonest' }, ]; const archivedSortOptions: Array<{ key: ArchivedSort; label: string }> = [ { key: 'recent', label: 'Most recent' }, { key: 'oldest', label: 'Oldest' }, { key: 'name', label: 'Name (A–Z)' }, ]; const archivedStatusOptions: ArchivedStatus[] = ['expired', 'exhausted', 'revoked']; const handleArchivedStatusToggle = (s: ArchivedStatus) => { // Prevent deselecting the last selected status — always keep at least one. if (archivedStatusFilter.has(s) && archivedStatusFilter.size === 1) return; onArchivedStatusToggle(s); }; return (
{open && ( <>
setOpen(false)} />
{view === 'archived' && ( <>
Status
{archivedStatusOptions.map((s) => ( ))}
)}
Sort by
{view === 'active' ? activeSortOptions.map((opt) => ( )) : archivedSortOptions.map((opt) => ( ))}
)}
); } type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; /** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ type EditExpiryId = 'keep' | ExpiryPresetId; const EXPIRY_PRESETS: ReadonlyArray<{ id: ExpiryPresetId; label: string; ms: number | null }> = [ { id: '1h', label: '1 hour', ms: 3_600_000 }, { id: '24h', label: '24 hours', ms: 86_400_000 }, { id: '7d', label: '7 days', ms: 7 * 86_400_000 }, { id: '30d', label: '30 days', ms: 30 * 86_400_000 }, { id: 'never', label: 'Never', ms: null }, // Custom uses a free-form datetime input rendered below the preset row; // ms is intentionally null and ignored for this id. { id: 'custom', label: 'Custom…', ms: null }, ]; /** * Format a millisecond timestamp as a value suitable for ``. * The input expects local-wall-clock time in `YYYY-MM-DDTHH:mm` format (no timezone suffix); * the browser then interprets it in the user's local timezone on read-back via `new Date(value)`. */ function toDatetimeLocalValue(ms: number): string { const d = new Date(ms); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } interface ExpirySelectorProps { value: EditExpiryId; customDateTime: string; onChange: (value: EditExpiryId, customDateTime: string) => void; /** When true, prepends a "Keep current" pill (used by Edit). Create/Reinstate omit it. */ showKeep: boolean; disabled?: boolean; } /** * Shared expiry preset row + custom datetime input. Used by Create, Edit, and Reinstate * modals so the picker UX stays consistent across all three flows. * * Resolution rules (applied by callers via `resolveExpiryFromSelector` below): * 'keep' → omit `expiresAt` from request body (Edit only) * 'never' → expiresAt: null * 'custom' → expiresAt: new Date(customDateTime).getTime() (validated by caller) * preset → expiresAt: Date.now() + preset.ms */ function ExpirySelector({ value, customDateTime, onChange, showKeep, disabled }: ExpirySelectorProps) { return (
{showKeep && ( )} {EXPIRY_PRESETS.map((p) => ( ))}
{value === 'custom' && ( onChange('custom', e.target.value)} min={toDatetimeLocalValue(Date.now() + 60_000)} disabled={disabled} className="input-standard w-full px-3 py-2 text-sm mt-2" /> )}
); } /** * Resolve an `ExpirySelector` selection into a `expiresAt` timestamp for API bodies. * * Returns one of: * - `{ kind: 'omit' }` — caller should NOT include `expiresAt` in the body (Edit "Keep current") * - `{ kind: 'value', expiresAt: number | null }` — caller sets `body.expiresAt = expiresAt` * - `{ kind: 'invalid', message: string }` — caller should toast the message and abort */ function resolveExpiryFromSelector( value: EditExpiryId, customDateTime: string, ): | { kind: 'omit' } | { kind: 'value'; expiresAt: number | null } | { kind: 'invalid'; message: string } { if (value === 'keep') return { kind: 'omit' }; if (value === 'never') return { kind: 'value', expiresAt: null }; if (value === 'custom') { if (customDateTime === '') { return { kind: 'invalid', message: 'Pick a future date & time' }; } const ts = new Date(customDateTime).getTime(); if (!Number.isFinite(ts) || ts <= Date.now()) { return { kind: 'invalid', message: 'Pick a future date & time' }; } return { kind: 'value', expiresAt: ts }; } const preset = EXPIRY_PRESETS.find((p) => p.id === value); if (!preset || preset.ms === null) { // Unreachable: 'never' and 'custom' are handled above; remaining ids all carry an ms. return { kind: 'invalid', message: 'Invalid expiry selection' }; } return { kind: 'value', expiresAt: Date.now() + preset.ms }; } interface CreateInviteModalProps { onClose: () => void; onCreated: (created: InviteLinkSummary) => void; } function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { const addToast = useUIStore((s) => s.addToast); const [name, setName] = useState(''); const [unlimited, setUnlimited] = useState(true); const [maxUses, setMaxUses] = useState('1'); const [expiryId, setExpiryId] = useState('7d'); const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); const nameInputRef = useRef(null); // Auto-focus name input on mount useEffect(() => { nameInputRef.current?.focus(); }, []); // Block close (Escape / backdrop click) while a submit is in flight; the shared // Modal wires Escape + backdrop-click to onClose, so we gate them here rather // than in a separate keydown listener. const handleClose = useCallback(() => { if (!submitting) onClose(); }, [onClose, submitting]); const handleCreate = async () => { const trimmed = name.trim(); if (trimmed.length === 0 || trimmed.length > 64) { addToast('Name must be 1–64 characters', 'warning'); return; } let maxUsesNum: number | null = null; if (!unlimited) { const parsed = Number(maxUses); if (!Number.isInteger(parsed) || parsed < 1) { addToast('Max uses must be a positive integer', 'warning'); return; } maxUsesNum = parsed; } // Create never uses the 'keep' option — it always sets a concrete expiry. const resolved = resolveExpiryFromSelector(expiryId, customDateTime); if (resolved.kind === 'invalid') { addToast(resolved.message, 'warning'); return; } if (resolved.kind === 'omit') { // Unreachable: ExpirySelector for Create is rendered with showKeep={false}, so // 'keep' cannot be selected. Defensive guard so future refactors fail loudly. addToast('Invalid expiry selection', 'warning'); return; } const expiresAt = resolved.expiresAt; setSubmitting(true); try { const created = await api.invites.create({ name: trimmed, maxUses: maxUsesNum, expiresAt }); try { await navigator.clipboard.writeText(created.url); addToast('Link created. Copied to clipboard.', 'success', 2000); } catch { addToast('Link created. Copy manually from the row.', 'success', 2000); } onCreated(created); onClose(); } catch (err) { addToast(`Failed to create invite: ${(err as Error).message}`, 'warning'); } finally { setSubmitting(false); } }; return createPortal( {/* Decorative icon + helper text — kept inside children so the visual identity (lavender icon chip + descriptive paragraph) is preserved across desktop and mobile fullscreen. */}

Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires.

{ e.preventDefault(); handleCreate(); }} className="space-y-5" > {/* Name */}
Name
setName(e.target.value)} maxLength={64} placeholder="e.g. Friends batch 1" className="input-standard w-full" disabled={submitting} />
{/* Max uses */}
Max uses
{/* Expiry */}
Expires
{ // Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot // be returned because never renders it. if (v === 'keep') return; setExpiryId(v); setCustomDateTime(dt); }} showKeep={false} disabled={submitting} />
{/* Actions */}
, document.body, ); } interface EditInviteModalProps { invite: InviteLinkSummary; onClose: () => void; onUpdated: () => void; } /** * Edit modal — same shape as Create, pre-filled. Only sends fields the user actually changed * (no-op churn avoidance). Disallowed for revoked rows (the row hides the Edit button entirely * — Reinstate is the only path back from revoked). */ function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) { const addToast = useUIStore((s) => s.addToast); const [name, setName] = useState(invite.name); const [unlimited, setUnlimited] = useState(invite.maxUses === null); const [maxUses, setMaxUses] = useState(invite.maxUses?.toString() ?? '1'); const [expiryId, setExpiryId] = useState('keep'); const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); const nameInputRef = useRef(null); // Auto-focus name input on mount useEffect(() => { nameInputRef.current?.focus(); }, []); // Block close (Escape / backdrop click) while a save is in flight; the shared // Modal wires both to onClose for us. const handleClose = useCallback(() => { if (!submitting) onClose(); }, [onClose, submitting]); const handleSave = async () => { const trimmed = name.trim(); if (trimmed.length === 0 || trimmed.length > 64) { addToast('Name must be 1–64 characters', 'warning'); return; } // Validate maxUses input only when the user has selected limited mode. // Server requires `maxUses >= usedCount` (a server-side floor of 1 still applies). let newMax: number | null = null; if (!unlimited) { const parsed = Number(maxUses); if (!Number.isInteger(parsed) || parsed < 1) { addToast('Max uses must be a positive integer', 'warning'); return; } if (parsed < invite.usedCount) { addToast( `Max uses cannot be less than current uses (${invite.usedCount})`, 'warning', ); return; } newMax = parsed; } // Build a partial body — only include fields the user actually changed. const body: { name?: string; maxUses?: number | null; expiresAt?: number | null } = {}; if (trimmed !== invite.name) body.name = trimmed; if (newMax !== invite.maxUses) body.maxUses = newMax; const resolved = resolveExpiryFromSelector(expiryId, customDateTime); if (resolved.kind === 'invalid') { addToast(resolved.message, 'warning'); return; } if (resolved.kind === 'value') { body.expiresAt = resolved.expiresAt; } // 'omit' (Keep current) → leave expiresAt off the body entirely. if (Object.keys(body).length === 0) { addToast('No changes to save', 'warning'); return; } setSubmitting(true); try { await api.invites.update(invite.id, body); addToast('Invite updated', 'success', 2000); onUpdated(); onClose(); } catch (err) { addToast(`Failed to update invite: ${(err as Error).message}`, 'warning'); } finally { setSubmitting(false); } }; // Floor for the maxUses input — at least 1, but also at least usedCount so the // browser native validation matches the server's constraint. const maxUsesMin = Math.max(1, invite.usedCount); return createPortal(

Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints.

{ e.preventDefault(); handleSave(); }} className="space-y-5" > {/* Name */}
Name
setName(e.target.value)} maxLength={64} className="input-standard w-full" disabled={submitting} />
{/* Max uses */}
Max uses ({invite.usedCount} used)
{/* Expiry */}
Expires
{ setExpiryId(v); setCustomDateTime(dt); }} showKeep={true} disabled={submitting} />
{/* Actions */}
, document.body, ); } interface ReinstateInviteModalProps { invite: InviteLinkSummary; onClose: () => void; onReinstated: () => void; } /** * Reinstate modal. Two visual variants share one component: * - Variant A (revoked): warns that a NEW link will be generated; old URL stays dead. * - Variant B (expired/exhausted): same URL becomes active again. * * Both variants always set a fresh expiry (the user is reactivating something whose * expiry has, by definition, lapsed or is being re-set). Server's reinstate handler * requires `maxUses > usedCount` for exhausted invites, hence the input min of usedCount+1. * * Toast and submit-label copy are derived from the response's `tokenRotated` flag and * the invite's pre-action status, respectively, per spec §4.2. */ function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInviteModalProps) { const addToast = useUIStore((s) => s.addToast); const isRevoked = invite.status === 'revoked'; const [unlimited, setUnlimited] = useState(invite.maxUses === null); // Default to a value strictly greater than usedCount — for exhausted invites, that's // the minimum the server will accept; for others, it's a sensible bump. const [maxUses, setMaxUses] = useState(() => { const baseline = invite.maxUses ?? invite.usedCount + 1; return Math.max(baseline, invite.usedCount + 1).toString(); }); const [expiryId, setExpiryId] = useState('7d'); const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); // Block close (Escape / backdrop click) while reinstate is in flight; the // shared Modal wires both to onClose. const handleClose = useCallback(() => { if (!submitting) onClose(); }, [onClose, submitting]); const maxUsesMin = invite.usedCount + 1; const handleReinstate = async () => { // Validate maxUses when limited. let newMax: number | null = null; if (!unlimited) { const parsed = Number(maxUses); if (!Number.isInteger(parsed) || parsed < maxUsesMin) { addToast( `Max uses must be at least ${maxUsesMin} (current uses: ${invite.usedCount})`, 'warning', ); return; } newMax = parsed; } // Reinstate always sets a new expiry — no 'keep' option in this flow. const resolved = resolveExpiryFromSelector(expiryId, customDateTime); if (resolved.kind === 'invalid') { addToast(resolved.message, 'warning'); return; } if (resolved.kind === 'omit') { // Unreachable: ExpirySelector for Reinstate is rendered with showKeep={false}. addToast('Invalid expiry selection', 'warning'); return; } const body: { maxUses?: number | null; expiresAt?: number | null } = { maxUses: newMax, expiresAt: resolved.expiresAt, }; setSubmitting(true); try { const result = await api.invites.reinstate(invite.id, body); if (result.tokenRotated) { try { await navigator.clipboard.writeText(result.invite.url); addToast('Reinstated with new link. Copied to clipboard.', 'success', 2500); } catch { addToast('Reinstated with new link. Copy manually from the row.', 'success', 2500); } } else { addToast('Reinstated. The same link is active again.', 'success', 2500); } onReinstated(); onClose(); } catch (err) { addToast(`Failed to reinstate: ${(err as Error).message}`, 'warning'); } finally { setSubmitting(false); } }; const subtitle = isRevoked ? 'This invite was revoked. Reinstating generates a new link with a different URL — the old URL stays inactive.' : 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.'; return createPortal(

{subtitle}

{ e.preventDefault(); handleReinstate(); }} className="space-y-5" > {/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */} {isRevoked && (
A new link will be generated. Anyone who had the old URL will not be able to use it.
)} {/* Max uses */}
Max uses (current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})
{/* Expiry */}
Expires
{ if (v === 'keep') return; // unreachable: showKeep={false} setExpiryId(v); setCustomDateTime(dt); }} showKeep={false} disabled={submitting} />
{/* Actions */}
, document.body, ); } interface RedemptionsModalProps { invite: InviteLinkSummary; onClose: () => void; } /** * Read-only redemption viewer. Each row shows the registrant's username at sign-up * time. When the live state has diverged (rename or account deletion), the row shows * the original name with the live state in parens — `alice (now Anastasia)` or * `bob (now Deleted User)` per spec §4.3. * * Note: the spec mentions opening the user's profile (UserPopover pattern) on row * click. That popover is not currently wired into a generic trigger callable from * outside its existing call sites, so this modal renders rows as non-interactive. * Adding click-through is a later polish pass — see Task 19 report. */ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { const addToast = useUIStore((s) => s.addToast); const [redemptions, setRedemptions] = useState(null); const [error, setError] = useState(false); useEffect(() => { let cancelled = false; api.invites .redemptions(invite.id) .then((r) => { if (!cancelled) setRedemptions(r.redemptions); }) .catch(() => { if (cancelled) return; setError(true); setRedemptions([]); addToast('Failed to load redemptions', 'warning'); }); return () => { cancelled = true; }; }, [invite.id, addToast]); return createPortal(

Users who registered using this invite link, in the order they signed up.

{/* Sticky summary band — pins at top of scroll area so the count + revoked warning stay visible while the list scrolls under them. The negative horizontal margin + padding compensates the body's `p-4` so the background can extend edge-to-edge. */}
{invite.status === 'revoked' && (
This invite was revoked {invite.revokedAt ? ` ${new Date(invite.revokedAt).toLocaleDateString()}` : ''} . The redemptions below represent users who registered before revocation.
)}
{invite.usedCount} {invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use {invite.usedCount === 1 ? '' : 's'}
{redemptions === null ? (
Loading…
) : redemptions.length === 0 ? (
{error ? 'Could not load redemptions.' : 'No redemptions yet.'}
) : (
{redemptions.map((r) => { const showCurrent = !r.isDeleted && r.currentUsername !== null && r.currentUsername !== r.registrantUsername; return (
{r.registrantUsername} {(r.isDeleted || showCurrent) && ( {' '} (now {r.isDeleted ? 'Deleted User' : r.currentUsername}) )} {new Date(r.redeemedAt).toLocaleString()}
); })}
)}
, document.body, ); } interface InviteRowProps { invite: InviteLinkSummary; expanded: boolean; onToggleExpand: () => void; onMutate: () => void; } /** * One row in the invite list. Renders as a clickable collapsed header that, when * expanded, reveals a meta grid + status-specific action row. Owns its own modal * and confirm-dialog state so the parent panel only manages list-level fetch + * single-row expansion state (`expandedInviteId`). * * Action surface depends on `invite.status`: * - active → Copy link · Edit · Revoke · View redemptions * - non-active → Reinstate · Delete permanently · View redemptions */ function InviteRow({ invite, expanded, onToggleExpand, onMutate }: InviteRowProps) { const addToast = useUIStore((s) => s.addToast); const [showEdit, setShowEdit] = useState(false); const [showReinstate, setShowReinstate] = useState(false); const [showRedemptions, setShowRedemptions] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [actionLoading, setActionLoading] = useState(false); const usageLabel = invite.maxUses === null ? `${invite.usedCount} / ∞` : `${invite.usedCount} / ${invite.maxUses}`; const usageNearLimit = invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; const handleCopy = async () => { try { await navigator.clipboard.writeText(invite.url); addToast('Invite link copied', 'success', 2000); } catch { addToast('Failed to copy link', 'warning'); } }; const performRevoke = async () => { setActionLoading(true); try { await api.invites.revoke(invite.id); addToast('Invite revoked', 'success', 2000); setConfirmRevoke(false); onMutate(); } catch (err) { addToast(`Failed to revoke: ${(err as Error).message}`, 'warning'); } finally { setActionLoading(false); } }; const performDelete = async () => { setActionLoading(true); try { await api.invites.delete(invite.id); addToast('Invite deleted', 'success', 2000); setConfirmDelete(false); onMutate(); } catch (err) { addToast(`Failed to delete: ${(err as Error).message}`, 'warning'); } finally { setActionLoading(false); } }; const isActive = invite.status === 'active'; const createdByLabel = invite.createdByUsername ?? 'Unknown'; // Subtitle (collapsed view) // active → "X / Y uses · Expires in 3 days · Created by alice" // archived → "X / Y uses · Revoked 4/12/2026 · 2d ago" const subtitle = isActive ? `${usageLabel} uses · ${formatExpiry(invite)} · Created by ${createdByLabel}` : `${usageLabel} uses · ${formatExpiry(invite)} · ${formatRelative(invite.createdAt)}`; // Archived row 1 second-cell label + value. EXHAUSTED has no dedicated terminal // timestamp on the invite, so we surface lastRedeemedAt (the moment that drove // it to exhausted) when known, falling back to em-dash if absent. let archivedTerminalLabel: string; let archivedTerminalValue: string; if (invite.status === 'expired') { archivedTerminalLabel = 'EXPIRED AT'; archivedTerminalValue = invite.expiresAt !== null ? formatRelative(invite.expiresAt) : '—'; } else if (invite.status === 'revoked') { archivedTerminalLabel = 'REVOKED AT'; archivedTerminalValue = invite.revokedAt !== null ? formatRelative(invite.revokedAt) : '—'; } else { // exhausted archivedTerminalLabel = 'EXHAUSTED'; archivedTerminalValue = invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; } const tokenDisplay = `…${invite.token.slice(-6)}`; const lastRedeemedDisplay = invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; return ( <>
{/* Collapsed clickable header */}
{invite.name}
{subtitle}
{!isActive && ( {inviteStatusLabel(invite.status)} )} {expanded ? '▾' : '▸'}
{/* Expanded body */} {expanded && (
{/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */}
Used
{usageLabel}
{isActive ? (
Expires
{formatExpiry(invite)}
) : (
{archivedTerminalLabel}
{archivedTerminalValue}
)}
Created
{formatRelative(invite.createdAt)}
{/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */}
Created by
{createdByLabel}
Token
{tokenDisplay}
Last redeemed
{lastRedeemedDisplay}
{/* Action row */}
{isActive ? ( <> ) : ( <> )}
)}
{showEdit && ( setShowEdit(false)} onUpdated={onMutate} /> )} {showReinstate && ( setShowReinstate(false)} onReinstated={onMutate} /> )} {showRedemptions && ( setShowRedemptions(false)} /> )} setConfirmRevoke(false)} onConfirm={performRevoke} title={`Revoke "${invite.name}"?`} description={ <> The link stops working immediately. Anyone who has the URL can no longer use it.

If you change your mind later, Reinstate issues a fresh link under this entry — the original URL stays inactive. } confirmLabel="Revoke link" variant="danger" loading={actionLoading} /> setConfirmDelete(false)} onConfirm={performDelete} title={`Delete "${invite.name}" permanently?`} description={ <> This cannot be undone. Redemption history for this link will also be removed. If you only want to stop the link from working, use Revoke{' '} instead — that preserves the redemption record. } confirmLabel="Delete permanently" variant="danger" loading={actionLoading} /> ); } export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); const addToast = useUIStore((s) => s.addToast); const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); const [showCreate, setShowCreate] = useState(false); const [tab, setTab] = useState<'active' | 'archived'>('active'); const [invites, setInvites] = useState([]); const [invitesLoading, setInvitesLoading] = useState(false); const [activeCount, setActiveCount] = useState(0); const [archivedCount, setArchivedCount] = useState(0); // Single-row expansion state — only one InviteRow at a time may be expanded. // Lifted to the panel so switching tabs can reset it; otherwise an expanded row // that scrolls out of the visible list keeps stale state. const [expandedInviteId, setExpandedInviteId] = useState(null); // Sort / filter state — independent per tab so switching tabs preserves each // tab's last selection. const [activeSort, setActiveSort] = useState('recent'); const [archivedSort, setArchivedSort] = useState('recent'); const [archivedStatusFilter, setArchivedStatusFilter] = useState>( () => new Set(['expired', 'exhausted', 'revoked']), ); const toggleArchivedStatus = (s: ArchivedStatus) => { setArchivedStatusFilter((prev) => { const next = new Set(prev); if (next.has(s)) next.delete(s); else next.add(s); return next; }); }; const handleTabSwitch = (next: 'active' | 'archived') => { setTab(next); setExpandedInviteId(null); }; // Tracks the currently displayed tab so in-flight fetches can detect when // the user has switched tabs and discard their stale response. Without this // guard, a slower 'archived' response can resolve after a newer 'active' // response and clobber the visible list. Used by both the auto-load effect // and manual fetchInvites() callers (e.g. post-mutation refresh). const tabRef = useRef<'active' | 'archived'>(tab); useEffect(() => { tabRef.current = tab; }, [tab]); const fetchInvites = useCallback( async (which: 'active' | 'archived') => { setInvitesLoading(true); try { const res = await api.invites.list(which); if (tabRef.current !== which) return; setInvites(res.invites); } catch { if (tabRef.current === which) addToast('Failed to load invites', 'warning'); } finally { if (tabRef.current === which) setInvitesLoading(false); } }, [addToast], ); const refreshCounts = useCallback(async () => { try { const [a, r] = await Promise.all([ api.invites.list('active'), api.invites.list('archived'), ]); setActiveCount(a.invites.length); setArchivedCount(r.invites.length); } catch { // Leave previous counts on transient failure } }, []); useEffect(() => { refreshCounts(); }, [refreshCounts]); useEffect(() => { if (instanceSettings) { setDraft({ registrationOpen: instanceSettings.registrationOpen, federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, }); setSaveError(''); } }, [instanceSettings]); useEffect(() => { fetchInvites(tab); }, [tab, fetchInvites]); // Derived sorted + filtered list. Tab badge counts (`activeCount`/`archivedCount`) // are NOT derived from this — they reflect the full unfiltered bucket size. // Must be declared before any conditional early-return to satisfy rules-of-hooks. const displayInvites = useMemo(() => { let list = invites; if (tab === 'archived') { list = filterInvitesByStatus(list, archivedStatusFilter); } list = sortInvites(list, tab === 'active' ? activeSort : archivedSort); return list; }, [invites, tab, activeSort, archivedSort, archivedStatusFilter]); if (!draft) return
Loading settings...
; const hasChanges = !!instanceSettings && ( draft.registrationOpen !== instanceSettings.registrationOpen || draft.federatedRegistrationOpen !== instanceSettings.federatedRegistrationOpen ); const handleSave = async () => { setSaving(true); setSaveError(''); try { await updateInstanceSettings({ registrationOpen: draft.registrationOpen, federatedRegistrationOpen: draft.federatedRegistrationOpen, }); addToast('Registration settings saved', 'success', 2000); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save'; setSaveError(message); addToast('Failed to update registration settings', 'warning'); } finally { setSaving(false); } }; const handleReset = () => { if (instanceSettings) { setDraft({ registrationOpen: instanceSettings.registrationOpen, federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, }); } setSaveError(''); }; return ( <>
e.preventDefault()}>

Registration

Control who can create accounts on this instance. Public registration covers local sign-ups; federated registration covers users from peered instances creating an account here.
{/* Public registration */}
Public Registration
{/* Federated registration */}
Federated Registration
{/* Invite Links */}
{/* Row 1: heading + create button */}
Invite Links
{/* Row 2: tab strip (left) + FilterDropdown (right) */}
{invitesLoading ? (
Loading...
) : invites.length === 0 ? (
{tab === 'active' ? 'No active invite links.' : 'No archived invite links.'}
) : displayInvites.length === 0 ? (
No invites match the current filter.
) : (
{displayInvites.map((inv) => ( setExpandedInviteId(expandedInviteId === inv.id ? null : inv.id) } onMutate={() => { fetchInvites(tab); refreshCounts(); }} /> ))}
)}
{/* Status messages */} {saveError && (
{saveError}
)} {/* Save / Reset bar */} {hasChanges && (
)}
{showCreate && ( setShowCreate(false)} onCreated={() => { fetchInvites('active'); refreshCounts(); }} /> )} ); }