Files
Magent/frontend/app/admin/invites/page.tsx

2085 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
recipient_email?: 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
recipient_email: string
send_email: boolean
message: string
}
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 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 defaultInviteForm = (): InviteForm => ({
code: '',
label: '',
description: '',
profile_id: '',
role: '',
max_uses: '',
enabled: true,
expires_at: '',
recipient_email: '',
send_email: false,
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 isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
const [profiles, setProfiles] = useState<Profile[]>([])
const [users, setUsers] = useState<AdminUserLite[]>([])
const [jellyfinUsersCount, setJellyfinUsersCount] = useState<number | null>(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 [templateSaving, setTemplateSaving] = useState(false)
const [templateResetting, setTemplateResetting] = useState(false)
const [emailSending, setEmailSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
const [profileEditingId, setProfileEditingId] = useState<number | null>(null)
const [profileForm, setProfileForm] = useState<ProfileForm>(defaultProfileForm())
const [bulkProfileId, setBulkProfileId] = useState('')
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
const [templateForm, setTemplateForm] = useState({
subject: '',
body_text: '',
body_html: '',
})
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm())
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('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 : [])
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)
)
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)
}
}
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 ?? '',
recipient_email: invite.recipient_email ?? '',
send_email: false,
message: '',
})
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,
recipient_email: inviteForm.recipient_email || null,
send_email: inviteForm.send_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')
}
resetInviteEditor()
const data = await response.json()
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 ? '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) => {
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 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 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 disabledInvites = invites.filter((invite) => invite.enabled === 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 inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
invites.forEach((invite) => {
const code = String(invite.code || '').trim()
if (code) inviteByCode.set(code.toLowerCase(), invite)
})
const userByName = new Map<string, AdminUserLite>()
users.forEach((user) => {
const username = String(user.username || '').trim()
if (username) userByName.set(username.toLowerCase(), user)
})
const childrenByInviter = new Map<string, AdminUserLite[]>()
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<string>()
const walk = (user: AdminUserLite, level: number, path: Set<string>) => {
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<number, InviteTraceRow[]>()
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 = (
<div className="admin-rail-stack">
<div className="admin-rail-card invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<span className="admin-rail-eyebrow">Overview</span>
<h2>Invite stats</h2>
<p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>
{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Email templates</span>
<div className="invite-admin-summary-row__value">
<strong>{emailTemplates.length}</strong>
<span>{invitesWithRecipient} invites with recipient email</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">SMTP email</span>
<div className="invite-admin-summary-row__value">
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
<span>{emailConfigured?.detail ?? 'Email settings unavailable'}</span>
</div>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
>
Blanket controls
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'profiles'}
className={activeTab === 'profiles' ? 'is-active' : ''}
onClick={() => setActiveTab('profiles')}
>
Profiles
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
Invites
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Trace map
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'emails'}
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
{loading ? 'Loading…' : 'Reload'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetInviteEditor()
setActiveTab('invites')
}}
>
New invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor()
setActiveTab('profiles')
}}
>
New profile
</button>
</div>
</div>
{activeTab === 'bulk' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Blanket controls</h2>
<p className="lede">
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.
</p>
</div>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<div className="user-bulk-group-meta">
<strong>Self-service invites</strong>
<span className="meta">
Enable or disable the My invites tab for all non-admin users.
</span>
</div>
<div className="admin-inline-actions">
<button
type="button"
onClick={() => void bulkSetInviteAccess(true)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? 'Working…' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void bulkSetInviteAccess(false)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? 'Working…' : 'Disable for all users'}
</button>
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Master invite template</span>
<select
value={masterInviteSelection}
onChange={(e) => setMasterInviteSelection(e.target.value)}
disabled={invitePolicySaving}
>
<option value="">None (users use their own defaults)</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
{invite.enabled === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void saveMasterInvitePolicy()} disabled={invitePolicySaving}>
{invitePolicySaving ? 'Saving…' : 'Save master template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setMasterInviteSelection('')
void saveMasterInvitePolicy('')
}}
disabled={invitePolicySaving}
>
{invitePolicySaving ? 'Saving…' : 'Clear master template'}
</button>
</div>
<div className="user-detail-helper">
{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 users profile/defaults.'}
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Profile</span>
<select
value={bulkProfileId}
onChange={(e) => setBulkProfileId(e.target.value)}
disabled={bulkProfileBusy}
>
<option value="">None / clear assignment</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<button type="button" onClick={bulkApplyProfile} disabled={bulkProfileBusy}>
{bulkProfileBusy ? 'Applying…' : 'Apply profile to all users'}
</button>
</div>
<div className="user-bulk-group">
<label>
<span className="user-bulk-label">Expiry days</span>
<input
value={bulkExpiryDays}
onChange={(e) => setBulkExpiryDays(e.target.value)}
inputMode="numeric"
placeholder="e.g. 30"
disabled={bulkExpiryBusy}
/>
</label>
<button type="button" onClick={bulkSetExpiryDays} disabled={bulkExpiryBusy}>
{bulkExpiryBusy ? 'Working…' : 'Set expiry for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={bulkClearExpiry}
disabled={bulkExpiryBusy}
>
{bulkExpiryBusy ? 'Working…' : 'Clear expiry for all users'}
</button>
</div>
</div>
</div>
</div>
)}
{activeTab === 'profiles' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<h2>Profiles</h2>
<p className="lede">Assign these to invites or apply them to all users using the blanket controls above.</p>
{loading ? (
<div className="status-banner">Loading profiles</div>
) : profiles.length === 0 ? (
<div className="status-banner">No profiles created yet.</div>
) : (
<div className="admin-list">
{profiles.map((profile) => (
<div key={profile.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>{profile.name}</strong>
<span className={`small-pill ${profile.is_active ? '' : 'is-muted'}`}>
{profile.is_active ? 'Active' : 'Disabled'}
</span>
<span className="small-pill">{profile.role}</span>
</div>
{profile.description && (
<p className="admin-list-item-text">{profile.description}</p>
)}
<div className="admin-meta-row">
<span>Auto search: {profile.auto_search_enabled ? 'On' : 'Off'}</span>
<span>
Account expiry:{' '}
{typeof profile.account_expires_days === 'number'
? `${profile.account_expires_days} days`
: 'Never'}
</span>
<span>Users: {profile.assigned_users ?? 0}</span>
<span>Invites: {profile.assigned_invites ?? 0}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => editProfile(profile)}>
Edit
</button>
<button type="button" onClick={() => deleteProfile(profile)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>{profileEditingId == null ? 'Create profile' : 'Edit profile'}</h2>
<p className="lede">
Profiles define defaults applied when a user signs up using an invite.
</p>
<form onSubmit={saveProfile} className="admin-form compact-form invite-form-layout profile-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Name and description used to identify the reusable profile.</small>
</div>
<div className="invite-form-row-control">
<label>
<span>Profile name</span>
<input
value={profileForm.name}
onChange={(e) =>
setProfileForm((current) => ({ ...current, name: e.target.value }))
}
placeholder="Standard users"
required
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note to explain when this profile should be used.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={profileForm.description}
onChange={(e) =>
setProfileForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Default invite settings for normal users"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Defaults</span>
<small>Base role and optional account expiry applied from this profile.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Role</span>
<select
value={profileForm.role}
onChange={(e) =>
setProfileForm((current) => ({
...current,
role: e.target.value as 'user' | 'admin',
}))
}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<label>
<span>Account expiry (days)</span>
<input
value={profileForm.account_expires_days}
onChange={(e) =>
setProfileForm((current) => ({
...current,
account_expires_days: e.target.value,
}))
}
inputMode="numeric"
placeholder="Blank = no expiry"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Default auto-download behavior and whether the profile is active.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={profileForm.auto_search_enabled}
onChange={(e) =>
setProfileForm((current) => ({
...current,
auto_search_enabled: e.target.checked,
}))
}
/>
Allow auto search/download by default
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={profileForm.is_active}
onChange={(e) =>
setProfileForm((current) => ({ ...current, is_active: e.target.checked }))
}
/>
Profile is active
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={profileSaving}>
{profileSaving ? 'Saving…' : profileEditingId == null ? 'Create profile' : 'Save profile'}
</button>
{profileEditingId != null && (
<button type="button" className="ghost-button" onClick={resetProfileEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
</div>
</div>
)}
{activeTab === 'invites' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<h2>Invite links</h2>
<p className="lede">Copy and share invite links. Profiles can be applied per invite.</p>
{loading ? (
<div className="status-banner">Loading invites</div>
) : invites.length === 0 ? (
<div className="status-banner">No invites created yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h2>
<p className="lede">
Link an invite to a profile to apply account defaults at sign-up.
</p>
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Code and label used to identify the invite link.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(e) =>
setInviteForm((current) => ({ ...current, code: e.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(e) =>
setInviteForm((current) => ({ ...current, label: e.target.value }))
}
placeholder="Staff invite batch"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(e) =>
setInviteForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Defaults</span>
<small>Choose a profile and optional role override for sign-up.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Profile</span>
<select
value={inviteForm.profile_id}
onChange={(e) =>
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<label>
<span>Role override</span>
<select
value={inviteForm.role}
onChange={(e) =>
setInviteForm((current) => ({
...current,
role: e.target.value as '' | 'user' | 'admin',
}))
}
>
<option value="">Use profile/default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time for the invite.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(e) =>
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(e) =>
setInviteForm((current) => ({ ...current, message: e.target.value }))
}
placeholder="Optional message appended to the invite email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
}
/>
Send You have been invited email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable the invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(e) =>
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
</div>
</div>
)}
{activeTab === 'emails' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Email templates</h2>
<p className="lede">
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
</p>
</div>
</div>
{!emailConfigured?.configured && (
<div className="status-banner">
{emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
</div>
)}
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
{emailTemplates.map((template) => (
<button
key={template.key}
type="button"
className={selectedTemplateKey === template.key ? 'is-active' : ''}
onClick={() => selectEmailTemplate(template.key)}
>
{template.label}
</button>
))}
</div>
{selectedTemplate ? (
<div className="invite-email-template-meta">
<h3>{selectedTemplate.label}</h3>
<p className="lede">{selectedTemplate.description}</p>
</div>
) : null}
<form
className="admin-form compact-form invite-form-layout invite-email-template-form"
onSubmit={(event) => {
event.preventDefault()
void saveEmailTemplate()
}}
>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Subject</span>
<small>Rendered with the same placeholder variables as the body.</small>
</div>
<div className="invite-form-row-control">
<input
value={templateForm.subject}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, subject: event.target.value }))
}
placeholder="Email subject"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Plain text body</span>
<small>Used for mail clients that prefer text only.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={12}
value={templateForm.body_text}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
}
placeholder="Plain text email body"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>HTML body</span>
<small>Optional rich HTML version. Basic HTML is supported.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={14}
value={templateForm.body_html}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
}
placeholder="<h1>Hello</h1><p>HTML email body</p>"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Placeholders</span>
<small>Use these anywhere in the subject or body.</small>
</div>
<div className="invite-form-row-control invite-email-placeholder-list">
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
<code key={placeholder}>{`{{${placeholder}}}`}</code>
))}
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={templateSaving}>
{templateSaving ? 'Saving…' : 'Save template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void resetEmailTemplate()}
disabled={templateResetting}
>
{templateResetting ? 'Resetting…' : 'Reset to default'}
</button>
</div>
</form>
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>Send email</h2>
<p className="lede">
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
</p>
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Template</span>
<small>Select which lifecycle email to send.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Template</span>
<select
value={emailSendForm.template_key}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
template_key: event.target.value as InviteEmailTemplateKey,
}))
}
>
{emailTemplates.map((template) => (
<option key={template.key} value={template.key}>
{template.label}
</option>
))}
</select>
</label>
<label>
<span>Recipient email</span>
<input
type="email"
value={emailSendForm.recipient_email}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Optional if invite/user already has one"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Context</span>
<small>Link the email to an invite or username to fill placeholders automatically.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Invite</span>
<select
value={emailSendForm.invite_id}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
invite_id: event.target.value,
}))
}
>
<option value="">None</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
</option>
))}
</select>
</label>
<label>
<span>Username</span>
<input
value={emailSendForm.username}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
username: event.target.value,
}))
}
placeholder="Optional user lookup"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Reason / note</span>
<small>Used by warning and banned templates, and appended to other emails.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Reason</span>
<input
value={emailSendForm.reason}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
}
placeholder="Optional reason"
/>
</label>
<label>
<span>Message</span>
<textarea
rows={6}
value={emailSendForm.message}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="Optional message"
/>
</label>
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
{emailSending ? 'Sending…' : 'Send email'}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === 'trace' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Invite trace map</h2>
<p className="lede">
Visual lineage of who invited who, including the invite code used for each sign-up.
</p>
</div>
</div>
<div className="invite-trace-toolbar">
<label className="invite-trace-filter">
<span>Find user / inviter / code</span>
<input
type="search"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder="Search by username, inviter, or invite code"
/>
</label>
<div className="invite-trace-controls">
<label className="invite-trace-scope">
<span>Scope</span>
<select
value={traceScope}
onChange={(e) =>
setTraceScope(e.target.value as InviteTraceScope)
}
>
<option value="all">All users</option>
<option value="invited">Invited only</option>
<option value="direct">Direct / root only</option>
</select>
</label>
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
<button
type="button"
className={traceView === 'graph' ? 'is-active' : ''}
onClick={() => setTraceView('graph')}
>
Graph
</button>
<button
type="button"
className={traceView === 'list' ? 'is-active' : ''}
onClick={() => setTraceView('list')}
>
List
</button>
</div>
</div>
<div className="invite-trace-summary">
<span>{scopedInviteTraceRows.length} rows shown</span>
<span>{traceInvitedCount} invited</span>
<span>{traceDirectCount} direct/root</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : scopedInviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : traceView === 'graph' ? (
<div className="invite-trace-graph">
{inviteTraceGraphColumns.map((column) => (
<section key={`trace-level-${column.level}`} className="invite-trace-column">
<header className="invite-trace-column-header">
<span>Level {column.level}</span>
<strong>{column.rows.length}</strong>
</header>
<div className="invite-trace-column-body">
{column.rows.map((row) => (
<article
key={`${row.username}-${column.level}-${row.inviteCode || 'direct'}`}
className="invite-trace-node"
>
<div className="invite-trace-node-main">
<div className="invite-trace-node-title">
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<p className={`invite-trace-node-arrow ${isInviteTraceRowInvited(row) ? '' : 'is-root'}`}>
{row.inviterUsername
? `\u2190 Invited by ${row.inviterUsername}`
: row.inviteCode
? `\u2190 Invited via code ${row.inviteCode}`
: 'Direct/root account'}
</p>
</div>
<div className="invite-trace-node-meta">
<span className="invite-trace-node-meta-item">
<span className="label">Invite code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
) : (
<div className="invite-trace-map">
{scopedInviteTraceRows.map((row) => (
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" />
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<div className="invite-trace-row-meta">
<span className="invite-trace-meta-item">
<span className="label">Invited by</span>
<strong>{row.inviterUsername || 'Root/direct'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Via code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</section>
</AdminShell>
)
}