2046 lines
77 KiB
TypeScript
2046 lines
77 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>
|
|
</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>
|
|
<label className="field-inline">
|
|
<span>Referral uses</span>
|
|
<div className="field-stack">
|
|
<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>
|
|
</div>
|
|
</label>
|
|
<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>
|
|
)
|
|
}
|