'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 } type InviteManagementTab = 'bulk' | 'profiles' | 'invites' 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 [activeTab, setActiveTab] = useState('bulk') 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 const usableInvites = invites.filter((invite) => invite.is_usable !== false).length const disabledInvites = invites.filter((invite) => invite.enabled === false).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length return ( } >
{error &&
{error}
} {status &&
{status}
}

Overview

Quick counts for invite links, profiles, and managed user defaults.

Invites
{invites.length} {usableInvites} usable • {disabledInvites} disabled
Profiles
{profiles.length} {activeProfiles} active profiles
Non-admin users
{nonAdminUsers.length} {profiledUsers} with profile
Expiry rules
{expiringUsers} users with custom expiry
{activeTab === 'bulk' && (

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}

How this page is organized

Use tabs to switch between blanket controls, reusable profiles, and invite links.

Profiles

Create reusable account defaults and apply them to invite links or existing users.

Invites

Create and manage signup links, assign profiles, and copy shareable URLs.

)} {activeTab === 'profiles' && (

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

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