'use client' import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import AdminShell from '../../ui/AdminShell' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' type AdminUserLite = { id: number username: string role: string profile_id?: number | null expires_at?: string | null } type Profile = { id: number name: string description?: string | null role: 'user' | 'admin' auto_search_enabled: boolean account_expires_days?: number | null is_active: boolean assigned_users?: number assigned_invites?: number } type Invite = { id: number code: string label?: string | null description?: string | null profile_id?: number | null profile?: { id: number; name: string } | null role?: 'user' | 'admin' | null max_uses?: number | null use_count: number remaining_uses?: number | null enabled: boolean expires_at?: string | null is_expired?: boolean is_usable?: boolean created_at?: string | null } type InviteForm = { code: string label: string description: string profile_id: string role: '' | 'user' | 'admin' max_uses: string enabled: boolean expires_at: string } type ProfileForm = { name: string description: string role: 'user' | 'admin' auto_search_enabled: boolean account_expires_days: string is_active: boolean } const defaultInviteForm = (): InviteForm => ({ code: '', label: '', description: '', profile_id: '', role: '', max_uses: '', enabled: true, expires_at: '', }) const defaultProfileForm = (): ProfileForm => ({ name: '', description: '', role: 'user', auto_search_enabled: true, account_expires_days: '', is_active: true, }) const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) if (Number.isNaN(date.valueOf())) return value return date.toLocaleString() } export default function AdminInviteManagementPage() { const router = useRouter() const [invites, setInvites] = useState([]) const [profiles, setProfiles] = useState([]) const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [inviteSaving, setInviteSaving] = useState(false) const [profileSaving, setProfileSaving] = useState(false) const [bulkProfileBusy, setBulkProfileBusy] = useState(false) const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false) const [error, setError] = useState(null) const [status, setStatus] = useState(null) const [inviteEditingId, setInviteEditingId] = useState(null) const [inviteForm, setInviteForm] = useState(defaultInviteForm()) const [profileEditingId, setProfileEditingId] = useState(null) const [profileForm, setProfileForm] = useState(defaultProfileForm()) const [bulkProfileId, setBulkProfileId] = useState('') const [bulkExpiryDays, setBulkExpiryDays] = useState('') const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' return `${window.location.origin}/signup` }, []) const handleAuthResponse = (response: Response) => { if (response.status === 401) { clearToken() router.push('/login') return true } if (response.status === 403) { router.push('/') return true } return false } const loadData = async () => { if (!getToken()) { router.push('/login') return } setLoading(true) setError(null) try { const baseUrl = getApiBase() const [inviteRes, profileRes, usersRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/users`), ]) if (!inviteRes.ok) { if (handleAuthResponse(inviteRes)) return throw new Error(`Failed to load invites (${inviteRes.status})`) } if (!profileRes.ok) { if (handleAuthResponse(profileRes)) return throw new Error(`Failed to load profiles (${profileRes.status})`) } if (!usersRes.ok) { if (handleAuthResponse(usersRes)) return throw new Error(`Failed to load users (${usersRes.status})`) } const [inviteData, profileData, usersData] = await Promise.all([ inviteRes.json(), profileRes.json(), usersRes.json(), ]) setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : []) setUsers(Array.isArray(usersData?.users) ? usersData.users : []) } catch (err) { console.error(err) setError('Could not load invite management data.') } finally { setLoading(false) } } useEffect(() => { void loadData() }, []) const resetInviteEditor = () => { setInviteEditingId(null) setInviteForm(defaultInviteForm()) } const editInvite = (invite: Invite) => { setInviteEditingId(invite.id) setInviteForm({ code: invite.code ?? '', label: invite.label ?? '', description: invite.description ?? '', profile_id: typeof invite.profile_id === 'number' && invite.profile_id > 0 ? String(invite.profile_id) : '', role: (invite.role ?? '') as '' | 'user' | 'admin', max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', enabled: invite.enabled !== false, expires_at: invite.expires_at ?? '', }) setStatus(null) setError(null) } const saveInvite = async (event: React.FormEvent) => { event.preventDefault() setInviteSaving(true) setError(null) setStatus(null) try { const baseUrl = getApiBase() const payload = { code: inviteForm.code || null, label: inviteForm.label || null, description: inviteForm.description || null, profile_id: inviteForm.profile_id || null, role: inviteForm.role || null, max_uses: inviteForm.max_uses || null, enabled: inviteForm.enabled, expires_at: inviteForm.expires_at || null, } const url = inviteEditingId == null ? `${baseUrl}/admin/invites` : `${baseUrl}/admin/invites/${inviteEditingId}` const response = await authFetch(url, { method: inviteEditingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Save failed') } setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') resetInviteEditor() await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not save invite.') } finally { setInviteSaving(false) } } const deleteInvite = async (invite: Invite) => { if (!window.confirm(`Delete invite "${invite.code}"?`)) return setError(null) setStatus(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, { method: 'DELETE', }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Delete failed') } if (inviteEditingId === invite.id) resetInviteEditor() setStatus(`Deleted invite ${invite.code}.`) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not delete invite.') } } const copyInviteLink = async (invite: Invite) => { const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url) setStatus(`Copied invite link for ${invite.code}.`) } else { window.prompt('Copy invite link', url) } } catch (err) { console.error(err) window.prompt('Copy invite link', url) } } const resetProfileEditor = () => { setProfileEditingId(null) setProfileForm(defaultProfileForm()) } const editProfile = (profile: Profile) => { setProfileEditingId(profile.id) setProfileForm({ name: profile.name ?? '', description: profile.description ?? '', role: profile.role ?? 'user', auto_search_enabled: Boolean(profile.auto_search_enabled), account_expires_days: typeof profile.account_expires_days === 'number' ? String(profile.account_expires_days) : '', is_active: profile.is_active !== false, }) setStatus(null) setError(null) } const saveProfile = async (event: React.FormEvent) => { event.preventDefault() setProfileSaving(true) setError(null) setStatus(null) try { const baseUrl = getApiBase() const payload = { name: profileForm.name, description: profileForm.description || null, role: profileForm.role, auto_search_enabled: profileForm.auto_search_enabled, account_expires_days: profileForm.account_expires_days || null, is_active: profileForm.is_active, } const url = profileEditingId == null ? `${baseUrl}/admin/profiles` : `${baseUrl}/admin/profiles/${profileEditingId}` const response = await authFetch(url, { method: profileEditingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Save failed') } setStatus(profileEditingId == null ? 'Profile created.' : 'Profile updated.') resetProfileEditor() await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not save profile.') } finally { setProfileSaving(false) } } const deleteProfile = async (profile: Profile) => { if (!window.confirm(`Delete profile "${profile.name}"?`)) return setError(null) setStatus(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, { method: 'DELETE', }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Delete failed') } if (profileEditingId === profile.id) resetProfileEditor() if (bulkProfileId === String(profile.id)) setBulkProfileId('') setStatus(`Deleted profile "${profile.name}".`) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not delete profile.') } } const bulkApplyProfile = async () => { setBulkProfileBusy(true) setStatus(null) setError(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: bulkProfileId || null, scope: 'non-admin-users', }), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Bulk profile update failed') } const data = await response.json() setStatus( bulkProfileId ? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.` : `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.` ) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not apply profile to all users.') } finally { setBulkProfileBusy(false) } } const bulkSetExpiryDays = async () => { if (!bulkExpiryDays.trim()) { setError('Enter expiry days before applying bulk expiry.') return } setBulkExpiryBusy(true) setStatus(null) setError(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ days: bulkExpiryDays, scope: 'non-admin-users' }), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Bulk expiry update failed') } const data = await response.json() setStatus(`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not set expiry for all users.') } finally { setBulkExpiryBusy(false) } } const bulkClearExpiry = async () => { setBulkExpiryBusy(true) setStatus(null) setError(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clear: true, scope: 'non-admin-users' }), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Bulk expiry clear failed') } const data = await response.json() setStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not clear expiry for all users.') } finally { setBulkExpiryBusy(false) } } const nonAdminUsers = users.filter((user) => user.role !== 'admin') const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length return ( } >
{error &&
{error}
} {status &&
{status}
}

Blanket controls

Apply invite profile defaults or expiry to all non-admin users. Individual users can still be edited from their user page.

Non-admin users: {nonAdminUsers.length} Profile assigned: {profiledUsers} Custom expiry set: {expiringUsers}

{profileEditingId == null ? 'Create profile' : 'Edit profile'}

Profiles define defaults applied when a user signs up using an invite.