"use client"; import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth"; import AdminShell from "../../ui/AdminShell"; 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; code_available?: boolean; 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; recipient_email?: string | null; is_expired?: boolean; is_usable?: boolean; operational_state?: "ready" | "disabled" | "expired" | "exhausted" | "profile_unavailable"; state_label?: string; attention_reason?: string | null; created_at?: string | null; created_by?: string | null; }; type InviteSummary = { total: number; ready: number; attention: number; used_signups: number; with_recipient: number; }; type InviteForm = { code: string; label: string; description: string; profile_id: string; role: "" | "user" | "admin"; max_uses: string; enabled: boolean; expires_at: string; recipient_email: string; message: string; }; type InviteDeliveryMethod = "" | "manual" | "email"; type ProfileForm = { name: string; description: string; role: "user" | "admin"; auto_search_enabled: boolean; account_expires_days: string; is_active: boolean; }; type InviteEmailTemplateKey = "invited" | "welcome" | "warning" | "banned"; type InviteManagementTab = "bulk" | "profiles" | "invites" | "trace" | "emails"; type InviteView = "all" | "ready" | "attention" | "used"; type InviteTraceScope = "all" | "invited" | "direct"; type InviteTraceView = "list" | "graph"; type InviteEmailTemplate = { key: InviteEmailTemplateKey; label: string; description: string; placeholders: string[]; subject: string; body_text: string; body_html: string; }; type InviteEmailSendForm = { template_key: InviteEmailTemplateKey; recipient_email: string; invite_id: string; username: string; message: string; reason: string; }; 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 futureInviteExpiry = (days: number) => { const expires = new Date(); expires.setDate(expires.getDate() + days); return expires.toISOString(); }; const defaultInviteForm = (profileId = "", safeDefaults = false): InviteForm => ({ code: "", label: "", description: "", profile_id: profileId, role: "", max_uses: safeDefaults ? "1" : "", enabled: true, expires_at: safeDefaults ? futureInviteExpiry(7) : "", recipient_email: "", message: "", }); const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({ template_key: "invited", recipient_email: "", invite_id: "", username: "", message: "", reason: "", }); 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 isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); const isInviteTraceRowInvited = (row: InviteTraceRow) => Boolean(String(row.inviterUsername || "").trim() || String(row.inviteCode || "").trim()); const isInviteOperationallyReady = (invite: Invite) => invite.operational_state ? invite.operational_state === "ready" : invite.is_usable !== false; 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 [sendingInviteId, setSendingInviteId] = useState(null); 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 [templateSaving, setTemplateSaving] = useState(false); const [templateResetting, setTemplateResetting] = useState(false); const [emailSending, setEmailSending] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(null); const [inviteEditingId, setInviteEditingId] = useState(null); const [inviteForm, setInviteForm] = useState(defaultInviteForm()); const [inviteFlowStep, setInviteFlowStep] = useState(1); const [useCustomInviteCode, setUseCustomInviteCode] = useState(false); const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState(""); const [inviteSummary, setInviteSummary] = useState(null); const [inviteView, setInviteView] = useState("all"); 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("invites"); const [emailTemplates, setEmailTemplates] = useState([]); const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null); const [selectedTemplateKey, setSelectedTemplateKey] = useState("invited"); const [templateForm, setTemplateForm] = useState({ subject: "", body_text: "", body_html: "", }); const [emailSendForm, setEmailSendForm] = useState(defaultInviteEmailSendForm()); 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 loadTemplateEditor = (templateKey: InviteEmailTemplateKey, templates: InviteEmailTemplate[]) => { const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null; if (!template) { setTemplateForm({ subject: "", body_text: "", body_html: "" }); return; } setSelectedTemplateKey(template.key); setTemplateForm({ subject: template.subject ?? "", body_text: template.body_text ?? "", body_html: template.body_html ?? "", }); }; 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, emailTemplateRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/users`), authFetch(`${baseUrl}/admin/invites/policy`), authFetch(`${baseUrl}/admin/invites/email/templates`), ]); 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})`); } if (!emailTemplateRes.ok) { if (handleAuthResponse(emailTemplateRes)) return; throw new Error(`Failed to load email templates (${emailTemplateRes.status})`); } const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([ inviteRes.json(), profileRes.json(), usersRes.json(), policyRes.json(), emailTemplateRes.json(), ]); const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null; setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []); setInviteSummary((inviteData?.summary ?? null) as InviteSummary | null); const nextProfiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []; setProfiles(nextProfiles); setInviteForm((current) => { const isPristine = !current.code && !current.label && !current.description && !current.profile_id && !current.recipient_email; if (!isPristine) return current; const defaultProfile = nextProfiles.find((profile: Profile) => profile.is_active !== false); return defaultInviteForm(defaultProfile ? String(defaultProfile.id) : "", true); }); setUsers(Array.isArray(usersData?.users) ? usersData.users : []); setInvitePolicy(nextPolicy); setMasterInviteSelection(nextPolicy?.master_invite_id == null ? "" : String(nextPolicy.master_invite_id)); const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : []; setEmailTemplates(nextTemplates); setEmailConfigured(emailTemplateData?.email ?? null); loadTemplateEditor(selectedTemplateKey, nextTemplates); 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); } }; // biome-ignore lint/correctness/useExhaustiveDependencies: Initial page bootstrap deliberately runs once per mount. useEffect(() => { void loadData(); }, []); const resetInviteEditor = () => { setInviteEditingId(null); const defaultProfile = profiles.find((profile) => profile.is_active !== false); setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : "", true)); setInviteFlowStep(1); setUseCustomInviteCode(false); setInviteDeliveryMethod(""); }; 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 ?? "", recipient_email: invite.recipient_email ?? "", message: "", }); setInviteFlowStep(4); setUseCustomInviteCode(true); setInviteDeliveryMethod(invite.recipient_email ? "email" : "manual"); setStatus(null); setError(null); }; const saveInvite = async (event: React.FormEvent) => { event.preventDefault(); const inviteName = inviteForm.label.trim(); if (!inviteName) { setError("Give this invite a name so you can recognise it later."); setStatus(null); return; } const recipientEmail = inviteForm.recipient_email.trim(); if (!inviteDeliveryMethod) { setError("Choose whether to copy the invite link yourself or send it by email."); setStatus(null); return; } if (inviteDeliveryMethod === "email" && !isValidEmail(recipientEmail)) { setError("Recipient email must be valid."); setStatus(null); return; } setInviteSaving(true); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const payload = { code: useCustomInviteCode ? inviteForm.code || null : null, label: inviteName, 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, recipient_email: inviteDeliveryMethod === "email" ? recipientEmail : null, send_email: inviteEditingId == null && inviteDeliveryMethod === "email", message: inviteForm.message || 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"); } const data = await response.json(); resetInviteEditor(); if (data?.email?.status === "ok") { setStatus( `${inviteEditingId == null ? "Invite created" : "Invite updated"} and email sent to ${data.email.recipient_email}.`, ); } else if (data?.email?.status === "error") { setStatus( `${inviteEditingId == null ? "Invite created" : "Invite updated"}, but email failed: ${data.email.detail}`, ); } else { setStatus( inviteEditingId == null ? inviteDeliveryMethod === "manual" ? "Invite created. Copy the link below when you are ready to share it." : "Invite created." : "Invite updated.", ); } 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) => { try { let usableInvite = invite; if (!invite.code_available) { const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, { method: "POST", }); if (!response.ok) { if (handleAuthResponse(response)) return; throw new Error((await response.text()) || "Could not generate a replacement link."); } const data = await response.json(); usableInvite = data.invite as Invite; setInvites((current) => current.map((item) => (item.id === invite.id ? usableInvite : item))); } const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`; if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url); setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`); } else { window.prompt("Copy invite link", url); } } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not generate or copy the invite link."); } }; const prepareInviteEmail = (invite: Invite) => { setEmailSendForm({ template_key: "invited", recipient_email: invite.recipient_email ?? "", invite_id: String(invite.id), username: "", message: "", reason: "", }); setActiveTab("emails"); setStatus( invite.recipient_email ? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.` : `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`, ); setError(null); }; const sendSavedInvite = async (invite: Invite) => { if (!invite.recipient_email) { editInvite(invite); setError("Add a recipient email before sending this invite."); return; } if (!emailConfigured?.configured) { setActiveTab("emails"); setError(emailConfigured?.detail ?? "Configure SMTP before sending invite emails."); return; } setSendingInviteId(invite.id); setError(null); setStatus(null); try { const response = await authFetch(`${getApiBase()}/admin/invites/email/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_key: "invited", invite_id: invite.id, recipient_email: invite.recipient_email, }), }); if (!response.ok) { if (handleAuthResponse(response)) return; const payload = await response.json().catch(() => null); throw new Error(payload?.detail || `Email delivery failed (${response.status})`); } setStatus(`Invite ${invite.code} was sent to ${invite.recipient_email}.`); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not send the invite email."); } finally { setSendingInviteId(null); } }; const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => { setSelectedTemplateKey(templateKey); loadTemplateEditor(templateKey, emailTemplates); }; const saveEmailTemplate = async () => { setTemplateSaving(true); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(templateForm), }); if (!response.ok) { if (handleAuthResponse(response)) return; const text = await response.text(); throw new Error(text || "Template save failed"); } setStatus(`Saved ${selectedTemplateKey} email template.`); await loadData(); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not save email template."); } finally { setTemplateSaving(false); } }; const resetEmailTemplate = async () => { if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return; setTemplateResetting(true); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, { method: "DELETE", }); if (!response.ok) { if (handleAuthResponse(response)) return; const text = await response.text(); throw new Error(text || "Template reset failed"); } setStatus(`Reset ${selectedTemplateKey} template to default.`); await loadData(); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not reset email template."); } finally { setTemplateResetting(false); } }; const sendEmailTemplate = async (event: React.FormEvent) => { event.preventDefault(); setEmailSending(true); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/admin/invites/email/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_key: emailSendForm.template_key, recipient_email: emailSendForm.recipient_email || null, invite_id: emailSendForm.invite_id || null, username: emailSendForm.username || null, message: emailSendForm.message || null, reason: emailSendForm.reason || null, }), }); if (!response.ok) { if (handleAuthResponse(response)) return; const text = await response.text(); throw new Error(text || "Email send failed"); } const data = await response.json(); setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? "recipient"}.`); if (emailSendForm.template_key === "invited") { await loadData(); } } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not send email."); } finally { setEmailSending(false); } }; 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 invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || "").trim())).length; const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length; const masterInvite = invitePolicy?.master_invite ?? null; const selectedTemplate = emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null; const inviteCodeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, ""); const inviteIdentityReady = Boolean( inviteForm.label.trim() && (!useCustomInviteCode || inviteCodeCharacters.length >= 6), ); const inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length; const filteredInvites = useMemo(() => { if (inviteView === "ready") return invites.filter(isInviteOperationallyReady); if (inviteView === "attention") return invites.filter((invite) => !isInviteOperationallyReady(invite)); if (inviteView === "used") return invites.filter((invite) => invite.use_count > 0); return invites; }, [inviteView, invites]); const traceUserIds = useMemo( () => new Map(users.map((user) => [user.username.trim().toLowerCase(), user.id])), [users], ); const traceAccountLink = (username: string, className?: string) => { const id = traceUserIds.get(username.trim().toLowerCase()); return id != null ? ( {username} ) : ( {username} ); }; 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} {inviteSummary?.ready ?? usableInvites} ready • {inviteAttentionCount} need attention
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
Email templates
{emailTemplates.length} {invitesWithRecipient} invites with recipient email
SMTP email
{emailConfigured?.configured ? "Ready" : "Needs setup"} {emailConfigured?.detail ?? "Email settings unavailable"}
Automatic setup

Readiness checks

Magent validates each link and applies safer defaults to new invitations.

); 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.