From 234ec8e70d78f7a1780f91a9cb1db30106beba44 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:06:48 +0200 Subject: [PATCH] feat(web): RegistrationPanel Create-invite modal --- .../RegistrationPanel.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 829d0284..e544a82e 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { InviteLinkSummary } from '@backspace/shared'; import { api } from '../../../api/client'; @@ -112,6 +113,205 @@ function InviteRow({ invite }: InviteRowProps) { ); } +const EXPIRY_PRESETS: ReadonlyArray<{ label: string; ms: number | null }> = [ + { label: '1 hour', ms: 3_600_000 }, + { label: '24 hours', ms: 86_400_000 }, + { label: '7 days', ms: 7 * 86_400_000 }, + { label: '30 days', ms: 30 * 86_400_000 }, + { label: 'Never', ms: null }, +]; + +interface CreateInviteModalProps { + onClose: () => void; + onCreated: (created: InviteLinkSummary) => void; +} + +function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [name, setName] = useState(''); + const [unlimited, setUnlimited] = useState(true); + const [maxUses, setMaxUses] = useState('1'); + const [expiryPreset, setExpiryPreset] = useState(7 * 86_400_000); + 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 parent handlers) + 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 handleCreate = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64) { + addToast('Name must be 1–64 characters', 'warning'); + return; + } + let maxUsesNum: 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; + } + maxUsesNum = parsed; + } + const expiresAt = expiryPreset === null ? null : Date.now() + expiryPreset; + + setSubmitting(true); + try { + const created = await api.invites.create({ name: trimmed, maxUses: maxUsesNum, expiresAt }); + try { + await navigator.clipboard.writeText(created.url); + addToast('Link created. Copied to clipboard.', 'success', 2000); + } catch { + addToast('Link created. Copy manually from the row.', 'success', 2000); + } + onCreated(created); + onClose(); + } catch (err) { + addToast(`Failed to create invite: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Modal panel — stop propagation so backdrop click doesn't fire inside */} +
e.stopPropagation()} + > +
{ + e.preventDefault(); + handleCreate(); + }} + className="p-6 space-y-5" + > +

Create invite link

+ + {/* Name */} +
+ + setName(e.target.value)} + maxLength={64} + placeholder="e.g. Friends batch 1" + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} +
+ +
+ {EXPIRY_PRESETS.map((p) => ( + + ))} +
+
+ + {/* Actions */} +
+ + +
+
+
+
, + document.body, + ); +} + export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); @@ -120,6 +320,7 @@ export function RegistrationPanel() { const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); + const [showCreate, setShowCreate] = useState(false); const [tab, setTab] = useState<'active' | 'archived'>('active'); const [invites, setInvites] = useState([]); @@ -201,6 +402,7 @@ export function RegistrationPanel() { }; return ( + <>
e.preventDefault()}>

Registration

@@ -254,6 +456,7 @@ export function RegistrationPanel() {
Invite Links
)}
+ + {showCreate && ( + setShowCreate(false)} + onCreated={() => fetchInvites('active')} + /> + )} + ); }