feat(web): RegistrationPanel invite-list with Active/Archived tabs

This commit is contained in:
Jannis Braun
2026-04-29 00:53:39 +02:00
parent 7dd6bd6263
commit dcd3daf390
@@ -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 (
<div className="bg-white/[0.02] rounded-lg p-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-txt-primary truncate" title={invite.name}>
{invite.name}
</span>
<span
className={`text-xs ${usageNearLimit ? 'text-accent-amber' : 'text-txt-tertiary'}`}
>
· {usageLabel}
</span>
{invite.status !== 'active' && (
<span
className={`text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded ${statusPillClass}`}
>
{invite.status}
</span>
)}
</div>
<div className="text-xs text-txt-tertiary mt-1">
{formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '}
{formatRelative(invite.createdAt)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{invite.status === 'active' && (
<button
type="button"
onClick={handleCopy}
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"
>
Copy link
</button>
)}
</div>
</div>
);
}
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<InviteLinkSummary[]>([]);
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 <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = !!instanceSettings && (
@@ -110,12 +237,48 @@ export function RegistrationPanel() {
</div>
</div>
{/* Invite Links — populated in subsequent tasks */}
{/* Invite Links */}
<div className="border-t border-white/[0.06] pt-5">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Invite Links</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 text-sm text-txt-tertiary">
Invite-link UI will appear here.
<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"
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>
<div className="flex gap-1 mb-3 p-1 bg-surface-input rounded-lg w-fit">
{(['active', 'archived'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`px-3 py-1 text-sm rounded transition-colors ${
tab === t
? 'bg-accent-primary text-white'
: 'text-txt-tertiary hover:text-txt-primary'
}`}
>
{t === 'active' ? 'Active' : 'Archived'}
</button>
))}
</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>
) : (
<div className="space-y-2">
{invites.map((inv) => (
<InviteRow key={inv.id} invite={inv} onMutate={() => fetchInvites(tab)} />
))}
</div>
)}
</div>
{/* Status messages */}