feat: customize site banners and login notices
This commit is contained in:
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user