From bde4d829ef78917ff4109242fec519b007ab81cd Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Wed, 29 Apr 2026 01:21:14 +0200
Subject: [PATCH] feat(web): RegistrationPanel Edit/Revoke/Reinstate/Delete +
Redemptions
---
.../RegistrationPanel.tsx | 1095 +++++++++++++++--
1 file changed, 968 insertions(+), 127 deletions(-)
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx
index 5a2306c3..b8c48f19 100644
--- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx
+++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx
@@ -1,10 +1,11 @@
import { createPortal } from 'react-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
-import type { InviteLinkSummary } from '@backspace/shared';
+import type { InviteLinkSummary, InviteRedemption } 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';
interface RegistrationDraft {
registrationOpen: boolean;
@@ -41,80 +42,11 @@ function formatExpiry(invite: InviteLinkSummary): string {
return `Expires in ${hours}h`;
}
-interface InviteRowProps {
- invite: InviteLinkSummary;
- onMutate: () => void;
-}
-
-function InviteRow({ invite }: InviteRowProps) {
- const addToast = useUIStore((s) => s.addToast);
-
- const usageLabel =
- invite.maxUses === null
- ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited`
- : `${invite.usedCount} / ${invite.maxUses} uses`;
-
- 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 statusPillClass =
- invite.status === 'expired'
- ? 'bg-rose-500/20 text-rose-400'
- : invite.status === 'exhausted'
- ? 'bg-amber-500/20 text-amber-400'
- : 'bg-white/10 text-txt-tertiary';
-
- return (
-
-
-
-
- {invite.name}
-
-
- · {usageLabel}
-
- {invite.status !== 'active' && (
-
- {invite.status}
-
- )}
-
-
- {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '}
- {formatRelative(invite.createdAt)}
-
-
-
- {invite.status === 'active' && (
-
- Copy link
-
- )}
-
-
- );
-}
-
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 },
@@ -137,6 +69,109 @@ function toDatetimeLocalValue(ms: number): string {
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 (
+
+
Expires
+
+ {showKeep && (
+ 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
+
+ )}
+ {EXPIRY_PRESETS.map((p) => (
+ 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}
+
+ ))}
+
+ {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;
@@ -185,31 +220,19 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
maxUsesNum = parsed;
}
- // Resolve expiresAt from the preset selection. Custom requires a non-empty,
- // strictly-future datetime; everything else is derived from the preset's ms offset.
- let expiresAt: number | null;
- if (expiryId === 'never') {
- expiresAt = null;
- } else if (expiryId === 'custom') {
- if (customDateTime === '') {
- addToast('Pick a future date & time', 'warning');
- return;
- }
- const ts = new Date(customDateTime).getTime();
- if (!Number.isFinite(ts) || ts <= Date.now()) {
- addToast('Pick a future date & time', 'warning');
- return;
- }
- expiresAt = ts;
- } else {
- const preset = EXPIRY_PRESETS.find((p) => p.id === expiryId);
- if (!preset || preset.ms === null) {
- // Unreachable: only 'never'/'custom' have null ms, and both are handled above.
- addToast('Invalid expiry selection', 'warning');
- return;
- }
- expiresAt = Date.now() + preset.ms;
+ // 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 {
@@ -308,36 +331,19 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{/* Expiry */}
-
-
Expires
-
- {EXPIRY_PRESETS.map((p) => (
- setExpiryId(p.id)}
- disabled={submitting}
- className={`px-2.5 py-1 text-xs rounded transition-colors disabled:opacity-50 ${
- expiryId === p.id
- ? 'bg-accent-primary text-white'
- : 'bg-surface-input text-txt-tertiary hover:text-txt-secondary'
- }`}
- >
- {p.label}
-
- ))}
-
- {expiryId === 'custom' && (
-
setCustomDateTime(e.target.value)}
- min={toDatetimeLocalValue(Date.now() + 60_000)}
- disabled={submitting}
- className="input-standard w-full px-3 py-2 text-sm mt-2"
- />
- )}
-
+ {
+ // 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 */}
@@ -364,6 +370,841 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
);
}
+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();
+ }, []);
+
+ // Escape closes the modal (capture phase so it fires before the parent settings modal handler)
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !submitting) {
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handleKey, true);
+ return () => document.removeEventListener('keydown', handleKey, true);
+ }, [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(
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
,
+ 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);
+
+ // Escape closes (capture phase to avoid bubbling into parent settings modal)
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !submitting) {
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handleKey, true);
+ return () => document.removeEventListener('keydown', handleKey, true);
+ }, [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);
+ }
+ };
+
+ return createPortal(
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
,
+ 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);
+
+ // Escape closes (capture phase to avoid bubbling into parent settings modal)
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handleKey, true);
+ return () => document.removeEventListener('keydown', handleKey, true);
+ }, [onClose]);
+
+ 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(
+
+
+
+
e.stopPropagation()}
+ >
+
+
+ Redemptions for "{invite.name}"
+
+
+ ×
+
+
+
+
+ {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;
+ onMutate: () => void;
+}
+
+/**
+ * One row in the invite list. Owns its own modal/popover/confirm-dialog state so the
+ * parent panel only deals with the list-level fetch + tab state.
+ *
+ * Action surface depends on `invite.status`:
+ * - active → Copy link · Edit · Revoke · ⋯ (View redemptions, Delete)
+ * - non-active → Reinstate · ⋯ (View redemptions, Delete)
+ *
+ * The kebab popover dismisses on outside click and Escape; clicks inside its items
+ * already call `setShowKebab(false)` before triggering the action.
+ */
+function InviteRow({ invite, onMutate }: InviteRowProps) {
+ const addToast = useUIStore((s) => s.addToast);
+ const [showEdit, setShowEdit] = useState(false);
+ const [showReinstate, setShowReinstate] = useState(false);
+ const [showRedemptions, setShowRedemptions] = useState(false);
+ const [showKebab, setShowKebab] = useState(false);
+ const [confirmRevoke, setConfirmRevoke] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [actionLoading, setActionLoading] = useState(false);
+ const kebabContainerRef = useRef(null);
+
+ // Dismiss kebab popover on outside click + Escape. Mirrors the pattern used in
+ // TransferOwnershipModal — listen on document, check containment via ref.
+ useEffect(() => {
+ if (!showKebab) return;
+ const handleMouseDown = (e: MouseEvent) => {
+ if (
+ kebabContainerRef.current &&
+ !kebabContainerRef.current.contains(e.target as Node)
+ ) {
+ setShowKebab(false);
+ }
+ };
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ // Stop propagation so the parent settings modal doesn't ALSO close.
+ e.stopPropagation();
+ setShowKebab(false);
+ }
+ };
+ document.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('keydown', handleKey, true);
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('keydown', handleKey, true);
+ };
+ }, [showKebab]);
+
+ const usageLabel =
+ invite.maxUses === null
+ ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited`
+ : `${invite.usedCount} / ${invite.maxUses} uses`;
+
+ 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 statusPillClass =
+ invite.status === 'expired'
+ ? 'bg-rose-500/20 text-rose-400'
+ : invite.status === 'exhausted'
+ ? 'bg-amber-500/20 text-amber-400'
+ : 'bg-white/10 text-txt-tertiary';
+
+ return (
+ <>
+
+
+
+
+ {invite.name}
+
+
+ · {usageLabel}
+
+ {invite.status !== 'active' && (
+
+ {invite.status}
+
+ )}
+
+
+ {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '}
+ {formatRelative(invite.createdAt)}
+
+
+
+
+ {invite.status === 'active' ? (
+ <>
+
+ Copy link
+
+
setShowEdit(true)}
+ className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors"
+ >
+ Edit
+
+
setConfirmRevoke(true)}
+ className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors"
+ >
+ Revoke
+
+ >
+ ) : (
+
setShowReinstate(true)}
+ className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors"
+ >
+ Reinstate
+
+ )}
+
setShowKebab((v) => !v)}
+ className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors"
+ >
+ ⋯
+
+ {showKebab && (
+
+ {
+ setShowKebab(false);
+ setShowRedemptions(true);
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs text-txt-secondary hover:text-txt-primary hover:bg-white/[0.04] transition-colors"
+ >
+ View redemptions
+
+ {
+ setShowKebab(false);
+ setConfirmDelete(true);
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs text-accent-rose hover:bg-accent-rose/10 transition-colors"
+ >
+ Delete permanently
+
+
+ )}
+
+
+
+ {showEdit && (
+ setShowEdit(false)}
+ onUpdated={onMutate}
+ />
+ )}
+ {showReinstate && (
+ setShowReinstate(false)}
+ onReinstated={onMutate}
+ />
+ )}
+ {showRedemptions && (
+ setShowRedemptions(false)} />
+ )}
+
+ setConfirmRevoke(false)}
+ onConfirm={performRevoke}
+ title={`Revoke "${invite.name}"?`}
+ description={
+ <>
+ The link will stop working immediately. Anyone who has the URL will not be able
+ to use it. You can reinstate the invite later — that will generate a new link
+ with a different URL.
+ >
+ }
+ 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);