feat(web): RegistrationPanel invite-list with Active/Archived tabs
This commit is contained in:
@@ -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 { useSettingsStore } from '../../../stores/settingsStore';
|
||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
@@ -8,6 +10,108 @@ interface RegistrationDraft {
|
|||||||
federatedRegistrationOpen: 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function RegistrationPanel() {
|
||||||
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
||||||
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
||||||
@@ -17,6 +121,25 @@ export function RegistrationPanel() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (instanceSettings) {
|
if (instanceSettings) {
|
||||||
setDraft({
|
setDraft({
|
||||||
@@ -27,6 +150,10 @@ export function RegistrationPanel() {
|
|||||||
}
|
}
|
||||||
}, [instanceSettings]);
|
}, [instanceSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvites(tab);
|
||||||
|
}, [tab, fetchInvites]);
|
||||||
|
|
||||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||||
|
|
||||||
const hasChanges = !!instanceSettings && (
|
const hasChanges = !!instanceSettings && (
|
||||||
@@ -110,12 +237,48 @@ export function RegistrationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite Links — populated in subsequent tasks */}
|
{/* Invite Links */}
|
||||||
<div className="border-t border-white/[0.06] pt-5">
|
<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="flex items-center justify-between mb-3">
|
||||||
<div className="rounded-lg bg-white/[0.02] p-3.5 text-sm text-txt-tertiary">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">Invite Links</div>
|
||||||
Invite-link UI will appear here.
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Status messages */}
|
||||||
|
|||||||
Reference in New Issue
Block a user