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 typing import Optional
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG 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): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="") model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent" app_name: str = "Magent"
@@ -108,6 +119,15 @@ class Settings(BaseSettings):
site_banner_tone: str = Field( site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE") 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( site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN") 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, normalize_user_auth_provider,
resolve_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 ..network_security import validate_notification_target_url
from ..db import ( from ..db import (
delete_setting, delete_setting,
@@ -174,6 +174,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_webhook_url", "magent_notify_webhook_url",
} }
BANNER_COLOR_SETTING_KEYS = {
"site_banner_background_color",
"site_banner_border_color",
}
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellystat_base_url", "jellystat_base_url",
"jellystat_api_key", "jellystat_api_key",
@@ -260,6 +265,9 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled", "site_banner_enabled",
"site_banner_message", "site_banner_message",
"site_banner_tone", "site_banner_tone",
"site_banner_background_color",
"site_banner_border_color",
"site_login_message",
"site_login_show_jellyfin_login", "site_login_show_jellyfin_login",
"site_login_show_local_login", "site_login_show_local_login",
"site_login_show_forgot_password", "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() value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}: if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or 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: if key in URL_SETTING_KEYS and value_to_store:
try: try:
value_to_store = _normalize_service_url(value_to_store) 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 ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG from ..build_info import BUILD_NUMBER, CHANGELOG
from ..config import normalize_banner_color
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"]) 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]: def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip() 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() tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES: if tone not in _BANNER_TONES:
tone = "info" 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), "enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message, "message": banner_message,
"tone": tone, "tone": tone,
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
}, },
"login": { "login": {
"message": login_message,
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login), "showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login), "showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password), "showForgotPassword": bool(runtime.site_login_show_forgot_password),
+47 -1
View File
@@ -2,7 +2,7 @@ import os
from types import SimpleNamespace from types import SimpleNamespace
import tempfile import tempfile
import unittest import unittest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, call, patch
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
@@ -298,6 +298,9 @@ class SiteInfoTests(unittest.TestCase):
site_banner_enabled=False, site_banner_enabled=False,
site_banner_message="", site_banner_message="",
site_banner_tone="info", 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_jellyfin_login=True,
site_login_show_local_login=True, site_login_show_local_login=True,
site_login_show_forgot_password=True, site_login_show_forgot_password=True,
@@ -310,6 +313,49 @@ class SiteInfoTests(unittest.TestCase):
self.assertEqual(info["navigation"], {"showRequests": False}) 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): class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None: 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 { 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-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; } .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-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-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); } .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' }], 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) { export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props const { setting, label, value, help, placeholder, onChange } = props
const id = `setting-${setting.key}` const id = `setting-${setting.key}`
@@ -36,13 +41,15 @@ export default function SettingField(props: Props) {
const zeroAllowed = setting.key === 'log_file_backup_count' const zeroAllowed = setting.key === 'log_file_backup_count'
const minimum = zeroAllowed ? 0 : 1 const minimum = zeroAllowed ? 0 : 1
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined 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 } const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
if (props.boolean) { if (props.boolean) {
return ( return (
<div className="setting-field setting-switch"> <div className="setting-field setting-switch">
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div> <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> </div>
) )
} }
@@ -52,6 +59,27 @@ export default function SettingField(props: Props) {
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label> <label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
{props.optionsUnavailable ? ( {props.optionsUnavailable ? (
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select> <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 ? ( ) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}> <select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>} {!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([ const TEXTAREA_SETTINGS = new Set([
'site_banner_message', 'site_banner_message',
'site_login_message',
'site_changelog', 'site_changelog',
'magent_ssl_certificate_pem', 'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem', 'magent_ssl_private_key_pem',
@@ -266,14 +267,21 @@ const SITE_SECTION_GROUPS: Array<{
{ {
key: 'site-banner', key: 'site-banner',
title: 'Site Banner', title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.', description: 'Control the sitewide banner message, preset tone, surrounding background and border colours, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'], keys: [
'site_banner_enabled',
'site_banner_tone',
'site_banner_background_color',
'site_banner_border_color',
'site_banner_message',
],
}, },
{ {
key: 'site-login', key: 'site-login',
title: 'Login Page Behaviour', title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.', description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [ keys: [
'site_login_message',
'site_login_show_jellyfin_login', 'site_login_show_jellyfin_login',
'site_login_show_local_login', 'site_login_show_local_login',
'site_login_show_forgot_password', 'site_login_show_forgot_password',
@@ -517,6 +525,7 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
log_level: 'Application log level', log_level: 'Application log level',
log_file: 'Active log file', log_file: 'Active log file',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in', 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_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password', site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link', 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 enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message') .replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone') .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 nav show requests', 'Top navigation: New Requests')
.replace('site changelog', 'Changelog text') .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_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.', site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.', 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_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_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.', 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', 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', 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', 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) => { 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 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: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-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-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; } .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; } .setting-switch > div { display: grid; gap: 6px; }
@@ -92,6 +95,8 @@
@media (max-width: 680px) { @media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; } .admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; } .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-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
.config-link-copy { flex-basis: calc(100% - 62px); } .config-link-copy { flex-basis: calc(100% - 62px); }
.config-directory-link .config-connection-badge { margin-left: 46px; } .config-directory-link .config-connection-badge { margin-left: 46px; }
+19 -6
View File
@@ -1,6 +1,6 @@
'use client' '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 { getApiBase, setToken } from '../lib/auth'
import AuthLayout from '../ui/AuthLayout' import AuthLayout from '../ui/AuthLayout'
@@ -15,7 +15,8 @@ export default function LoginPage() {
const [mode, setMode] = useState<LoginMode>('jellyfin') const [mode, setMode] = useState<LoginMode>('jellyfin')
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS) const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
const [optionsReady, setOptionsReady] = useState(false) 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 [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const canSignIn = options.showJellyfinLogin || options.showLocalLogin const canSignIn = options.showJellyfinLogin || options.showLocalLogin
@@ -35,8 +36,14 @@ export default function LoginPage() {
showForgotPassword: data?.login?.showForgotPassword !== false, showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== 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') { 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 { } catch {
// Keep the normal sign-in methods available during a settings outage. // Keep the normal sign-in methods available during a settings outage.
@@ -80,15 +87,21 @@ export default function LoginPage() {
} finally { setLoading(false) } } finally { setLoading(false) }
} }
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
return ( return (
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={ <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></> 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>} {banner && <p className={`account-notice site-banner-login ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} style={bannerStyle} role="status">{banner.message}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account"> {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 === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</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> : ( {!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}> <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> <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 { .site-banner {
border-radius: var(--ops-radius); border-radius: var(--ops-radius);
border: 1px solid rgba(255, 208, 130, 0.28); border: 1px solid var(--site-banner-border-color, var(--site-banner-tone-border, rgba(255, 208, 130, 0.28)));
background: rgba(103, 75, 25, 0.38); background: var(--site-banner-background-color, var(--site-banner-tone-background, rgba(103, 75, 25, 0.38)));
color: #ffe4b3; color: #ffe4b3;
font-family: "JetBrains Mono", Consolas, monospace; font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.82rem; 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, .card,
.admin-card, .admin-card,
+8 -2
View File
@@ -1,12 +1,14 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, type CSSProperties } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = { type BannerInfo = {
enabled: boolean enabled: boolean
message: string message: string
tone?: string tone?: string
backgroundColor?: string | null
borderColor?: string | null
} }
type SiteInfo = { type SiteInfo = {
@@ -52,10 +54,14 @@ export default function SiteStatus() {
const banner = info?.banner const banner = info?.banner
const tone = banner?.tone || 'info' const tone = banner?.tone || 'info'
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
return ( return (
<> <>
{banner?.enabled && banner.message ? ( {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} ) : null}
</> </>
) )
+64 -4
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright') const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101' const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const output = process.env.REVIEW_DIR 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 () => { ;(async () => {
const browser = await chromium.launch({ headless: true }) const browser = await chromium.launch({ headless: true })
@@ -17,6 +17,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
let provider = 'jellyfin' let provider = 'jellyfin'
let supported = true let supported = true
let savedEmail = 'member@example.com' 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 calls = []
const errors = [] const errors = []
await context.route('**/api/**', async (route) => { await context.route('**/api/**', async (route) => {
@@ -25,7 +37,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
const method = request.method() const method = request.method()
const reply = (json, status = 200) => route.fulfill({ status, json }) const reply = (json, status = 200) => route.fulfill({ status, json })
if (path === '/api/site/public' || path === '/api/site/info') return reply(options) 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 ? { 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 }, 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 }, 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.showLocalLogin = false
options.login.showForgotPassword = false options.login.showForgotPassword = false
options.login.showSignupLink = false options.login.showSignupLink = false
options.login.message = 'Sign-in help is available from the media team.'
options.banner.message = 'Maintenance tonight' options.banner.message = 'Maintenance tonight'
options.banner.backgroundColor = '#24172f'
options.banner.borderColor = '#d946ef'
await openLogin() await openLogin()
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0) 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: 'Forgot password?' }).count(), 0)
assert.equal(await page.getByRole('link', { name: /Create an account/ }).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('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 = structuredClone(site)
options.login.showLocalLogin = false options.login.showLocalLogin = false
await openLogin() await openLogin()
loginStatus = 200 loginStatus = 200
await login() 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')) 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') await page.goto(base + '/profile')
const email = page.getByLabel('Email address', { exact: true }) const email = page.getByLabel('Email address', { exact: true })