'use client' import { useCallback, useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Loader2, Plus, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table' import { useToast } from '@/components/ui/use-toast' import { formatDate } from '@/lib/utils' interface AllowlistEntry { id: string email: string note: string | null created_at: string } interface AccessData { brand: { domain: string; appName: string; signupMode: 'open' | 'invite_only' } role: 'owner' | 'admin' | 'member' entries: AllowlistEntry[] } /** * Invite-only signup management (2026-08-27): the mode switch and the email * allowlist for the byrÄ's brand domain. Company invites bypass the list, so * this list only governs who can create an account cold on the domain. */ export default function SignupAccessManager({ canEdit }: { canEdit: boolean }) { const t = useTranslations('clients') const { toast } = useToast() const [data, setData] = useState(null) const [loadError, setLoadError] = useState<'no_brand' | 'failed' | null>(null) const [savingMode, setSavingMode] = useState(false) const [adding, setAdding] = useState(false) const [removingId, setRemovingId] = useState(null) const [email, setEmail] = useState('') const [note, setNote] = useState('') const load = useCallback(async () => { try { const res = await fetch('/api/clients/signup-access', { cache: 'no-store' }) if (res.status === 404) { setLoadError('no_brand') return } if (!res.ok) { setLoadError('failed') return } const json = (await res.json()) as { data: AccessData } setData(json.data) setLoadError(null) } catch { setLoadError('failed') } }, []) useEffect(() => { void load() }, [load]) const toggleMode = async (inviteOnly: boolean) => { if (!data) return setSavingMode(true) const previous = data.brand.signupMode setData({ ...data, brand: { ...data.brand, signupMode: inviteOnly ? 'invite_only' : 'open' }, }) try { const res = await fetch('/api/clients/signup-access', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signup_mode: inviteOnly ? 'invite_only' : 'open' }), }) if (!res.ok) throw new Error(`PATCH failed: ${res.status}`) } catch { setData((current) => current ? { ...current, brand: { ...current.brand, signupMode: previous } } : current, ) toast({ title: t('access_save_failed'), variant: 'destructive' }) } finally { setSavingMode(false) } } const addEntry = async (e: React.FormEvent) => { e.preventDefault() if (!email.trim() || !data) return setAdding(true) try { const res = await fetch('/api/clients/signup-access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim(), note: note.trim() || undefined, }), }) const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: res.status === 409 ? t('access_duplicate') : t('access_save_failed'), variant: 'destructive', }) return } // Functional update: a concurrent mode toggle or remove must not be // clobbered by the `data` snapshot captured when this request started. setData((current) => current ? { ...current, entries: [json.data as AllowlistEntry, ...current.entries] } : current, ) setEmail('') setNote('') } catch { toast({ title: t('access_save_failed'), variant: 'destructive' }) } finally { setAdding(false) } } const removeEntry = async (id: string) => { if (!data) return setRemovingId(id) try { const res = await fetch('/api/clients/signup-access', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) if (!res.ok) throw new Error(`DELETE failed: ${res.status}`) // Functional update: see addEntry. Avoids clobbering a concurrent // mode toggle or add with a stale snapshot. setData((current) => current ? { ...current, entries: current.entries.filter((entry) => entry.id !== id) } : current, ) } catch { toast({ title: t('access_save_failed'), variant: 'destructive' }) } finally { setRemovingId(null) } } if (loadError === 'no_brand') { return

{t('access_no_brand')}

} if (loadError === 'failed') { return

{t('access_load_failed')}

} if (!data) { return (
) } const inviteOnly = data.brand.signupMode === 'invite_only' return (

{t('access_mode_label', { domain: data.brand.domain })}

{t('access_mode_hint')}

void toggleMode(checked)} aria-label={t('access_mode_label', { domain: data.brand.domain })} />
{!canEdit && (

{t('access_readonly_hint')}

)}

{t('access_list_heading')}

{canEdit && (
setEmail(e.target.value)} placeholder={t('access_add_placeholder')} required disabled={adding} className="w-64" />
setNote(e.target.value)} placeholder={t('access_add_note_placeholder')} disabled={adding} className="w-56" />
)} {data.entries.length === 0 ? (

{t('access_empty')}

) : ( {canEdit && {data.entries.map((entry) => ( {canEdit && ( )} ))}
{t('access_col_email')} {t('access_col_note')} {t('access_col_added')}}
{entry.email} {entry.note || ''} {formatDate(entry.created_at)}
)}
) }