From af67c888c63602c7ad89126ee66817976212a998 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Fri, 23 Jan 2026 23:05:09 +1300 Subject: [PATCH] Fix invite referral uses hint layout --- frontend/app/admin/SettingsPage.tsx | 782 +++++++++++++++++++++++++++- frontend/app/globals.css | 143 ++++- 2 files changed, 923 insertions(+), 2 deletions(-) diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 2db337d..f075e11 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -29,9 +29,46 @@ const SECTION_LABELS: Record = { 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']) +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 = { jellyseerr: 'Connect the request system where users submit content.', @@ -44,6 +81,12 @@ const SECTION_DESCRIPTIONS: Record = { 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 = { @@ -55,6 +98,12 @@ const SETTINGS_SECTION_MAP: Record = { prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', requests: 'requests', + invites: 'invites', + password: 'password', + captcha: 'captcha', + smtp: 'smtp', + notifications: 'notify', + expiry: 'expiry', cache: null, logs: 'log', maintenance: null, @@ -78,6 +127,59 @@ const labelFromKey = (key: string) => .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 @@ -106,6 +208,31 @@ export default function SettingsPage({ section }: SettingsPageProps) { const [artworkPrefetch, setArtworkPrefetch] = useState(null) const [maintenanceStatus, setMaintenanceStatus] = useState(null) const [maintenanceBusy, setMaintenanceBusy] = useState(false) + const [invites, setInvites] = useState([]) + const [inviteProfiles, setInviteProfiles] = useState([]) + const [inviteStatus, setInviteStatus] = useState(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(['discord']) + const [announcementStatus, setAnnouncementStatus] = useState(null) const loadSettings = async () => { const baseUrl = getApiBase() @@ -198,6 +325,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { if (section === 'artwork') { await loadArtworkPrefetchStatus() } + if (section === 'invites') { + await loadInviteProfiles() + await loadInvites() + } } catch (err) { console.error(err) setStatus('Could not load admin settings.') @@ -251,6 +382,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { 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 @@ -289,6 +422,59 @@ export default function SettingsPage({ section }: SettingsPageProps) { 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 = ( @@ -312,6 +498,62 @@ export default function SettingsPage({ section }: SettingsPageProps) { 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 + ) => ( + + ) + const submit = async (event: React.FormEvent) => { event.preventDefault() setStatus(null) @@ -422,6 +664,174 @@ export default function SettingsPage({ section }: SettingsPageProps) { } } + 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 { @@ -1085,6 +1495,83 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } + if (setting.key === 'captcha_provider') { + return ( + + ) + } + if (setting.key === 'expiry_default_action') { + return ( + + ) + } + if (NUMBER_SETTINGS.has(setting.key)) { + return ( + + ) + } return (