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
+20
View File
@@ -1,9 +1,20 @@
import re
from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def normalize_banner_color(value: object) -> Optional[str]:
color = str(value or "").strip().lower()
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent"
@@ -108,6 +119,15 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_banner_background_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
)
site_banner_border_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
)
site_login_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
)
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
+14 -1
View File
@@ -20,7 +20,7 @@ from ..auth import (
normalize_user_auth_provider,
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..config import normalize_banner_color, settings as env_settings
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
@@ -174,6 +174,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_webhook_url",
}
BANNER_COLOR_SETTING_KEYS = {
"site_banner_background_color",
"site_banner_border_color",
}
SETTING_KEYS: List[str] = [
"jellystat_base_url",
"jellystat_api_key",
@@ -260,6 +265,9 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_banner_background_color",
"site_banner_border_color",
"site_login_message",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
@@ -712,6 +720,11 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in BANNER_COLOR_SETTING_KEYS:
normalized_color = normalize_banner_color(value_to_store)
if not normalized_color:
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
value_to_store = normalized_color
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
+5
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..config import normalize_banner_color
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
@@ -15,6 +16,7 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
login_message = (runtime.site_login_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
@@ -24,8 +26,11 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
},
"login": {
"message": login_message,
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
+47 -1
View File
@@ -2,7 +2,7 @@ import os
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, call, patch
import httpx
from fastapi import HTTPException
@@ -298,6 +298,9 @@ class SiteInfoTests(unittest.TestCase):
site_banner_enabled=False,
site_banner_message="",
site_banner_tone="info",
site_banner_background_color=None,
site_banner_border_color=None,
site_login_message="",
site_login_show_jellyfin_login=True,
site_login_show_local_login=True,
site_login_show_forgot_password=True,
@@ -310,6 +313,49 @@ class SiteInfoTests(unittest.TestCase):
self.assertEqual(info["navigation"], {"showRequests": False})
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
runtime = settings.model_copy(update={
"site_banner_enabled": True,
"site_banner_message": "Planned maintenance",
"site_banner_tone": "warning",
"site_banner_background_color": "#123ABC",
"site_banner_border_color": "red",
"site_login_message": "Use your Grizzlyflix account to sign in.",
})
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
self.assertIsNone(info["banner"]["borderColor"])
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_banner_colours_are_normalized_before_saving(self) -> None:
with patch.object(admin_router, "set_setting") as save:
result = await admin_router.update_settings({
"site_banner_background_color": "#A1B2C3",
"site_banner_border_color": "#010203",
})
self.assertEqual(result, {"status": "ok", "updated": 2})
self.assertEqual(
save.call_args_list,
[
call("site_banner_background_color", "#a1b2c3"),
call("site_banner_border_color", "#010203"),
],
)
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
with self.assertRaises(HTTPException) as raised:
await admin_router.update_settings({
"site_banner_border_color": "red; background: url(example)",
})
self.assertEqual(raised.exception.status_code, 400)
class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None:
+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}
</>
)
+64 -4
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const output = process.env.REVIEW_DIR
const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } }
const site = { login: { message: '', showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning', backgroundColor: null, borderColor: null } }
;(async () => {
const browser = await chromium.launch({ headless: true })
@@ -17,6 +17,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
let provider = 'jellyfin'
let supported = true
let savedEmail = 'member@example.com'
let adminSettings = [
['site_banner_enabled', 'true'],
['site_banner_tone', 'warning'],
['site_banner_background_color', '#24172f'],
['site_banner_border_color', '#d946ef'],
['site_banner_message', 'Maintenance tonight'],
['site_login_message', 'Sign-in help is available from the media team.'],
['site_login_show_jellyfin_login', 'true'],
['site_login_show_local_login', 'true'],
['site_login_show_forgot_password', 'true'],
['site_login_show_signup_link', 'true'],
].map(([key, value]) => ({ key, value, isSet: true, source: 'db', sensitive: false }))
const calls = []
const errors = []
await context.route('**/api/**', async (route) => {
@@ -25,7 +37,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
const method = request.method()
const reply = (json, status = 200) => route.fulfill({ status, json })
if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'user' })
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'admin' })
if (path === '/api/admin/settings') {
if (method === 'PUT') {
const body = request.postDataJSON()
calls.push({ path, method, body: request.postData() })
adminSettings = adminSettings.map((setting) => Object.hasOwn(body, setting.key)
? { ...setting, value: body[setting.key], isSet: Boolean(body[setting.key]) }
: setting)
return reply({ status: 'ok', updated: Object.keys(body).length })
}
return reply({ settings: adminSettings })
}
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
stats: { total: 12, ready: 9, in_progress: 3 },
@@ -86,20 +109,57 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
options.login.showLocalLogin = false
options.login.showForgotPassword = false
options.login.showSignupLink = false
options.login.message = 'Sign-in help is available from the media team.'
options.banner.message = 'Maintenance tonight'
options.banner.backgroundColor = '#24172f'
options.banner.borderColor = '#d946ef'
await openLogin()
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
assert(await page.getByText('Maintenance tonight').isVisible())
assert(await page.getByText('Sign-in help is available from the media team.').isVisible())
const customBannerStyle = await page.getByText('Maintenance tonight').evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
}))
assert.deepEqual(customBannerStyle, { background: 'rgb(36, 23, 47)', border: 'rgb(217, 70, 239)' })
options = structuredClone(site)
options.login.showLocalLogin = false
await openLogin()
loginStatus = 200
await login()
await page.waitForURL(base + '/')
await page.waitForURL(base + '/welcome')
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect and notices')
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect, login message and custom banner colours')
options.banner = { enabled: true, message: 'Custom site banner', tone: 'warning', backgroundColor: '#24172f', borderColor: '#d946ef' }
await page.goto(base + '/admin/site')
const signedInBannerStyle = await page.getByText('Custom site banner', { exact: true }).evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
}))
assert.deepEqual(signedInBannerStyle, { background: 'rgb(36, 23, 47)', border: 'rgb(217, 70, 239)' })
const bannerRegion = page.locator('#config-site-banner')
const backgroundHex = bannerRegion.getByLabel('Banner background colour', { exact: true })
await backgroundHex.waitFor()
await backgroundHex.fill('red')
assert.equal(await backgroundHex.evaluate((element) => element.checkValidity()), false)
await bannerRegion.getByLabel('Choose banner background colour', { exact: true }).fill('#112233')
assert.equal(await backgroundHex.inputValue(), '#112233')
await bannerRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
await bannerRegion.getByText('Settings saved.').waitFor()
const bannerSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
assert.equal(JSON.parse(bannerSave.body).site_banner_background_color, '#112233')
const loginRegion = page.locator('#config-site-login')
await loginRegion.getByLabel('Logged-out login page message', { exact: true }).fill('Welcome. Contact support if you cannot sign in.')
await loginRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
await loginRegion.getByText('Settings saved.').waitFor()
const loginSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
assert.equal(JSON.parse(loginSave.body).site_login_message, 'Welcome. Contact support if you cannot sign in.')
assert(!Object.hasOwn(JSON.parse(loginSave.body), 'site_banner_message'))
console.log('PASS: Site & sign-in colour picker, native hex validation, login message and region-only saves')
await page.goto(base + '/profile')
const email = page.getByLabel('Email address', { exact: true })