'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 invite_management_enabled?: boolean 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' type InviteTraceScope = 'all' | 'invited' | 'direct' type InviteTraceView = 'list' | 'graph' type InviteTraceRow = { username: string role: string authProvider: string level: number inviterUsername: string | null inviteCode: string | null inviteLabel: string | null createdAt: string | null childCount: number isCycle?: boolean } type InvitePolicy = { master_invite_id?: number | null master_invite?: Invite | null non_admin_users?: number invite_access_enabled_users?: number } 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() } const isInviteTraceRowInvited = (row: InviteTraceRow) => Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim()) 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 [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false) const [invitePolicySaving, setInvitePolicySaving] = 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 [masterInviteSelection, setMasterInviteSelection] = useState('') const [invitePolicy, setInvitePolicy] = useState(null) const [activeTab, setActiveTab] = useState('bulk') const [traceFilter, setTraceFilter] = useState('') const [traceScope, setTraceScope] = useState('all') const [traceView, setTraceView] = useState('graph') 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, policyRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/users`), authFetch(`${baseUrl}/admin/invites/policy`), ]) 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})`) } if (!policyRes.ok) { if (handleAuthResponse(policyRes)) return throw new Error(`Failed to load invite policy (${policyRes.status})`) } const [inviteData, profileData, usersData, policyData] = await Promise.all([ inviteRes.json(), profileRes.json(), usersRes.json(), policyRes.json(), ]) const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : []) setUsers(Array.isArray(usersData?.users) ? usersData.users : []) setInvitePolicy(nextPolicy) setMasterInviteSelection( nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id) ) 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 bulkSetInviteAccess = async (enabled: boolean) => { setBulkInviteAccessBusy(true) setStatus(null) setError(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Bulk invite access update failed') } const data = await response.json() setStatus( `${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.` ) await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not update invite access for all users.') } finally { setBulkInviteAccessBusy(false) } } const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => { const selectedValue = nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || '' setInvitePolicySaving(true) setStatus(null) setError(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/invites/policy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ master_invite_id: selectedValue || null }), }) if (!response.ok) { if (handleAuthResponse(response)) return const text = await response.text() throw new Error(text || 'Invite policy update failed') } setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.') await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not update invite policy.') } finally { setInvitePolicySaving(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 inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).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 masterInvite = invitePolicy?.master_invite ?? null 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]) const scopedInviteTraceRows = useMemo(() => { if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)) if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row)) return inviteTraceRows }, [inviteTraceRows, traceScope]) const traceInvitedCount = useMemo( () => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length, [inviteTraceRows] ) const traceDirectCount = inviteTraceRows.length - traceInvitedCount const inviteTraceGraphColumns = useMemo(() => { if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }> const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level)) const grouped = new Map() scopedInviteTraceRows.forEach((row) => { const level = Math.max(0, row.level - minLevel) const bucket = grouped.get(level) ?? [] bucket.push(row) grouped.set(level, bucket) }) return Array.from(grouped.entries()) .sort((a, b) => a[0] - b[0]) .map(([level, rows]) => ({ level, rows: [...rows].sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base', }) ), })) }, [scopedInviteTraceRows]) const inviteManagementRail = (
Overview

Invite stats

Live counts for invites, 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'}
Self-service invites
{inviteAccessEnabledUsers} {masterInvite ? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}` : 'users enabled • no master template set'}
Expiry rules
{expiringUsers} users with custom expiry
) return (
{error &&
{error}
} {status &&
{status}
}
{activeTab === 'bulk' && (

Blanket controls

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

Self-service invites Enable or disable the “My invites” tab for all non-admin users.
{masterInvite ? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ''}. Self-service invites inherit its limits/status/profile.` : 'No master template set. Self-service invites use each user’s profile/defaults.'}
)} {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.