feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s

This commit is contained in:
2026-09-16 14:22:38 +12:00
parent 3465343a69
commit d75f36c691
12 changed files with 239 additions and 20 deletions
+3
View File
@@ -36,6 +36,9 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
.account-notice.site-banner-login.is-error { border-color: var(--site-banner-border-color, #763d44); background: var(--site-banner-background-color, #311e23); }
.account-notice.site-banner-login.is-status { border-color: var(--site-banner-border-color, #365c50); background: var(--site-banner-background-color, #1b2924); }
.account-login-message { color: #ded8ed; border-color: #514a60; background: #26222d; white-space: pre-line; }
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
+29 -1
View File
@@ -26,6 +26,11 @@ const SELECTS: Record<string, Option[]> = {
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
}
const COLOR_DEFAULTS: Record<string, string> = {
site_banner_background_color: '#332814',
site_banner_border_color: '#a27b32',
}
export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props
const id = `setting-${setting.key}`
@@ -36,13 +41,15 @@ export default function SettingField(props: Props) {
const zeroAllowed = setting.key === 'log_file_backup_count'
const minimum = zeroAllowed ? 0 : 1
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
const colorDefault = COLOR_DEFAULTS[setting.key]
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
if (props.boolean) {
return (
<div className="setting-field setting-switch">
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
<input {...aria} type="checkbox" role="switch" aria-checked={value.toLowerCase() === 'true'} checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
</div>
)
}
@@ -52,6 +59,27 @@ export default function SettingField(props: Props) {
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
{props.optionsUnavailable ? (
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
) : colorDefault ? (
<div className="setting-color-control">
<input
id={`${id}-picker`}
type="color"
aria-label={`Choose ${label.toLowerCase()}`}
value={pickerValue}
onChange={(event) => onChange(event.target.value)}
/>
<input
{...aria}
type="text"
value={value}
pattern="#[0-9A-Fa-f]{6}"
placeholder={colorDefault}
autoComplete="off"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
{value ? <button type="button" className="ghost-button" onClick={() => onChange('')}>Use tone default</button> : null}
</div>
) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>}
+19 -3
View File
@@ -70,6 +70,7 @@ const BOOL_SETTINGS = new Set([
])
const TEXTAREA_SETTINGS = new Set([
'site_banner_message',
'site_login_message',
'site_changelog',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
@@ -266,14 +267,21 @@ const SITE_SECTION_GROUPS: Array<{
{
key: 'site-banner',
title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
description: 'Control the sitewide banner message, preset tone, surrounding background and border colours, and visibility.',
keys: [
'site_banner_enabled',
'site_banner_tone',
'site_banner_background_color',
'site_banner_border_color',
'site_banner_message',
],
},
{
key: 'site-login',
title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [
'site_login_message',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
@@ -517,6 +525,7 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
log_level: 'Application log level',
log_file: 'Active log file',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_message: 'Logged-out login page message',
site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link',
@@ -551,6 +560,9 @@ const labelFromKey = (key: string) =>
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone')
.replace('site banner background color', 'Banner background colour')
.replace('site banner border color', 'Banner border colour')
.replace('site login message', 'Logged-out login page message')
.replace('site nav show requests', 'Top navigation: New Requests')
.replace('site changelog', 'Changelog text')
@@ -1051,7 +1063,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.',
site_banner_tone: 'Preset visual tone used whenever custom colours are blank.',
site_banner_background_color: 'Optional six-digit hex colour behind the banner message. Clear it to use the selected tone.',
site_banner_border_color: 'Optional six-digit hex colour around the banner. Clear it to use the selected tone.',
site_login_message: 'Optional message shown only on the logged-out login page. Leave blank to hide it.',
site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
@@ -1097,6 +1112,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
site_login_message: 'Sign-in information, an outage notice, or help for users…',
}
const parseActionError = (err: unknown, fallback: string) => {
+5
View File
@@ -50,6 +50,9 @@
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
.config-subsection .setting-color-control { display: grid; grid-template-columns: 52px minmax(140px, 1fr) auto; align-items: center; gap: 8px; }
.config-subsection .setting-color-control input[type=color] { width: 52px; min-width: 52px; padding: 4px; cursor: pointer; }
.config-subsection .setting-color-control .ghost-button { min-height: 42px; padding: 9px 12px; white-space: nowrap; }
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
.setting-switch > div { display: grid; gap: 6px; }
@@ -92,6 +95,8 @@
@media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; }
.config-subsection .setting-color-control { grid-template-columns: 52px minmax(0, 1fr); }
.config-subsection .setting-color-control .ghost-button { grid-column: 1 / -1; }
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
.config-link-copy { flex-basis: calc(100% - 62px); }
.config-directory-link .config-connection-badge { margin-left: 46px; }
+19 -6
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, type FormEvent } from 'react'
import { useEffect, useState, type CSSProperties, type FormEvent } from 'react'
import { getApiBase, setToken } from '../lib/auth'
import AuthLayout from '../ui/AuthLayout'
@@ -15,7 +15,8 @@ export default function LoginPage() {
const [mode, setMode] = useState<LoginMode>('jellyfin')
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
const [optionsReady, setOptionsReady] = useState(false)
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
const [banner, setBanner] = useState<{ message: string; tone: string; backgroundColor?: string | null; borderColor?: string | null } | null>(null)
const [loginMessage, setLoginMessage] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
@@ -35,8 +36,14 @@ export default function LoginPage() {
showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false,
})
setLoginMessage(typeof data?.login?.message === 'string' ? data.login.message.trim() : '')
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
setBanner({
message: data.banner.message,
tone: data.banner.tone || 'info',
backgroundColor: data.banner.backgroundColor,
borderColor: data.banner.borderColor,
})
}
} catch {
// Keep the normal sign-in methods available during a settings outage.
@@ -80,15 +87,21 @@ export default function LoginPage() {
} finally { setLoading(false) }
}
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
return (
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></>
}>
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
{banner && <p className={`account-notice site-banner-login ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} style={bannerStyle} role="status">{banner.message}</p>}
{loginMessage && <p className="account-notice account-login-message" role="status">{loginMessage}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <fieldset className="login-methods" aria-label="Sign-in account">
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
</div>}
</fieldset>}
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
+6 -2
View File
@@ -281,12 +281,16 @@ a {
.site-banner {
border-radius: var(--ops-radius);
border: 1px solid rgba(255, 208, 130, 0.28);
background: rgba(103, 75, 25, 0.38);
border: 1px solid var(--site-banner-border-color, var(--site-banner-tone-border, rgba(255, 208, 130, 0.28)));
background: var(--site-banner-background-color, var(--site-banner-tone-background, rgba(103, 75, 25, 0.38)));
color: #ffe4b3;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.82rem;
}
.site-banner--info { --site-banner-tone-background: rgba(46, 92, 153, 0.34); --site-banner-tone-border: rgba(126, 184, 255, 0.38); }
.site-banner--warning { --site-banner-tone-background: rgba(103, 75, 25, 0.38); --site-banner-tone-border: rgba(255, 208, 130, 0.28); }
.site-banner--error { --site-banner-tone-background: rgba(104, 36, 43, 0.4); --site-banner-tone-border: rgba(255, 128, 139, 0.38); }
.site-banner--maintenance { --site-banner-tone-background: rgba(98, 57, 27, 0.42); --site-banner-tone-border: rgba(255, 163, 92, 0.38); }
.card,
.admin-card,
+8 -2
View File
@@ -1,12 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, type CSSProperties } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = {
enabled: boolean
message: string
tone?: string
backgroundColor?: string | null
borderColor?: string | null
}
type SiteInfo = {
@@ -52,10 +54,14 @@ export default function SiteStatus() {
const banner = info?.banner
const tone = banner?.tone || 'info'
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
<div className={`site-banner site-banner--${tone}`} style={bannerStyle}>{banner.message}</div>
) : null}
</>
)