From d75f36c6917fa892b59647b9d84fea9be3d1cba5 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Wed, 16 Sep 2026 14:22:38 +1200 Subject: [PATCH] feat: customize site banners and login notices --- backend/app/config.py | 20 ++++++++ backend/app/routers/admin.py | 15 +++++- backend/app/routers/site.py | 5 ++ backend/tests/test_backend_quality.py | 48 ++++++++++++++++++- frontend/app/account.css | 3 ++ frontend/app/admin/SettingField.tsx | 30 +++++++++++- frontend/app/admin/SettingsPage.tsx | 22 +++++++-- frontend/app/admin/config.css | 5 ++ frontend/app/login/page.tsx | 25 +++++++--- frontend/app/ops-redesign.css | 8 +++- frontend/app/ui/SiteStatus.tsx | 10 +++- scripts/review_account_ui.cjs | 68 +++++++++++++++++++++++++-- 12 files changed, 239 insertions(+), 20 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4cb4629..82df002 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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") ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index b568927..84d2954 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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) diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py index 9933e58..c3d1bcd 100644 --- a/backend/app/routers/site.py +++ b/backend/app/routers/site.py @@ -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), diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index d967337..f1bc0e5 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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: diff --git a/frontend/app/account.css b/frontend/app/account.css index 3dd3a2c..54ba4af 100644 --- a/frontend/app/account.css +++ b/frontend/app/account.css @@ -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); } diff --git a/frontend/app/admin/SettingField.tsx b/frontend/app/admin/SettingField.tsx index c0e40a8..82af1df 100644 --- a/frontend/app/admin/SettingField.tsx +++ b/frontend/app/admin/SettingField.tsx @@ -26,6 +26,11 @@ const SELECTS: Record = { requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }], } +const COLOR_DEFAULTS: Record = { + 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 (
{help &&

{help}

}
- onChange(String(event.target.checked))} /> + onChange(String(event.target.checked))} />
) } @@ -52,6 +59,27 @@ export default function SettingField(props: Props) { {props.optionsUnavailable ? ( + ) : colorDefault ? ( +
+ onChange(event.target.value)} + /> + onChange(event.target.value)} + /> + {value ? : null} +
) : selectedOptions ? (