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' && ( - - )} -
-
- ); -} - 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 ( +
+ +
+ {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; @@ -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 */} -
- -
- {EXPIRY_PRESETS.map((p) => ( - - ))} -
- {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()} + > +
{ + e.preventDefault(); + handleSave(); + }} + className="p-6 space-y-5" + > +

Edit "{invite.name}"

+ + {/* Name */} +
+ + setName(e.target.value)} + maxLength={64} + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} + { + 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); + + // 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()} + > +
{ + e.preventDefault(); + handleReinstate(); + }} + className="p-6 space-y-5" + > +

Reinstate "{invite.name}"

+ + {isRevoked ? ( +

+ This will generate a new link.{' '} + The previously revoked URL stays inactive — anyone who had the old link will + not be able to use it. +

+ ) : ( +

+ The same link will start working again.{' '} + Anyone who saved the URL will be able to use it. +

+ )} + + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} + { + 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); + + // 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' ? ( + <> + + + + + ) : ( + + )} + + {showKebab && ( +
+ + +
+ )} +
+
+ + {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);