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 ( +