Files
backspace/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx
T
Jannis Braun e2d938e6c0 feat(mobile): admin reach + post-mount routing + RegistrationPanel modals → Modal
MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.

MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.

RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
2026-05-05 23:23:37 +02:00

1767 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ArchivedStatus>,
): 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<ArchivedStatus>;
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 (AZ)' },
{ 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 (AZ)' },
];
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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] text-txt-tertiary hover:text-txt-secondary bg-white/[0.04] hover:bg-white/[0.06] rounded transition-colors"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="opacity-60">
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Filter
<span className="text-[10px]"></span>
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 glass rounded-lg p-1.5 w-48">
{view === 'archived' && (
<>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-2 py-1">
Status
</div>
{archivedStatusOptions.map((s) => (
<button
key={s}
type="button"
onClick={() => handleArchivedStatusToggle(s)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded ${
archivedStatusFilter.has(s)
? 'text-txt-primary bg-white/[0.04]'
: 'text-txt-tertiary'
} hover:bg-white/[0.06] transition-colors`}
>
<div className={`w-2 h-2 rounded-full ${inviteStatusDotColor(s)}`} />
<span>{inviteStatusLabel(s)}</span>
</button>
))}
<div className="h-px bg-white/[0.06] my-1" />
</>
)}
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-2 py-1">
Sort by
</div>
{view === 'active'
? activeSortOptions.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => { onActiveSortChange(opt.key); setOpen(false); }}
className={`w-full text-left px-2 py-1.5 text-xs rounded ${
activeSort === opt.key
? 'text-accent-lavender bg-accent-lavender/[0.08]'
: 'text-txt-primary'
} hover:bg-white/[0.06] transition-colors`}
>
{opt.label}
</button>
))
: archivedSortOptions.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => { onArchivedSortChange(opt.key); setOpen(false); }}
className={`w-full text-left px-2 py-1.5 text-xs rounded ${
archivedSort === opt.key
? 'text-accent-lavender bg-accent-lavender/[0.08]'
: 'text-txt-primary'
} hover:bg-white/[0.06] transition-colors`}
>
{opt.label}
</button>
))}
</div>
</>
)}
</div>
);
}
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 `<input type="datetime-local">`.
* 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 (
<div>
<div className="flex items-center gap-2 flex-wrap">
{showKeep && (
<button
type="button"
onClick={() => onChange('keep', customDateTime)}
disabled={disabled}
className={`px-2.5 py-1 text-xs rounded transition-colors disabled:opacity-50 ${
value === 'keep'
? 'bg-accent-primary text-white'
: 'bg-surface-input text-txt-tertiary hover:text-txt-secondary'
}`}
>
Keep current
</button>
)}
{EXPIRY_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onChange(p.id, customDateTime)}
disabled={disabled}
className={`px-2.5 py-1 text-xs rounded transition-colors disabled:opacity-50 ${
value === p.id
? 'bg-accent-primary text-white'
: 'bg-surface-input text-txt-tertiary hover:text-txt-secondary'
}`}
>
{p.label}
</button>
))}
</div>
{value === 'custom' && (
<input
type="datetime-local"
value={customDateTime}
onChange={(e) => 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"
/>
)}
</div>
);
}
/**
* 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<ExpiryPresetId>('7d');
const [customDateTime, setCustomDateTime] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(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 164 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(
<Modal
isOpen
onClose={handleClose}
title="Create invite link"
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
{/* Decorative icon + helper text — kept inside children so the visual
identity (lavender icon chip + descriptive paragraph) is preserved
across desktop and mobile fullscreen. */}
<div className="flex items-start gap-3 -mt-1 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</div>
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
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.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
className="space-y-5"
>
{/* Name */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={64}
placeholder="e.g. Friends batch 1"
className="input-standard w-full"
disabled={submitting}
/>
</div>
{/* Max uses */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Max uses</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="maxUsesMode"
checked={unlimited}
onChange={() => setUnlimited(true)}
disabled={submitting}
className="accent-accent-primary"
/>
<span className="text-txt-primary">Unlimited</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="maxUsesMode"
checked={!unlimited}
onChange={() => setUnlimited(false)}
disabled={submitting}
className="accent-accent-primary"
/>
<input
type="number"
min={1}
value={maxUses}
onChange={(e) => {
setMaxUses(e.target.value);
setUnlimited(false);
}}
onClick={() => setUnlimited(false)}
disabled={submitting}
className="input-standard w-16 text-center disabled:opacity-50"
/>
<span className="text-txt-secondary">uses</span>
</label>
</div>
</div>
{/* Expiry */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
<ExpirySelector
value={expiryId}
customDateTime={customDateTime}
onChange={(v, dt) => {
// Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot
// be returned because <ExpirySelector showKeep={false}> never renders it.
if (v === 'keep') return;
setExpiryId(v);
setCustomDateTime(dt);
}}
showKeep={false}
disabled={submitting}
/>
</div>
{/* Actions */}
<div className="flex justify-center pt-2">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{submitting ? 'Creating…' : 'Create link'}
</button>
</div>
</div>
</form>
</Modal>,
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<EditExpiryId>('keep');
const [customDateTime, setCustomDateTime] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(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 164 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(
<Modal
isOpen
onClose={handleClose}
title={`Edit "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
<div className="flex items-start gap-3 -mt-1 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</div>
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
Adjust the limits on this invite link. The URL stays the same anyone who already has it can still redeem under the new constraints.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
className="space-y-5"
>
{/* Name */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Name</div>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={64}
className="input-standard w-full"
disabled={submitting}
/>
</div>
{/* Max uses */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
Max uses <span className="normal-case font-normal text-txt-tertiary">({invite.usedCount} used)</span>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="editMaxUsesMode"
checked={unlimited}
onChange={() => setUnlimited(true)}
disabled={submitting}
className="accent-accent-primary"
/>
<span className="text-txt-primary">Unlimited</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="editMaxUsesMode"
checked={!unlimited}
onChange={() => setUnlimited(false)}
disabled={submitting}
className="accent-accent-primary"
/>
<input
type="number"
min={maxUsesMin}
value={maxUses}
onChange={(e) => {
setMaxUses(e.target.value);
setUnlimited(false);
}}
onClick={() => setUnlimited(false)}
disabled={submitting}
className="input-standard w-16 text-center disabled:opacity-50"
/>
<span className="text-txt-secondary">uses</span>
</label>
</div>
</div>
{/* Expiry */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
<ExpirySelector
value={expiryId}
customDateTime={customDateTime}
onChange={(v, dt) => {
setExpiryId(v);
setCustomDateTime(dt);
}}
showKeep={true}
disabled={submitting}
/>
</div>
{/* Actions */}
<div className="flex justify-center pt-2">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{submitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
</form>
</Modal>,
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<ExpiryPresetId>('7d');
const [customDateTime, setCustomDateTime] = useState<string>('');
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(
<Modal
isOpen
onClose={handleClose}
title={`Reinstate "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-md"
>
<div className="flex items-start gap-3 -mt-1 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-lavender/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-lavender">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</div>
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">{subtitle}</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleReinstate();
}}
className="space-y-5"
>
{/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */}
{isRevoked && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20 text-[13px] text-accent-amber">
A new link will be generated. Anyone who had the old URL will not be able to use it.
</div>
)}
{/* Max uses */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
Max uses <span className="normal-case font-normal text-txt-tertiary">(current: {invite.maxUses ?? '∞'}, used: {invite.usedCount})</span>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="reinstateMaxUsesMode"
checked={unlimited}
onChange={() => setUnlimited(true)}
disabled={submitting}
className="accent-accent-primary"
/>
<span className="text-txt-primary">Unlimited</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="reinstateMaxUsesMode"
checked={!unlimited}
onChange={() => setUnlimited(false)}
disabled={submitting}
className="accent-accent-primary"
/>
<input
type="number"
min={maxUsesMin}
value={maxUses}
onChange={(e) => {
setMaxUses(e.target.value);
setUnlimited(false);
}}
onClick={() => setUnlimited(false)}
disabled={submitting}
className="input-standard w-16 text-center disabled:opacity-50"
/>
<span className="text-txt-secondary">uses</span>
</label>
</div>
</div>
{/* Expiry */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">Expires</div>
<ExpirySelector
value={expiryId}
customDateTime={customDateTime}
onChange={(v, dt) => {
if (v === 'keep') return; // unreachable: showKeep={false}
setExpiryId(v);
setCustomDateTime(dt);
}}
showKeep={false}
disabled={submitting}
/>
</div>
{/* Actions */}
<div className="flex justify-center pt-2">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-3 animate-slide-up">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{submitting
? 'Reinstating…'
: isRevoked
? 'Reinstate with new link'
: 'Reinstate'}
</button>
</div>
</div>
</form>
</Modal>,
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<InviteRedemption[] | null>(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(
<Modal
isOpen
onClose={onClose}
title={`Redemptions for "${invite.name}"`}
mobileStyle="fullscreen"
maxWidth="max-w-lg"
>
<div className="flex items-start gap-3 -mt-1 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-sky/15 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-accent-sky">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</div>
<p className="text-[13px] text-txt-secondary leading-snug min-w-0">
Users who registered using this invite link, in the order they signed up.
</p>
</div>
{/* 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. */}
<div
className="sticky top-0 z-10 -mx-4 px-4 pb-3 space-y-3 bg-[var(--glass-modal-bg)] backdrop-blur-md"
>
{invite.status === 'revoked' && (
<div className="bg-accent-rose/10 border border-accent-rose/30 rounded p-2.5 text-xs text-accent-rose leading-relaxed">
This invite was revoked
{invite.revokedAt
? ` ${new Date(invite.revokedAt).toLocaleDateString()}`
: ''}
. The redemptions below represent users who registered before revocation.
</div>
)}
<div className="text-sm text-txt-tertiary">
{invite.usedCount}
{invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use
{invite.usedCount === 1 ? '' : 's'}
</div>
</div>
<div className="-mx-1">
{redemptions === null ? (
<div className="text-sm text-txt-tertiary px-3 py-2">Loading</div>
) : redemptions.length === 0 ? (
<div className="text-sm text-txt-tertiary px-3 py-2">
{error ? 'Could not load redemptions.' : 'No redemptions yet.'}
</div>
) : (
<div className="space-y-0.5">
{redemptions.map((r) => {
const showCurrent =
!r.isDeleted &&
r.currentUsername !== null &&
r.currentUsername !== r.registrantUsername;
return (
<div
key={r.id}
className="flex items-center justify-between gap-3 py-1.5 px-3 rounded hover:bg-surface-input cursor-default"
>
<span className="text-sm text-txt-primary truncate">
{r.registrantUsername}
{(r.isDeleted || showCurrent) && (
<span className="text-txt-tertiary">
{' '}
(now {r.isDeleted ? 'Deleted User' : r.currentUsername})
</span>
)}
</span>
<span className="text-xs text-txt-tertiary shrink-0">
{new Date(r.redeemedAt).toLocaleString()}
</span>
</div>
);
})}
</div>
)}
</div>
</Modal>,
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 (
<>
<div
className={`bg-white/[0.02] rounded-md transition-colors ${
expanded ? 'border border-white/[0.06]' : ''
}`}
>
{/* Collapsed clickable header */}
<div
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-white/[0.02] rounded-md"
onClick={onToggleExpand}
>
<div className="flex items-center gap-2.5 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${inviteStatusDotColor(invite.status)}`} />
<div className="min-w-0">
<div
className="text-sm font-medium truncate text-txt-primary"
title={invite.name}
>
{invite.name}
</div>
<div className="text-[11px] text-txt-tertiary truncate">{subtitle}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{!isActive && (
<span
className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded ${inviteStatusPillColor(invite.status)}`}
>
{inviteStatusLabel(invite.status)}
</span>
)}
<span className="text-txt-tertiary text-xs">{expanded ? '▾' : '▸'}</span>
</div>
</div>
{/* Expanded body */}
{expanded && (
<div className="px-3 pb-3">
<div className="border-t border-white/[0.05] pt-3">
{/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Used</div>
<div
className={`text-xs ${
usageNearLimit ? 'text-accent-amber font-medium' : 'text-txt-secondary'
}`}
>
{usageLabel}
</div>
</div>
{isActive ? (
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Expires</div>
<div className="text-xs text-txt-secondary">{formatExpiry(invite)}</div>
</div>
) : (
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">
{archivedTerminalLabel}
</div>
<div className="text-xs text-txt-secondary">{archivedTerminalValue}</div>
</div>
)}
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Created</div>
<div className="text-xs text-txt-secondary">{formatRelative(invite.createdAt)}</div>
</div>
</div>
{/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Created by</div>
<div className="text-xs text-txt-secondary truncate" title={createdByLabel}>
{createdByLabel}
</div>
</div>
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Token</div>
<div className="text-xs font-mono text-txt-secondary" title={invite.token}>
{tokenDisplay}
</div>
</div>
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Last redeemed</div>
<div className="text-xs text-txt-secondary">{lastRedeemedDisplay}</div>
</div>
</div>
{/* Action row */}
<div className="flex items-center gap-2 flex-wrap">
{isActive ? (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleCopy(); }}
className="px-3 py-1.5 text-xs font-medium bg-accent-lavender/10 text-accent-lavender hover:bg-accent-lavender/20 rounded transition-colors"
>
Copy link
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowEdit(true); }}
className="px-3 py-1.5 text-xs font-medium bg-accent-lavender/10 text-accent-lavender hover:bg-accent-lavender/20 rounded transition-colors"
>
Edit
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setConfirmRevoke(true); }}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Revoke
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowRedemptions(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-secondary underline decoration-dotted transition-colors ml-1"
>
View redemptions
</button>
</>
) : (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowReinstate(true); }}
className="px-3 py-1.5 text-xs font-medium bg-status-online/10 text-status-online hover:bg-status-online/20 rounded transition-colors"
>
Reinstate
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Delete permanently
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowRedemptions(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-secondary underline decoration-dotted transition-colors ml-1"
>
View redemptions
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
{showEdit && (
<EditInviteModal
invite={invite}
onClose={() => setShowEdit(false)}
onUpdated={onMutate}
/>
)}
{showReinstate && (
<ReinstateInviteModal
invite={invite}
onClose={() => setShowReinstate(false)}
onReinstated={onMutate}
/>
)}
{showRedemptions && (
<RedemptionsModal invite={invite} onClose={() => setShowRedemptions(false)} />
)}
<ConfirmDialog
isOpen={confirmRevoke}
onClose={() => setConfirmRevoke(false)}
onConfirm={performRevoke}
title={`Revoke "${invite.name}"?`}
description={
<>
The link stops working immediately. Anyone who has the URL can no longer use it.
<br />
<br />
If you change your mind later, <strong>Reinstate</strong> issues a fresh link under this entry the original URL stays inactive.
</>
}
confirmLabel="Revoke link"
variant="danger"
loading={actionLoading}
/>
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => 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 <strong>Revoke</strong>{' '}
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<RegistrationDraft | null>(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<InviteLinkSummary[]>([]);
const [invitesLoading, setInvitesLoading] = useState(false);
const [activeCount, setActiveCount] = useState<number>(0);
const [archivedCount, setArchivedCount] = useState<number>(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<string | null>(null);
// Sort / filter state — independent per tab so switching tabs preserves each
// tab's last selection.
const [activeSort, setActiveSort] = useState<ActiveSort>('recent');
const [archivedSort, setArchivedSort] = useState<ArchivedSort>('recent');
const [archivedStatusFilter, setArchivedStatusFilter] = useState<Set<ArchivedStatus>>(
() => new Set<ArchivedStatus>(['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 <div className="text-sm text-txt-tertiary">Loading settings...</div>;
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 (
<>
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
<h2 className="text-lg font-semibold text-txt-primary">Registration</h2>
<div className="text-xs text-txt-tertiary">
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.
</div>
{/* Public registration */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Public Registration</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<label className="flex items-center justify-between cursor-pointer gap-4">
<div className="flex-1">
<div className="text-sm font-medium text-txt-primary">Allow new local accounts</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Anyone can create a local account from the registration page. When off, only invite
links can create new local accounts.
</div>
</div>
<Toggle
enabled={draft.registrationOpen}
onChange={(v) => setDraft({ ...draft, registrationOpen: v })}
/>
</label>
</div>
</div>
{/* Federated registration */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Federated Registration</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<label className="flex items-center justify-between cursor-pointer gap-4">
<div className="flex-1">
<div className="text-sm font-medium text-txt-primary">Allow new federated accounts</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Users from other instances can create a federated account here via their Connections
settings. Existing federated accounts can always log in regardless of this setting.
</div>
</div>
<Toggle
enabled={draft.federatedRegistrationOpen}
onChange={(v) => setDraft({ ...draft, federatedRegistrationOpen: v })}
/>
</label>
</div>
</div>
{/* Invite Links */}
<div className="border-t border-white/[0.06] pt-5">
{/* Row 1: heading + create button */}
<div className="flex items-center justify-between mb-3">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">Invite Links</div>
<button
type="button"
onClick={() => setShowCreate(true)}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Create link
</button>
</div>
{/* Row 2: tab strip (left) + FilterDropdown (right) */}
<div className="flex items-center justify-between mb-3">
<div className="flex bg-white/[0.04] rounded-md p-0.5">
<button
type="button"
onClick={() => handleTabSwitch('active')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
tab === 'active' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Active <span className="text-[10px] text-txt-tertiary ml-0.5">{activeCount}</span>
</button>
<button
type="button"
onClick={() => handleTabSwitch('archived')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
tab === 'archived' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Archived <span className="text-[10px] text-txt-tertiary ml-0.5">{archivedCount}</span>
</button>
</div>
<FilterDropdown
view={tab}
activeSort={activeSort}
onActiveSortChange={setActiveSort}
archivedSort={archivedSort}
onArchivedSortChange={setArchivedSort}
archivedStatusFilter={archivedStatusFilter}
onArchivedStatusToggle={toggleArchivedStatus}
/>
</div>
{invitesLoading ? (
<div className="text-sm text-txt-tertiary">Loading...</div>
) : invites.length === 0 ? (
<div className="text-sm text-txt-tertiary">
{tab === 'active' ? 'No active invite links.' : 'No archived invite links.'}
</div>
) : displayInvites.length === 0 ? (
<div className="text-[11px] text-txt-tertiary py-3 text-center">
No invites match the current filter.
</div>
) : (
<div className="space-y-2">
{displayInvites.map((inv) => (
<InviteRow
key={inv.id}
invite={inv}
expanded={expandedInviteId === inv.id}
onToggleExpand={() =>
setExpandedInviteId(expandedInviteId === inv.id ? null : inv.id)
}
onMutate={() => { fetchInvites(tab); refreshCounts(); }}
/>
))}
</div>
)}
</div>
{/* Status messages */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
)}
{/* Save / Reset bar */}
{hasChanges && (
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleReset}
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</form>
{showCreate && (
<CreateInviteModal
onClose={() => setShowCreate(false)}
onCreated={() => { fetchInvites('active'); refreshCounts(); }}
/>
)}
</>
);
}