'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 auth_provider?: string | null profile_id?: number | null expires_at?: string | null created_at?: string | null invited_by_code?: string | null invited_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 created_by?: 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' | 'trace' 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 [jellyfinUsersCount, setJellyfinUsersCount] = useState(null) 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 [traceFilter, setTraceFilter] = 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 : []) try { const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`) if (jellyfinRes.ok) { const jellyfinData = await jellyfinRes.json() setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0) } else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) { if (handleAuthResponse(jellyfinRes)) return } else { setJellyfinUsersCount(null) } } catch (jellyfinErr) { console.warn('Could not load Jellyfin user count for invite overview', jellyfinErr) setJellyfinUsersCount(null) } } 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 const inviteTraceRows = useMemo(() => { const inviteByCode = new Map() invites.forEach((invite) => { const code = String(invite.code || '').trim() if (code) inviteByCode.set(code.toLowerCase(), invite) }) const userByName = new Map() users.forEach((user) => { const username = String(user.username || '').trim() if (username) userByName.set(username.toLowerCase(), user) }) const childrenByInviter = new Map() const inviterMetaByUser = new Map< string, { inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null } >() users.forEach((user) => { const username = String(user.username || '').trim() if (!username) return const inviteCodeRaw = String(user.invited_by_code || '').trim() let inviterUsername: string | null = null let inviteLabel: string | null = null if (inviteCodeRaw) { const invite = inviteByCode.get(inviteCodeRaw.toLowerCase()) inviteLabel = (invite?.label as string | undefined) || null const createdBy = String(invite?.created_by || '').trim() if (createdBy) inviterUsername = createdBy } inviterMetaByUser.set(username.toLowerCase(), { inviterUsername, inviteCode: inviteCodeRaw || null, inviteLabel, }) const key = (inviterUsername || '__root__').toLowerCase() const bucket = childrenByInviter.get(key) ?? [] bucket.push(user) childrenByInviter.set(key, bucket) }) childrenByInviter.forEach((bucket) => bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' })) ) const rows: Array<{ username: string role: string authProvider: string level: number inviterUsername: string | null inviteCode: string | null inviteLabel: string | null createdAt: string | null childCount: number isCycle?: boolean }> = [] const visited = new Set() const walk = (user: AdminUserLite, level: number, path: Set) => { const username = String(user.username || '').trim() const userKey = username.toLowerCase() if (!username) return const meta = inviterMetaByUser.get(userKey) ?? { inviterUsername: null, inviteCode: null, inviteLabel: null, } const childCount = (childrenByInviter.get(userKey) ?? []).length if (path.has(userKey)) { rows.push({ username, role: String(user.role || 'user'), authProvider: String(user.auth_provider || 'local'), level, inviterUsername: meta.inviterUsername, inviteCode: meta.inviteCode, inviteLabel: meta.inviteLabel, createdAt: (user.created_at as string | null) ?? null, childCount, isCycle: true, }) return } rows.push({ username, role: String(user.role || 'user'), authProvider: String(user.auth_provider || 'local'), level, inviterUsername: meta.inviterUsername, inviteCode: meta.inviteCode, inviteLabel: meta.inviteLabel, createdAt: (user.created_at as string | null) ?? null, childCount, }) visited.add(userKey) const nextPath = new Set(path) nextPath.add(userKey) ;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath)) } ;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set())) users.forEach((user) => { const key = String(user.username || '').toLowerCase() if (key && !visited.has(key)) { walk(user, 0, new Set()) } }) const filter = traceFilter.trim().toLowerCase() if (!filter) return rows return rows.filter((row) => [ row.username, row.inviterUsername || '', row.inviteCode || '', row.inviteLabel || '', row.role || '', row.authProvider || '', ] .join(' ') .toLowerCase() .includes(filter) ) }, [invites, traceFilter, users]) 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
Local non-admin accounts
{nonAdminUsers.length} {profiledUsers} with profile
Jellyfin users
{jellyfinUsersCount ?? '—'} {jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
Expiry rules
{expiringUsers} users with custom expiry
{activeTab === 'bulk' && (

Blanket controls

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

Local non-admin users: {nonAdminUsers.length} Jellyfin users: {jellyfinUsersCount ?? '—'} 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' && (

Profiles

Assign these to invites or apply them to all users using the blanket controls above.

{loading ? (
Loading profiles…
) : profiles.length === 0 ? (
No profiles created yet.
) : (
{profiles.map((profile) => (
{profile.name} {profile.is_active ? 'Active' : 'Disabled'} {profile.role}
{profile.description && (

{profile.description}

)}
Auto search: {profile.auto_search_enabled ? 'On' : 'Off'} Account expiry:{' '} {typeof profile.account_expires_days === 'number' ? `${profile.account_expires_days} days` : 'Never'} Users: {profile.assigned_users ?? 0} Invites: {profile.assigned_invites ?? 0}
))}
)}

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

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

Identity Name and description used to identify the reusable profile.
Description Optional note to explain when this profile should be used.