Files
Magent/frontend/app/admin/SettingsPage.tsx
2026-01-23 23:07:13 +13:00

2044 lines
76 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
key: string
value: string | null
isSet: boolean
source: string
sensitive: boolean
}
type ServiceOptions = {
rootFolders: { id: number; path: string; label: string }[]
qualityProfiles: { id: number; name: string; label: string }[]
}
const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork',
cache: 'Cache',
sonarr: 'Sonarr',
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request syncing',
invites: 'Invites',
password: 'Password rules',
captcha: 'Captcha',
smtp: 'Email (SMTP)',
notify: 'Notifications',
expiry: 'Account expiry',
}
const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'invites_enabled',
'invites_require_captcha',
'signup_allow_referrals',
'password_require_upper',
'password_require_lower',
'password_require_number',
'password_require_symbol',
'password_reset_enabled',
'smtp_tls',
'smtp_starttls',
'notify_email_enabled',
'notify_discord_enabled',
'notify_telegram_enabled',
'notify_matrix_enabled',
'notify_pushover_enabled',
'notify_pushbullet_enabled',
'notify_gotify_enabled',
'notify_ntfy_enabled',
'jellyseerr_sync_users',
])
const NUMBER_SETTINGS = new Set([
'invite_default_profile_id',
'referral_default_uses',
'password_min_length',
'smtp_port',
'expiry_default_days',
'expiry_warning_days',
'expiry_check_interval_minutes',
'jellyseerr_sync_interval_minutes',
])
const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Configure how posters and artwork are loaded.',
cache: 'Manage saved request data and offline artwork.',
sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.',
invites: 'Invite-only sign-ups and default rules.',
password: 'Set global password rules and local reset settings.',
captcha: 'Choose and configure captcha providers.',
smtp: 'Email delivery settings for password resets and notices.',
notify: 'Where system messages should be sent.',
expiry: 'Handle account expiry and automated actions.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: 'artwork',
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
invites: 'invites',
password: 'password',
captcha: 'captcha',
smtp: 'smtp',
notifications: 'notify',
expiry: 'expiry',
cache: null,
logs: 'log',
maintenance: null,
}
const labelFromKey = (key: string) =>
key
.replaceAll('_', ' ')
.replace('base url', 'URL')
.replace('api key', 'API key')
.replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent')
.replace('requests sync ttl minutes', 'Refresh saved requests if older than (minutes)')
.replace('requests poll interval seconds', 'Background refresh check (seconds)')
.replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)')
.replace('requests full sync time', 'Full refresh time (24h)')
.replace('requests cleanup time', 'Clean up old history time (24h)')
.replace('requests cleanup days', 'Remove history older than (days)')
.replace('requests data source', 'Where requests are loaded from')
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
.replace('invites enabled', 'Allow invite sign-ups')
.replace('invites require captcha', 'Require captcha for invite sign-ups')
.replace('invite default profile id', 'Default invite profile')
.replace('signup allow referrals', 'Allow users to create referral invites')
.replace('referral default uses', 'Default referral invite uses')
.replace('password min length', 'Minimum password length')
.replace('password require upper', 'Require uppercase letters')
.replace('password require lower', 'Require lowercase letters')
.replace('password require number', 'Require numbers')
.replace('password require symbol', 'Require symbols')
.replace('password reset enabled', 'Allow password reset emails')
.replace('captcha provider', 'Captcha provider')
.replace('hcaptcha site key', 'hCaptcha site key')
.replace('hcaptcha secret key', 'hCaptcha secret key')
.replace('recaptcha site key', 'reCAPTCHA site key')
.replace('recaptcha secret key', 'reCAPTCHA secret key')
.replace('turnstile site key', 'Turnstile site key')
.replace('turnstile secret key', 'Turnstile secret key')
.replace('smtp host', 'SMTP host')
.replace('smtp port', 'SMTP port')
.replace('smtp user', 'SMTP username')
.replace('smtp password', 'SMTP password')
.replace('smtp from', 'SMTP from address')
.replace('smtp tls', 'Use TLS (SMTPS)')
.replace('smtp starttls', 'Use STARTTLS')
.replace('notify email enabled', 'Send emails')
.replace('notify discord enabled', 'Send Discord alerts')
.replace('notify telegram enabled', 'Send Telegram alerts')
.replace('notify matrix enabled', 'Send Matrix alerts')
.replace('notify pushover enabled', 'Send Pushover alerts')
.replace('notify pushbullet enabled', 'Send Pushbullet alerts')
.replace('notify gotify enabled', 'Send Gotify alerts')
.replace('notify ntfy enabled', 'Send ntfy alerts')
.replace('telegram bot token', 'Telegram bot token')
.replace('telegram chat id', 'Telegram chat ID')
.replace('matrix homeserver', 'Matrix homeserver')
.replace('matrix user', 'Matrix username')
.replace('matrix password', 'Matrix password')
.replace('matrix access token', 'Matrix access token')
.replace('matrix room id', 'Matrix room ID')
.replace('pushover token', 'Pushover app token')
.replace('pushover user key', 'Pushover user key')
.replace('pushbullet token', 'Pushbullet access token')
.replace('gotify url', 'Gotify URL')
.replace('gotify token', 'Gotify token')
.replace('ntfy url', 'ntfy URL')
.replace('ntfy topic', 'ntfy topic')
.replace('expiry default days', 'Default account expiry (days)')
.replace('expiry default action', 'Expiry action')
.replace('expiry warning days', 'Warn users this many days before expiry')
.replace('expiry check interval minutes', 'Expiry check interval (minutes)')
.replace('jellyseerr sync users', 'Sync Jellyseerr users into Magent')
.replace('jellyseerr sync interval minutes', 'Jellyseerr sync interval (minutes)')
type SettingsPageProps = {
section: string
}
export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter()
const [settings, setSettings] = useState<AdminSetting[]>([])
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [status, setStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [sonarrOptions, setSonarrOptions] = useState<ServiceOptions | null>(null)
const [radarrOptions, setRadarrOptions] = useState<ServiceOptions | null>(null)
const [sonarrError, setSonarrError] = useState<string | null>(null)
const [radarrError, setRadarrError] = useState<string | null>(null)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null)
const [requestsSyncStatus, setRequestsSyncStatus] = useState<string | null>(null)
const [artworkPrefetchStatus, setArtworkPrefetchStatus] = useState<string | null>(null)
const [logsStatus, setLogsStatus] = useState<string | null>(null)
const [logsLines, setLogsLines] = useState<string[]>([])
const [logsCount, setLogsCount] = useState(200)
const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [invites, setInvites] = useState<any[]>([])
const [inviteProfiles, setInviteProfiles] = useState<any[]>([])
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteForm, setInviteForm] = useState({
profile_id: '',
max_uses: '1',
require_captcha: false,
allow_referrals: false,
})
const [profileForm, setProfileForm] = useState({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
const [inviteExpiry, setInviteExpiry] = useState({ unit: 'days', value: '7' })
const [profileExpiry, setProfileExpiry] = useState({ unit: 'days', value: '' })
const [profileUserExpiry, setProfileUserExpiry] = useState({ unit: 'days', value: '' })
const [announcementSubject, setAnnouncementSubject] = useState('')
const [announcementBody, setAnnouncementBody] = useState('')
const [announcementChannels, setAnnouncementChannels] = useState<string[]>(['discord'])
const [announcementStatus, setAnnouncementStatus] = useState<string | null>(null)
const loadSettings = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Failed to load settings')
}
const data = await response.json()
const fetched = Array.isArray(data?.settings) ? data.settings : []
setSettings(fetched)
const initialValues: Record<string, string> = {}
for (const setting of fetched) {
if (!setting.sensitive && setting.value) {
if (BOOL_SETTINGS.has(setting.key)) {
initialValues[setting.key] = String(setting.value).toLowerCase()
} else {
initialValues[setting.key] = setting.value
}
} else {
initialValues[setting.key] = ''
}
}
setFormValues(initialValues)
setStatus(null)
}
const loadArtworkPrefetchStatus = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
if (!response.ok) {
return
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
} catch (err) {
console.error(err)
}
}
const loadOptions = async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`)
if (!response.ok) {
throw new Error('Options unavailable')
}
const data = await response.json()
if (service === 'sonarr') {
setSonarrOptions({
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
})
setSonarrError(null)
} else {
setRadarrOptions({
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
})
setRadarrError(null)
}
} catch (err) {
console.error(err)
if (service === 'sonarr') {
setSonarrError('Could not load Sonarr options.')
} else {
setRadarrError('Could not load Radarr options.')
}
}
}
useEffect(() => {
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
await loadSettings()
if (section === 'artwork') {
await loadArtworkPrefetchStatus()
}
if (section === 'invites') {
await loadInviteProfiles()
await loadInvites()
}
} catch (err) {
console.error(err)
setStatus('Could not load admin settings.')
} finally {
setLoading(false)
}
}
load()
if (section === 'sonarr') {
void loadOptions('sonarr')
}
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
for (const setting of settings) {
const section = setting.key.split('_')[0] ?? 'other'
if (!groups[section]) groups[section] = []
groups[section].push(setting)
}
return groups
}, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set([
'requests_sync_ttl_minutes',
'requests_data_source',
'artwork_cache_mode',
])
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const settingsSections = isCacheSection
? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }]
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items:
sectionKey === 'requests' || sectionKey === 'artwork'
? (groupedSettings[sectionKey] ?? []).filter(
(setting) => !cacheSettingKeys.has(setting.key)
)
: groupedSettings[sectionKey] ?? [],
}))
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache'
const showInviteExtras = section === 'invites'
const showNotificationExtras = section === 'notifications'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
if (showCacheExtras && sectionGroup.key === 'cache') return true
if (showRequestsExtras && sectionGroup.key === 'requests') return true
return false
}
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.',
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
radarr_base_url: 'Radarr server URL for movies.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds: 'How often the background checker runs.',
requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.',
requests_full_sync_time: 'Daily time to refresh the full request list.',
requests_cleanup_time: 'Daily time to trim old history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
invites_enabled: 'Allow new users to register with invite links.',
invites_require_captcha: 'Require a captcha on invite sign-up.',
invite_default_profile_id: 'Default invite profile applied when creating invites.',
signup_allow_referrals: 'Let users create referral invites for friends/family.',
referral_default_uses: 'Default number of uses for referral invites.',
password_min_length: 'Minimum length required for passwords.',
password_require_upper: 'Require uppercase letters in passwords.',
password_require_lower: 'Require lowercase letters in passwords.',
password_require_number: 'Require numbers in passwords.',
password_require_symbol: 'Require symbols in passwords.',
password_reset_enabled: 'Allow local users to request password reset emails.',
captcha_provider: 'Choose which captcha provider to use for sign-up.',
hcaptcha_site_key: 'Public hCaptcha site key.',
hcaptcha_secret_key: 'Secret hCaptcha key.',
recaptcha_site_key: 'Public reCAPTCHA site key.',
recaptcha_secret_key: 'Secret reCAPTCHA key.',
turnstile_site_key: 'Public Turnstile site key.',
turnstile_secret_key: 'Secret Turnstile key.',
smtp_host: 'SMTP server hostname.',
smtp_port: 'SMTP server port.',
smtp_user: 'SMTP username (optional).',
smtp_password: 'SMTP password (optional).',
smtp_from: 'Default "from" address for system emails.',
smtp_tls: 'Use TLS for SMTP (SMTPS).',
smtp_starttls: 'Use STARTTLS for SMTP.',
notify_email_enabled: 'Send notices by email.',
notify_discord_enabled: 'Send notices to Discord.',
notify_telegram_enabled: 'Send notices to Telegram.',
notify_matrix_enabled: 'Send notices to Matrix.',
notify_pushover_enabled: 'Send notices to Pushover.',
notify_pushbullet_enabled: 'Send notices to Pushbullet.',
notify_gotify_enabled: 'Send notices to Gotify.',
notify_ntfy_enabled: 'Send notices to ntfy.',
telegram_bot_token: 'Telegram bot token for sending notices.',
telegram_chat_id: 'Default Telegram chat ID.',
matrix_homeserver: 'Matrix server base URL.',
matrix_user: 'Matrix bot username.',
matrix_password: 'Matrix bot password.',
matrix_access_token: 'Matrix access token.',
matrix_room_id: 'Matrix room ID for announcements.',
pushover_token: 'Pushover application token.',
pushover_user_key: 'Pushover user key.',
pushbullet_token: 'Pushbullet access token.',
gotify_url: 'Gotify server URL.',
gotify_token: 'Gotify app token.',
ntfy_url: 'ntfy server URL.',
ntfy_topic: 'ntfy topic for notifications.',
expiry_default_days: 'Default number of days before accounts expire.',
expiry_default_action: 'Action to take when an account expires.',
expiry_warning_days: 'How many days before expiry to warn the user.',
expiry_check_interval_minutes: 'How often expiry checks run.',
jellyseerr_sync_users: 'Sync Jellyseerr users into Magent.',
jellyseerr_sync_interval_minutes: 'How often Jellyseerr user sync runs.',
}
const buildSelectOptions = (
currentValue: string,
options: { id: number; label: string; path?: string }[],
includePath: boolean
) => {
const optionValues = new Set(options.map((option) => String(option.id)))
const list = options.map((option) => (
<option key={option.id} value={String(option.id)}>
{includePath && option.path ? option.path : option.label}
</option>
))
if (currentValue && !optionValues.has(currentValue)) {
list.unshift(
<option key="custom" value={currentValue}>
Custom: {currentValue}
</option>
)
}
return list
}
const durationOptions = {
minutes: Array.from({ length: 60 }, (_, i) => String(i + 1)),
hours: Array.from({ length: 24 }, (_, i) => String(i + 1)),
days: Array.from({ length: 365 }, (_, i) => String(i + 1)),
}
const toDays = (choice: { unit: string; value: string }) => {
if (!choice || choice.unit === 'unlimited') return null
const amount = Number(choice.value)
if (!amount || amount <= 0) return null
if (choice.unit === 'minutes') return amount / 1440
if (choice.unit === 'hours') return amount / 24
if (choice.unit === 'months') return amount * 30
return amount
}
const renderDurationControl = (
label: string,
choice: { unit: string; value: string },
setChoice: (next: { unit: string; value: string }) => void
) => (
<label>
{label}
<div className="duration-row">
<input
type="number"
min={1}
placeholder="Amount"
value={choice.value}
onChange={(event) =>
setChoice({
unit: choice.unit,
value: event.target.value,
})
}
disabled={choice.unit === 'unlimited'}
/>
<select
value={choice.unit}
onChange={(event) =>
setChoice({
unit: event.target.value,
value: event.target.value === 'unlimited' ? '' : choice.value,
})
}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="months">Months</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
</label>
)
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
const payload: Record<string, string> = {}
const formData = new FormData(event.currentTarget)
for (const setting of settings) {
const rawValue = formData.get(setting.key)
if (typeof rawValue !== 'string') {
continue
}
const value = rawValue.trim()
if (value === '') {
continue
}
payload[setting.key] = value
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setStatus('Settings saved. New values take effect immediately.')
await loadSettings()
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not save settings.'
setStatus(message)
}
}
const syncJellyfinUsers = async () => {
setJellyfinSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setJellyfinSyncStatus(`Imported ${data?.imported ?? 0} users from Jellyfin.`)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not import Jellyfin users.'
setJellyfinSyncStatus(message)
}
}
const syncRequests = async () => {
setRequestsSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setRequestsSync(data?.sync ?? null)
setRequestsSyncStatus('Sync started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not sync requests.'
setRequestsSyncStatus(message)
}
}
const syncRequestsDelta = async () => {
setRequestsSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Delta sync failed')
}
const data = await response.json()
setRequestsSync(data?.sync ?? null)
setRequestsSyncStatus('Delta sync started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not run delta sync.'
setRequestsSyncStatus(message)
}
}
const loadInviteProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`)
if (!response.ok) {
throw new Error('Profiles unavailable')
}
const data = await response.json()
setInviteProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
} catch (err) {
console.error(err)
setInviteProfiles([])
}
}
const loadInvites = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`)
if (!response.ok) {
throw new Error('Invites unavailable')
}
const data = await response.json()
setInvites(Array.isArray(data?.invites) ? data.invites : [])
} catch (err) {
console.error(err)
setInvites([])
}
}
const createInviteProfile = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...profileForm,
max_uses: profileForm.max_uses ? Number(profileForm.max_uses) : null,
expires_in_days: toDays(profileExpiry),
referral_uses: profileForm.referral_uses ? Number(profileForm.referral_uses) : null,
user_expiry_days: toDays(profileUserExpiry),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile creation failed')
}
setProfileForm({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
setProfileExpiry({ unit: 'days', value: '' })
setProfileUserExpiry({ unit: 'days', value: '' })
setInviteStatus('Invite profile created.')
await loadInviteProfiles()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite profile.')
}
}
const createInviteCode = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: inviteForm.profile_id ? Number(inviteForm.profile_id) : null,
max_uses: inviteForm.max_uses ? Number(inviteForm.max_uses) : null,
expires_in_days: toDays(inviteExpiry),
require_captcha: inviteForm.require_captcha,
allow_referrals: inviteForm.allow_referrals,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite creation failed')
}
setInviteStatus('Invite created.')
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite.')
}
}
const disableInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${encodeURIComponent(code)}/disable`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Disable failed')
}
setInviteStatus(`Invite ${code} disabled.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not disable invite.')
}
}
const deleteInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/invites/${encodeURIComponent(code)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
throw new Error('Delete failed')
}
setInviteStatus(`Invite ${code} deleted.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not delete invite.')
}
}
const toggleAnnouncementChannel = (channel: string) => {
setAnnouncementChannels((current) =>
current.includes(channel) ? current.filter((item) => item !== channel) : [...current, channel]
)
}
const sendAnnouncement = async () => {
setAnnouncementStatus(null)
if (!announcementSubject || !announcementBody) {
setAnnouncementStatus('Enter a subject and a message.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: announcementSubject,
body: announcementBody,
channels: announcementChannels,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Announcement failed')
}
setAnnouncementStatus('Announcement sent.')
setAnnouncementBody('')
setAnnouncementSubject('')
} catch (err) {
console.error(err)
setAnnouncementStatus('Could not send the announcement.')
}
}
const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not cache artwork.'
setArtworkPrefetchStatus(message)
}
}
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
const timer = setInterval(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
if (!response.ok) {
return
}
const data = await response.json()
if (!active) return
setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
}
} catch (err) {
console.error(err)
}
}, 2000)
return () => {
active = false
clearInterval(timer)
}
}, [artworkPrefetch?.status])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
return
}
const timer = setTimeout(() => {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
}, [artworkPrefetch?.status])
useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
const timer = setInterval(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/status`)
if (!response.ok) {
return
}
const data = await response.json()
if (!active) return
setRequestsSync(data?.sync ?? null)
if (data?.sync?.status && data.sync.status !== 'running') {
setRequestsSyncStatus(data.sync.message || 'Sync complete.')
}
} catch (err) {
console.error(err)
}
}, 2000)
return () => {
active = false
clearInterval(timer)
}
}, [requestsSync?.status])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
return
}
const timer = setTimeout(() => {
setRequestsSync(null)
}, 5000)
return () => clearTimeout(timer)
}, [requestsSync?.status])
const loadLogs = async () => {
setLogsStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/logs?lines=${encodeURIComponent(String(logsCount))}`
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Log fetch failed')
}
const data = await response.json()
if (Array.isArray(data?.lines)) {
setLogsLines(data.lines)
} else {
setLogsLines([])
}
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load logs.'
setLogsStatus(message)
}
}
useEffect(() => {
if (!showLogs) {
return
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [logsCount, showLogs])
const loadCache = async () => {
setCacheStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/cache?limit=${encodeURIComponent(String(cacheCount))}`
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Cache fetch failed')
}
const data = await response.json()
if (Array.isArray(data?.rows)) {
setCacheRows(data.rows)
} else {
setCacheRows([])
}
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
}
}
const runRepair = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/repair`, { method: 'POST' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Repair failed')
}
const data = await response.json()
setMaintenanceStatus(`Integrity check: ${data?.integrity ?? 'unknown'}. Vacuum complete.`)
} catch (err) {
console.error(err)
setMaintenanceStatus('Database repair failed.')
}
}
const runCleanup = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/cleanup?days=90`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Cleanup failed')
}
const data = await response.json()
setMaintenanceStatus(
`Cleaned history older than ${data?.days ?? 90} days.`
)
} catch (err) {
console.error(err)
setMaintenanceStatus('Cleanup failed.')
}
}
const runFlushAndResync = async () => {
setMaintenanceStatus(null)
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
return
}
}
try {
const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
if (!flushResponse.ok) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
setMaintenanceStatus('Database flushed. Starting re-sync...')
await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.')
} catch (err) {
console.error(err)
setMaintenanceStatus('Flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
}
const clearLogFile = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/logs/clear`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Clear logs failed')
}
setMaintenanceStatus('Log file cleared.')
setLogsLines([])
} catch (err) {
console.error(err)
setMaintenanceStatus('Clearing logs failed.')
}
}
if (loading) {
return <main className="card">Loading admin settings...</main>
}
return (
<AdminShell
title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
{settingsSections.length > 0 ? (
<form onSubmit={submit} className="admin-form">
{settingsSections
.filter(shouldRenderSection)
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section">
<div className="section-header">
<h2>{sectionGroup.title}</h2>
{sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
</button>
)}
{sectionGroup.key === 'radarr' && (
<button type="button" onClick={() => loadOptions('radarr')}>
Refresh Radarr options
</button>
)}
{sectionGroup.key === 'jellyfin' && (
<button type="button" onClick={syncJellyfinUsers}>
Import Jellyfin users
</button>
)}
{(showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache') ? (
<button type="button" onClick={prefetchArtwork}>
Cache all artwork now
</button>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Full refresh
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (new changes)
</button>
</div>
)}
</div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
<div className="error-banner">{sonarrError}</div>
)}
{sectionGroup.key === 'radarr' && radarrError && (
<div className="error-banner">{radarrError}</div>
)}
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetch && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {artworkPrefetch.status}</span>
<span>
{artworkPrefetch.processed ?? 0}
{artworkPrefetch.total ? ` / ${artworkPrefetch.total}` : ''} cached
</span>
</div>
<div
className={`progress ${artworkPrefetch.total ? '' : 'progress-indeterminate'} ${
artworkPrefetch.status === 'completed' ? 'progress-complete' : ''
}`}
>
<div
className="progress-fill"
style={{
width:
artworkPrefetch.status === 'completed'
? '100%'
: artworkPrefetch.total
? `${Math.min(
100,
Math.round((artworkPrefetch.processed / artworkPrefetch.total) * 100)
)}%`
: '30%',
}}
/>
</div>
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {requestsSync.status}</span>
<span>
{requestsSync.stored ?? 0}
{requestsSync.total ? ` / ${requestsSync.total}` : ''} synced
</span>
</div>
<div
className={`progress ${requestsSync.total ? '' : 'progress-indeterminate'} ${
requestsSync.status === 'completed' ? 'progress-complete' : ''
}`}
>
<div
className="progress-fill"
style={{
width:
requestsSync.status === 'completed'
? '100%'
: requestsSync.total
? `${Math.min(
100,
Math.round((requestsSync.stored / requestsSync.total) * 100)
)}%`
: '30%',
}}
/>
</div>
{requestsSync.message && <div className="meta">{requestsSync.message}</div>}
</div>
)}
<div className="admin-grid">
{sectionGroup.items.map((setting) => {
const value = formValues[setting.key] ?? ''
const helperText = settingDescriptions[setting.key]
const isSonarrProfile = setting.key === 'sonarr_quality_profile_id'
const isSonarrRoot = setting.key === 'sonarr_root_folder'
const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key)
if (isBoolSetting) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'false'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</label>
)
}
if (isSonarrProfile && sonarrOptions) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a quality profile</option>
{buildSelectOptions(value, sonarrOptions.qualityProfiles, false)}
</select>
</label>
)
}
if (isSonarrRoot && sonarrOptions) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a root folder</option>
{buildSelectOptions(value, sonarrOptions.rootFolders, true)}
</select>
</label>
)
}
if (isRadarrProfile && radarrOptions) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a quality profile</option>
{buildSelectOptions(value, radarrOptions.qualityProfiles, false)}
</select>
</label>
)
}
if (isRadarrRoot && radarrOptions) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a root folder</option>
{buildSelectOptions(value, radarrOptions.rootFolders, true)}
</select>
</label>
)
}
if (setting.key === 'log_level') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</label>
)
}
if (setting.key === 'artwork_cache_mode') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'remote'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="remote">Pull from the internet</option>
<option value="cache">Cache locally</option>
</select>
</label>
)
}
if (
setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time'
) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<input
name={setting.key}
type="time"
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
if (
setting.key === 'requests_delta_sync_interval_minutes' ||
setting.key === 'requests_cleanup_days'
) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<input
name={setting.key}
type="number"
min={1}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
if (setting.key === 'requests_data_source') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'prefer_cache'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">Use saved requests first (faster)</option>
</select>
</label>
)
}
if (setting.key === 'captcha_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'none'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="none">No captcha</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Cloudflare Turnstile</option>
</select>
</label>
)
}
if (setting.key === 'expiry_default_action') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'disable'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
)
}
if (NUMBER_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<input
name={setting.key}
type="number"
min={0}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<input
name={setting.key}
type={setting.sensitive ? 'password' : 'text'}
placeholder={
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
})}
</div>
</section>
))}
{status && <div className="status-banner">{status}</div>}
<div className="admin-actions">
<button type="submit">Save changes</button>
</div>
</form>
) : (
<div className="status-banner">
No settings to show here yet. Try the Cache page for artwork and saved-request controls.
</div>
)}
{showLogs && (
<section className="admin-section" id="logs">
<div className="section-header">
<h2>Activity log</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Lines to show</span>
<select
value={logsCount}
onChange={(event) => setLogsCount(Number(event.target.value))}
>
<option value={100}>100</option>
<option value={200}>200</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<button type="button" onClick={loadLogs}>
Refresh log
</button>
</div>
</div>
{logsStatus && <div className="error-banner">{logsStatus}</div>}
<pre className="log-viewer">{logsLines.join('')}</pre>
</section>
)}
{showCacheExtras && (
<section className="admin-section" id="cache">
<div className="section-header">
<h2>Saved requests (cache)</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Rows to show</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache}>
Load saved requests
</button>
</div>
</div>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<div className="cache-table">
<div className="cache-row cache-head">
<span>Request</span>
<span>Title</span>
<span>Type</span>
<span>Status</span>
<span>Last update</span>
</div>
{cacheRows.length === 0 ? (
<div className="meta">No saved requests loaded yet.</div>
) : (
cacheRows.map((row) => (
<div key={row.request_id} className="cache-row">
<span>#{row.request_id}</span>
<span>{row.title || 'Untitled'}</span>
<span>{row.media_type || 'unknown'}</span>
<span>{row.status ?? 'n/a'}</span>
<span>{row.updated_at || row.created_at || 'n/a'}</span>
</div>
))
)}
</div>
</section>
)}
{showMaintenance && (
<section className="admin-section" id="maintenance">
<div className="section-header">
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history.
</div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid">
<button type="button" onClick={runRepair}>
Repair database
</button>
<button type="button" className="ghost-button" onClick={runCleanup}>
Clean history (older than 90 days)
</button>
<button type="button" className="ghost-button" onClick={clearLogFile}>
Clear activity log
</button>
<button
type="button"
className="danger-button"
onClick={runFlushAndResync}
disabled={maintenanceBusy}
>
Flush database + resync
</button>
</div>
</section>
)}
{showRequestsExtras && (
<section className="admin-section" id="schedules">
<div className="section-header">
<h2>Scheduled tasks</h2>
</div>
<div className="status-banner">
Automated jobs keep requests and housekeeping up to date.
</div>
<div className="schedule-grid">
<div className="schedule-card">
<h3>Quick request check</h3>
<p>
Every {formValues.requests_delta_sync_interval_minutes || '5'} minutes, checks for
new or updated requests.
</p>
</div>
<div className="schedule-card">
<h3>Full daily refresh</h3>
<p>
Every day at {formValues.requests_full_sync_time || '00:00'}, refreshes the entire
requests list.
</p>
</div>
<div className="schedule-card">
<h3>History cleanup</h3>
<p>
Every day at {formValues.requests_cleanup_time || '02:00'}, removes history older
than {formValues.requests_cleanup_days || '90'} days.
</p>
</div>
</div>
</section>
)}
{showInviteExtras && (
<section className="admin-section" id="invites">
<div className="section-header">
<h2>Invites</h2>
</div>
<div className="status-banner">
Create profiles for common rules, then issue invites in seconds.
</div>
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="invite-stack">
<div className="summary-card invite-card">
<h3>Create a profile</h3>
<p className="meta">Profiles save default rules for new invites.</p>
<div className="invite-grid">
<label>
Profile name
<input
value={profileForm.name}
onChange={(event) =>
setProfileForm((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label>
Description
<input
value={profileForm.description}
onChange={(event) =>
setProfileForm((current) => ({ ...current, description: event.target.value }))
}
/>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={profileForm.max_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', profileExpiry, setProfileExpiry)}
{renderDurationControl(
'User account expires after',
profileUserExpiry,
setProfileUserExpiry
)}
<label>
Expiry action
<select
value={profileForm.user_expiry_action}
onChange={(event) =>
setProfileForm((current) => ({
...current,
user_expiry_action: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
<label>
Referral uses
<input
type="number"
min={0}
value={profileForm.referral_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, referral_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for 1.</span>
</label>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.require_captcha}
onChange={(event) =>
setProfileForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.allow_referrals}
onChange={(event) =>
setProfileForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Let users share their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteProfile}>
Save profile
</button>
</div>
</div>
<div className="summary-card invite-card">
<h3>Create an invite</h3>
<p className="meta">Pick a profile or customize a one-off invite.</p>
<div className="invite-grid">
<label>
Profile
<select
value={inviteForm.profile_id}
onChange={(event) =>
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
}
>
<option value="">No profile</option>
{inviteProfiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', inviteExpiry, setInviteExpiry)}
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.require_captcha}
onChange={(event) =>
setInviteForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.allow_referrals}
onChange={(event) =>
setInviteForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Lets this person create their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteCode}>
Create invite
</button>
<button type="button" className="ghost-button" onClick={loadInvites}>
Refresh invites
</button>
</div>
</div>
</div>
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
<span>Expires</span>
<span>Status</span>
<span>Actions</span>
</div>
{invites.length === 0 ? (
<div className="meta">No invites yet.</div>
) : (
invites.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? 'Unlimited'}
</span>
<span>{invite.expires_at || 'Never'}</span>
<span>{invite.disabled ? 'Disabled' : 'Active'}</span>
<span className="cache-actions">
<button
type="button"
className="ghost-button"
onClick={() => disableInviteCode(invite.code)}
disabled={invite.disabled}
>
Disable
</button>
<button
type="button"
className="danger-button"
onClick={() => deleteInviteCode(invite.code)}
>
Delete
</button>
</span>
</div>
))
)}
</div>
</section>
)}
{showNotificationExtras && (
<section className="admin-section" id="announcements">
<div className="section-header">
<h2>Send an announcement</h2>
</div>
<div className="status-banner">
Send a message to all users through the channels you select.
</div>
{announcementStatus && <div className="status-banner">{announcementStatus}</div>}
<div className="admin-grid">
<label>
Subject
<input
value={announcementSubject}
onChange={(event) => setAnnouncementSubject(event.target.value)}
/>
</label>
<label>
Message
<textarea
rows={5}
value={announcementBody}
onChange={(event) => setAnnouncementBody(event.target.value)}
/>
</label>
<div className="settings-checkbox-grid">
{[
{ id: 'email', label: 'Email' },
{ id: 'discord', label: 'Discord' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'pushover', label: 'Pushover' },
{ id: 'pushbullet', label: 'Pushbullet' },
{ id: 'gotify', label: 'Gotify' },
{ id: 'ntfy', label: 'ntfy' },
].map((channel) => (
<label key={channel.id} className="toggle">
<input
type="checkbox"
checked={announcementChannels.includes(channel.id)}
onChange={() => toggleAnnouncementChannel(channel.id)}
/>
<span>{channel.label}</span>
</label>
))}
</div>
</div>
<div className="admin-actions">
<button type="button" onClick={sendAnnouncement}>
Send announcement
</button>
</div>
</section>
)}
</AdminShell>
)
}