'use client' import PageHeading from '../../ui/PageHeading' import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean } type OwnedInvite = { id: number; code: string; label?: string | null; description?: string | null recipient_email?: string | null; max_uses?: number | null; use_count: number remaining_uses?: number | null; enabled: boolean; expires_at?: string | null is_usable?: boolean; created_at?: string | null } type OwnedInvitesResponse = { invites?: OwnedInvite[] invite_access?: { enabled?: boolean; managed_by_master?: boolean } master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null } type InviteForm = { code: string; label: string; description: string; recipient_email: string enabled: boolean; message: string } type DeliveryMethod = '' | 'manual' | 'email' const defaultInviteForm = (): InviteForm => ({ code: '', label: '', description: '', recipient_email: '', enabled: true, message: '', }) const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) return Number.isNaN(date.valueOf()) ? value : date.toLocaleString() } const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) export default function ProfileInvitesPage() { const router = useRouter() const [profile, setProfile] = useState(null) const [invites, setInvites] = useState([]) const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false) const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false) const [masterInvite, setMasterInvite] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [status, setStatus] = useState(null) const [editingId, setEditingId] = useState(null) const [flowStep, setFlowStep] = useState(1) const [useCustomCode, setUseCustomCode] = useState(false) const [deliveryMethod, setDeliveryMethod] = useState('') const [inviteForm, setInviteForm] = useState(defaultInviteForm()) const [createdInvite, setCreatedInvite] = useState(null) const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' return `${window.location.origin}/signup` }, []) const loadInvites = async () => { const response = await authFetch(`${getApiBase()}/auth/profile/invites`) if (!response.ok) { if (response.status === 401) { clearToken() router.push('/login') return } throw new Error('Could not load your invite workspace.') } const data = (await response.json()) as OwnedInvitesResponse setInvites(Array.isArray(data.invites) ? data.invites : []) setInviteAccessEnabled(Boolean(data.invite_access?.enabled)) setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master)) setMasterInvite(data.master_invite ?? null) } useEffect(() => { if (!getToken()) { router.push('/login') return } const load = async () => { try { const profileResponse = await authFetch(`${getApiBase()}/auth/profile`) if (!profileResponse.ok) { if (profileResponse.status === 401) { clearToken() router.push('/login') return } throw new Error('Could not load your profile.') } const profileData = await profileResponse.json() setProfile(profileData?.user ?? null) await loadInvites() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not load your invite workspace.') } finally { setLoading(false) } } void load() }, [router]) const resetFlow = () => { setEditingId(null) setFlowStep(1) setUseCustomCode(false) setDeliveryMethod('') setInviteForm(defaultInviteForm()) } const editInvite = (invite: OwnedInvite) => { setEditingId(invite.id) setCreatedInvite(null) setFlowStep(4) setUseCustomCode(true) setDeliveryMethod(invite.recipient_email ? 'email' : 'manual') setInviteForm({ code: invite.code, label: invite.label ?? '', description: invite.description ?? '', recipient_email: invite.recipient_email ?? '', enabled: invite.enabled !== false, message: '', }) setError(null) setStatus(null) window.scrollTo({ top: 0, behavior: 'smooth' }) } const saveInvite = async (event: React.FormEvent) => { event.preventDefault() const inviteName = inviteForm.label.trim() const recipientEmail = inviteForm.recipient_email.trim() if (!inviteName) { setError('Give this invite a name so you can recognise it later.') return } if (!deliveryMethod) { setError('Choose how you want to deliver the invite.') return } if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) { setError('Enter a valid recipient email address.') return } setSaving(true) setError(null) setStatus(null) try { const response = await authFetch( editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`, { method: editingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: useCustomCode ? inviteForm.code || null : null, label: inviteName, description: inviteForm.description || null, recipient_email: deliveryMethod === 'email' ? recipientEmail : null, enabled: inviteForm.enabled, send_email: editingId == null && deliveryMethod === 'email', message: inviteForm.message || null, }), } ) if (!response.ok) { if (response.status === 401) { clearToken() router.push('/login') return } throw new Error((await response.text()) || 'Could not save the invite.') } const data = await response.json() const savedInvite = data?.invite as OwnedInvite | undefined setStatus( data?.email?.status === 'ok' ? `Invite created and emailed to ${data.email.recipient_email}.` : data?.email?.status === 'error' ? `Invite created, but the email could not be sent: ${data.email.detail}` : editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.' ) resetFlow() if (editingId == null && savedInvite) setCreatedInvite(savedInvite) await loadInvites() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not save the invite.') } finally { setSaving(false) } } const deleteInvite = async (invite: OwnedInvite) => { if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return setError(null) try { const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' }) if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.') if (editingId === invite.id) resetFlow() setStatus(`Deleted ${invite.label || invite.code}.`) await loadInvites() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not delete the invite.') } } const copyInviteLink = async (invite: OwnedInvite) => { const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` try { await navigator.clipboard.writeText(url) setStatus(`Copied the link for ${invite.label || invite.code}.`) } catch { window.prompt('Copy invite link', url) } } const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '') const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6)) const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : '' if (loading) return
Loading invite workspace…
return (
{error &&
{error}
} {status &&
{status}
} {!canManageInvites ? (

Invites are not enabled for your account

Ask an administrator if you need permission to invite someone.

) : (
Invite flow

{editingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}

Set up the invite one decision at a time.

{editingId != null && }
{createdInvite && editingId == null ? (
Invite ready

{createdInvite.label || 'Your invite'}

{createdInvite.recipient_email ? `The invite was emailed to ${createdInvite.recipient_email}.` : 'Copy this link and send it to the person you are inviting.'}

) : (
    {['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => { const step = index + 1 return
  1. {String(step).padStart(2, '0')}{label}
  2. })}
1 ? 'is-complete' : 'is-active'}`}>
01
Identity

Who is this invite for?

Give it a name that will make sense when you return later.

{useCustomCode && } {flowStep === 1 &&
}
{flowStep >= 2 &&
2 ? 'is-complete' : 'is-active'}`}>
02
Description

Add a welcome note

This optional message is shown on the sign-up page.