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

1467 lines
57 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
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
created_by?: string | null
}
type InviteForm = {
code: string
label: string
description: string
profile_id: string
role: '' | 'user' | 'admin'
max_uses: string
enabled: boolean
expires_at: string
}
type ProfileForm = {
name: string
description: string
role: 'user' | 'admin'
auto_search_enabled: boolean
account_expires_days: string
is_active: boolean
}
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InvitePolicy = {
master_invite_id?: number | null
master_invite?: Invite | null
non_admin_users?: number
invite_access_enabled_users?: number
}
const defaultInviteForm = (): InviteForm => ({
code: '',
label: '',
description: '',
profile_id: '',
role: '',
max_uses: '',
enabled: true,
expires_at: '',
})
const defaultProfileForm = (): ProfileForm => ({
name: '',
description: '',
role: 'user',
auto_search_enabled: true,
account_expires_days: '',
is_active: true,
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
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 [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 [traceFilter, setTraceFilter] = useState('')
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
const handleAuthResponse = (response: Response) => {
if (response.status === 401) {
clearToken()
router.push('/login')
return true
}
if (response.status === 403) {
router.push('/')
return true
}
return false
}
const loadData = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`),
authFetch(`${baseUrl}/admin/users`),
authFetch(`${baseUrl}/admin/invites/policy`),
])
if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return
throw new Error(`Failed to load invites (${inviteRes.status})`)
}
if (!profileRes.ok) {
if (handleAuthResponse(profileRes)) return
throw new Error(`Failed to load profiles (${profileRes.status})`)
}
if (!usersRes.ok) {
if (handleAuthResponse(usersRes)) return
throw new Error(`Failed to load users (${usersRes.status})`)
}
if (!policyRes.ok) {
if (handleAuthResponse(policyRes)) return
throw new Error(`Failed to load invite policy (${policyRes.status})`)
}
const [inviteData, profileData, usersData, policyData] = await Promise.all([
inviteRes.json(),
profileRes.json(),
usersRes.json(),
policyRes.json(),
])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
setInvitePolicy(nextPolicy)
setMasterInviteSelection(
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
)
try {
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
if (jellyfinRes.ok) {
const jellyfinData = await jellyfinRes.json()
setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0)
} else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) {
if (handleAuthResponse(jellyfinRes)) return
} else {
setJellyfinUsersCount(null)
}
} catch (jellyfinErr) {
console.warn('Could not load Jellyfin user count for invite overview', jellyfinErr)
setJellyfinUsersCount(null)
}
} catch (err) {
console.error(err)
setError('Could not load invite management data.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultInviteForm())
}
const editInvite = (invite: Invite) => {
setInviteEditingId(invite.id)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
profile_id:
typeof invite.profile_id === 'number' && invite.profile_id > 0
? String(invite.profile_id)
: '',
role: (invite.role ?? '') as '' | 'user' | 'admin',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '',
})
setStatus(null)
setError(null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const payload = {
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
profile_id: inviteForm.profile_id || null,
role: inviteForm.role || null,
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
}
const url =
inviteEditingId == null
? `${baseUrl}/admin/invites`
: `${baseUrl}/admin/invites/${inviteEditingId}`
const response = await authFetch(url, {
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Save failed')
}
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
resetInviteEditor()
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: Invite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Delete failed')
}
if (inviteEditingId === invite.id) resetInviteEditor()
setStatus(`Deleted invite ${invite.code}.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: Invite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const resetProfileEditor = () => {
setProfileEditingId(null)
setProfileForm(defaultProfileForm())
}
const editProfile = (profile: Profile) => {
setProfileEditingId(profile.id)
setProfileForm({
name: profile.name ?? '',
description: profile.description ?? '',
role: profile.role ?? 'user',
auto_search_enabled: Boolean(profile.auto_search_enabled),
account_expires_days:
typeof profile.account_expires_days === 'number' ? String(profile.account_expires_days) : '',
is_active: profile.is_active !== false,
})
setStatus(null)
setError(null)
}
const saveProfile = async (event: React.FormEvent) => {
event.preventDefault()
setProfileSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const payload = {
name: profileForm.name,
description: profileForm.description || null,
role: profileForm.role,
auto_search_enabled: profileForm.auto_search_enabled,
account_expires_days: profileForm.account_expires_days || null,
is_active: profileForm.is_active,
}
const url =
profileEditingId == null
? `${baseUrl}/admin/profiles`
: `${baseUrl}/admin/profiles/${profileEditingId}`
const response = await authFetch(url, {
method: profileEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Save failed')
}
setStatus(profileEditingId == null ? 'Profile created.' : 'Profile updated.')
resetProfileEditor()
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save profile.')
} finally {
setProfileSaving(false)
}
}
const deleteProfile = async (profile: Profile) => {
if (!window.confirm(`Delete profile "${profile.name}"?`)) return
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Delete failed')
}
if (profileEditingId === profile.id) resetProfileEditor()
if (bulkProfileId === String(profile.id)) setBulkProfileId('')
setStatus(`Deleted profile "${profile.name}".`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not delete profile.')
}
}
const bulkApplyProfile = async () => {
setBulkProfileBusy(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: bulkProfileId || null,
scope: 'non-admin-users',
}),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Bulk profile update failed')
}
const data = await response.json()
setStatus(
bulkProfileId
? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.`
: `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.`
)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not apply profile to all users.')
} finally {
setBulkProfileBusy(false)
}
}
const bulkSetExpiryDays = async () => {
if (!bulkExpiryDays.trim()) {
setError('Enter expiry days before applying bulk expiry.')
return
}
setBulkExpiryBusy(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: bulkExpiryDays, scope: 'non-admin-users' }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Bulk expiry update failed')
}
const data = await response.json()
setStatus(`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not set expiry for all users.')
} finally {
setBulkExpiryBusy(false)
}
}
const bulkClearExpiry = async () => {
setBulkExpiryBusy(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true, scope: 'non-admin-users' }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Bulk expiry clear failed')
}
const data = await response.json()
setStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not clear expiry for all users.')
} finally {
setBulkExpiryBusy(false)
}
}
const bulkSetInviteAccess = async (enabled: boolean) => {
setBulkInviteAccessBusy(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Bulk invite access update failed')
}
const data = await response.json()
setStatus(
`${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.`
)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update invite access for all users.')
} finally {
setBulkInviteAccessBusy(false)
}
}
const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => {
const selectedValue =
nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || ''
setInvitePolicySaving(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ master_invite_id: selectedValue || null }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Invite policy update failed')
}
setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.')
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update invite policy.')
} finally {
setInvitePolicySaving(false)
}
}
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const masterInvite = invitePolicy?.master_invite ?? null
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<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])
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-panel invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<h2>Overview</h2>
<p className="lede">
Quick counts for invite links, 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>
</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>
</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="admin-split-grid invite-admin-bulk-grid">
<div className="admin-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="admin-meta-row">
<span>Local non-admin users: {nonAdminUsers.length}</span>
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
<span>Profile assigned: {profiledUsers}</span>
<span>Custom expiry set: {expiringUsers}</span>
</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 className="admin-panel">
<div className="user-directory-panel-header">
<div>
<h2>How this page is organized</h2>
<p className="lede">Use tabs to switch between blanket controls, reusable profiles, and invite links.</p>
</div>
</div>
<div className="admin-list">
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Profiles</strong>
</div>
<p className="admin-list-item-text">
Create reusable account defaults and apply them to invite links or existing users.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('profiles')}>
Open
</button>
</div>
</div>
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Invites</strong>
</div>
<p className="admin-list-item-text">
Create and manage signup links, assign profiles, and copy shareable URLs.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('invites')}>
Open
</button>
</div>
</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>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={() => 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>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 === '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-summary">
<span>{inviteTraceRows.length} rows shown</span>
<span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div>
) : (
<div className="invite-trace-map">
{inviteTraceRows.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>
)
}