From dcd3daf3902a5d5d7517abd567c82c02142ed52c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:53:39 +0200 Subject: [PATCH] feat(web): RegistrationPanel invite-list with Active/Archived tabs --- .../RegistrationPanel.tsx | 173 +++++++++++++++++- 1 file changed, 168 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 36f999d9..3bada018 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { InviteLinkSummary } from '@backspace/shared'; +import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; @@ -8,6 +10,108 @@ interface RegistrationDraft { 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`; +} + +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' && ( + + )} +
+
+ ); +} + export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); @@ -17,6 +121,25 @@ export function RegistrationPanel() { const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); + const [tab, setTab] = useState<'active' | 'archived'>('active'); + const [invites, setInvites] = useState([]); + const [invitesLoading, setInvitesLoading] = useState(false); + + const fetchInvites = useCallback( + async (which: 'active' | 'archived') => { + setInvitesLoading(true); + try { + const res = await api.invites.list(which); + setInvites(res.invites); + } catch { + addToast('Failed to load invites', 'warning'); + } finally { + setInvitesLoading(false); + } + }, + [addToast], + ); + useEffect(() => { if (instanceSettings) { setDraft({ @@ -27,6 +150,10 @@ export function RegistrationPanel() { } }, [instanceSettings]); + useEffect(() => { + fetchInvites(tab); + }, [tab, fetchInvites]); + if (!draft) return
Loading settings...
; const hasChanges = !!instanceSettings && ( @@ -110,12 +237,48 @@ export function RegistrationPanel() { - {/* Invite Links — populated in subsequent tasks */} + {/* Invite Links */}
-
Invite Links
-
- Invite-link UI will appear here. +
+
Invite Links
+
+ +
+ {(['active', 'archived'] as const).map((t) => ( + + ))} +
+ + {invitesLoading ? ( +
Loading...
+ ) : invites.length === 0 ? ( +
+ {tab === 'active' ? 'No active invite links.' : 'No archived invite links.'} +
+ ) : ( +
+ {invites.map((inv) => ( + fetchInvites(tab)} /> + ))} +
+ )}
{/* Status messages */}