diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py
new file mode 100644
index 000000000..fc9669f38
--- /dev/null
+++ b/application/single_app/admin_settings_fields.py
@@ -0,0 +1,902 @@
+# admin_settings_fields.py
+"""Declarative field definitions for the Admin Settings surface.
+
+``admin_settings_nav.py`` says which groups, tabs and sections exist. It does
+not say which settings live inside a section, because the server-rendered
+Admin Settings page answers that with hand-written markup in
+``templates/admin/_panes/``.
+
+The V2 React admin surface cannot read that markup. Without a machine-readable
+description it can only discover settings by scanning the settings document for
+``enable_*`` booleans, which is why it could render switches and nothing else:
+titles, colours, selects, ranges, uploads and repeatable lists are all
+invisible to that scan.
+
+This module supplies the missing description. Each section id from ``ADMIN_NAV``
+maps to an ordered list of fields, and each field carries everything a generic
+renderer needs -- type, label, help text, default, bounds, options and
+visibility dependencies.
+
+Two things keep this honest rather than becoming a third source of truth:
+
+``LEGACY_FIELD_NAMES``
+ Records the server-rendered form field names each entry replaces, including
+ the places where V1's form shape differs from the stored settings key. The
+ parity functional test walks the V1 panes and fails when a form field is not
+ claimed here, so a setting cannot exist in one interface only.
+
+``normalize_admin_settings_updates``
+ Validates and coerces incoming values against these definitions, delegating
+ to the same normalizers the server-rendered form uses. Both interfaces
+ therefore agree on what a valid value is.
+
+Only the Appearance group is described so far. Sections with no entry here fall
+back to the V2 surface's ``enable_*`` scan, so undescribed groups keep working
+exactly as they did.
+"""
+
+import re
+from urllib.parse import urlparse
+
+from functions_ai_notice import (
+ AI_NOTICE_MAX_MESSAGE_LENGTH,
+ normalize_ai_notice_frequency,
+ normalize_ai_notice_message,
+)
+from functions_terms_of_use import (
+ TERMS_OF_USE_DEFAULT_REDIRECT,
+ TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH,
+ TERMS_OF_USE_MAX_MESSAGE_LENGTH,
+ TERMS_OF_USE_MAX_TITLE_LENGTH,
+ normalize_terms_of_use_frequency,
+ normalize_terms_of_use_redirect_url,
+ normalize_terms_of_use_text,
+)
+
+HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
+
+# Field types the V2 renderer knows how to draw. A type outside this set is a
+# schema bug, and the schema test fails on it rather than the browser silently
+# rendering nothing.
+FIELD_TYPES = (
+ "text",
+ "textarea",
+ "select",
+ "switch",
+ "checkbox_set",
+ "color",
+ "range",
+ "number",
+ "image",
+ "link_list",
+ "component",
+)
+
+# Types that own their persistence outside the settings PATCH: image uploads go
+# through the multipart branding endpoint, and components talk to their own API.
+NON_PATCHABLE_TYPES = ("image", "component")
+
+LANDING_PAGE_ALIGNMENTS = ("left", "center", "right")
+USER_AGREEMENT_APPLY_TO_VALUES = ("personal", "group", "public", "chat")
+
+LOGO_SCALE_MIN_PERCENT = 50
+LOGO_SCALE_MAX_PERCENT = 500
+LOGO_SCALE_DEFAULT_PERCENT = 100
+
+# Advisory, not enforced. The server-rendered form warns and saves anyway, so
+# rejecting the value here would lock an administrator out of editing a section
+# that already holds a longer agreement.
+USER_AGREEMENT_WORD_LIMIT = 200
+
+CLASSIFICATION_BANNER_DEFAULT_COLOR = "#ffc107"
+CLASSIFICATION_BANNER_DEFAULT_TEXT_COLOR = "#ffffff"
+
+# Schemes permitted for administrator-configured navigation links. These render
+# into an anchor href, so allowing arbitrary schemes would let a saved link
+# carry javascript: into every page's navigation.
+EXTERNAL_LINK_ALLOWED_SCHEMES = ("http", "https")
+
+
+ADMIN_SETTINGS_FIELDS = {
+ "branding-section": [
+ {
+ "key": "app_title",
+ "type": "text",
+ "label": "Application Title",
+ "help": "Shown in the browser tab, the header and the landing page.",
+ "default": "Simple Chat",
+ "max_length": 120,
+ },
+ {
+ "key": "show_logo",
+ "type": "switch",
+ "label": "Show Logo",
+ "help": "Display the application logo in the header and navigation areas.",
+ "default": False,
+ },
+ {
+ "key": "hide_app_title",
+ "type": "switch",
+ "label": "Hide Application Title",
+ "help": "Show only the logo in the header and navigation.",
+ "default": False,
+ },
+ {
+ "key": "landing_page_logo_scale_percent",
+ "type": "range",
+ "label": "Main Page Logo Size",
+ "help": (
+ "Adjusts the logo on the home page only. Navigation logo size is "
+ "unaffected."
+ ),
+ "default": LOGO_SCALE_DEFAULT_PERCENT,
+ "min": LOGO_SCALE_MIN_PERCENT,
+ "max": LOGO_SCALE_MAX_PERCENT,
+ "step": 10,
+ "suffix": "%",
+ "depends_on": {"key": "show_logo", "equals": True},
+ },
+ {
+ "key": "custom_logo_base64",
+ "type": "image",
+ "label": "Custom Logo (Light Mode)",
+ "help": (
+ "Stored at up to 500px tall so the home page can enlarge it without "
+ "keeping an oversized asset in settings."
+ ),
+ "upload_target": "logo",
+ "accept": ".png,.jpg,.jpeg",
+ "version_key": "logo_version",
+ },
+ {
+ "key": "custom_logo_dark_base64",
+ "type": "image",
+ "label": "Custom Logo (Dark Mode)",
+ "help": "Falls back to the light mode logo when no dark variant is uploaded.",
+ "upload_target": "logo_dark",
+ "accept": ".png,.jpg,.jpeg",
+ "version_key": "logo_dark_version",
+ },
+ {
+ "key": "custom_favicon_base64",
+ "type": "image",
+ "label": "Custom Favicon",
+ "help": "Converted to a 32x32 ICO. Upload a square image for best results.",
+ "upload_target": "favicon",
+ "accept": ".png,.jpg,.jpeg,.ico",
+ "version_key": "favicon_version",
+ },
+ ],
+ "home-page-text-section": [
+ {
+ "key": "landing_page_alignment",
+ "type": "select",
+ "label": "Markdown Alignment",
+ "help": "How the landing page markdown is aligned on the home page.",
+ "default": "left",
+ "options": [
+ {"value": "left", "label": "Left"},
+ {"value": "center", "label": "Center"},
+ {"value": "right", "label": "Right"},
+ ],
+ },
+ {
+ "key": "enable_landing_page_editor",
+ "type": "switch",
+ "label": "Enable Markdown Editor",
+ "help": (
+ "When off, the landing page text is shown as a read-only preview "
+ "instead of an editable field."
+ ),
+ "default": False,
+ },
+ {
+ "key": "landing_page_text",
+ "type": "textarea",
+ "label": "Landing Page Text",
+ "help": "Markdown is supported.",
+ "default": "",
+ "rows": 8,
+ "markdown": True,
+ "max_length": 20000,
+ "depends_on": {"key": "enable_landing_page_editor", "equals": True},
+ },
+ ],
+ "appearance-section": [
+ {
+ "key": "enable_dark_mode_default",
+ "type": "switch",
+ "label": "Enable Dark Mode by Default",
+ "help": "Users can still switch themes individually.",
+ "default": False,
+ },
+ {
+ "key": "enable_left_nav_default",
+ "type": "switch",
+ "label": "Enable Left Nav by Default",
+ "help": "Users can still toggle the sidebar individually.",
+ "default": True,
+ },
+ ],
+ "classification-banner-section": [
+ {
+ "key": "classification_banner_enabled",
+ "type": "switch",
+ "label": "Enable Classification Banner",
+ "help": "Shows a data sensitivity banner at the top of every page.",
+ "default": False,
+ },
+ {
+ "key": "classification_banner_text",
+ "type": "text",
+ "label": "Banner Text",
+ "default": "",
+ "max_length": 200,
+ "depends_on": {"key": "classification_banner_enabled", "equals": True},
+ },
+ {
+ "key": "classification_banner_color",
+ "type": "color",
+ "label": "Banner Color",
+ "default": CLASSIFICATION_BANNER_DEFAULT_COLOR,
+ "depends_on": {"key": "classification_banner_enabled", "equals": True},
+ },
+ {
+ "key": "classification_banner_text_color",
+ "type": "color",
+ "label": "Banner Text Color",
+ "default": CLASSIFICATION_BANNER_DEFAULT_TEXT_COLOR,
+ "depends_on": {"key": "classification_banner_enabled", "equals": True},
+ },
+ {
+ "type": "component",
+ "component": "classification-banner-preview",
+ "label": "Preview",
+ "depends_on": {"key": "classification_banner_enabled", "equals": True},
+ },
+ ],
+ "ai-notice-section": [
+ {
+ "key": "enable_ai_notice",
+ "type": "switch",
+ "label": "Show a custom AI notice below the chat input",
+ "help": (
+ "Displays an administrator-provided reminder directly below the chat "
+ "composer."
+ ),
+ "default": False,
+ },
+ {
+ "key": "ai_notice_message",
+ "type": "textarea",
+ "label": "Notice Text",
+ "help": "Plain text only. Line breaks are preserved.",
+ "default": "",
+ "rows": 3,
+ "max_length": AI_NOTICE_MAX_MESSAGE_LENGTH,
+ "placeholder": (
+ "AI-generated responses may contain errors. Review important "
+ "information before relying on it."
+ ),
+ "depends_on": {"key": "enable_ai_notice", "equals": True},
+ },
+ {
+ "key": "ai_notice_frequency",
+ "type": "select",
+ "label": "Display Behavior",
+ "help": (
+ "Changing the notice text or display behavior creates a new message "
+ "version and shows it again."
+ ),
+ "default": "non_dismissible",
+ "options": [
+ {"value": "non_dismissible", "label": "Always visible; users cannot dismiss it"},
+ {"value": "every_session", "label": "Dismissible once per session"},
+ {"value": "daily", "label": "Dismissible once per day"},
+ {"value": "once", "label": "Dismissible once per message version"},
+ ],
+ "depends_on": {"key": "enable_ai_notice", "equals": True},
+ },
+ ],
+ "terms-of-use-section": [
+ {
+ "key": "enable_terms_of_use",
+ "type": "switch",
+ "label": "Require terms of use",
+ "help": (
+ "Users must accept before reaching authenticated pages or APIs. "
+ "Passive sign-in flows are gated immediately after the session is "
+ "created."
+ ),
+ "default": False,
+ },
+ {
+ "key": "terms_of_use_title",
+ "type": "text",
+ "label": "Popup Title",
+ "default": "Terms of Use",
+ "max_length": TERMS_OF_USE_MAX_TITLE_LENGTH,
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ {
+ "key": "terms_of_use_frequency",
+ "type": "select",
+ "label": "Show Frequency",
+ "help": (
+ "Changing the title, message or frequency creates a new terms version "
+ "that users must accept again."
+ ),
+ "default": "once",
+ "options": [
+ {"value": "every_session", "label": "At the start of every session"},
+ {"value": "daily", "label": "Once per day"},
+ {"value": "once", "label": "Just once per terms version"},
+ ],
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ {
+ "key": "terms_of_use_message",
+ "type": "textarea",
+ "label": "Terms of Use Message",
+ "help": "Plain text is shown to users with line breaks preserved.",
+ "default": "",
+ "rows": 7,
+ "max_length": TERMS_OF_USE_MAX_MESSAGE_LENGTH,
+ "placeholder": (
+ "Enter the terms, notice, or rules of behavior users must accept "
+ "before using the application."
+ ),
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ {
+ "key": "terms_of_use_decline_redirect_url",
+ "type": "text",
+ "label": "Cancel Redirect URL",
+ "help": (
+ "A local path such as / or an HTTPS URL. Signed-in users are locally "
+ "logged out before this redirect."
+ ),
+ "default": TERMS_OF_USE_DEFAULT_REDIRECT,
+ "max_length": 2000,
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ {
+ "key": "terms_of_use_accept_button_text",
+ "type": "text",
+ "label": "Accept Button Text",
+ "default": "Accept and continue",
+ "max_length": TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH,
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ {
+ "key": "terms_of_use_decline_button_text",
+ "type": "text",
+ "label": "Cancel Button Text",
+ "default": "Cancel",
+ "max_length": TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH,
+ "depends_on": {"key": "enable_terms_of_use", "equals": True},
+ },
+ ],
+ "user-agreement-section": [
+ {
+ "key": "enable_user_agreement",
+ "type": "switch",
+ "label": "Enable User Agreement",
+ "help": (
+ "Users must accept the agreement before uploading files in the "
+ "selected workspace types."
+ ),
+ "default": False,
+ },
+ {
+ "key": "user_agreement_apply_to",
+ "type": "checkbox_set",
+ "label": "Apply to",
+ "help": "Select where the user agreement should be shown.",
+ "default": [],
+ "min_selected": 1,
+ "options": [
+ {"value": "personal", "label": "Personal Workspaces"},
+ {"value": "group", "label": "Group Workspaces"},
+ {"value": "public", "label": "Public Workspaces"},
+ {"value": "chat", "label": "Chat"},
+ ],
+ "depends_on": {"key": "enable_user_agreement", "equals": True},
+ },
+ {
+ "key": "user_agreement_text",
+ "type": "textarea",
+ "label": "Agreement Text",
+ "help": "Markdown is supported.",
+ "default": "",
+ "rows": 6,
+ "markdown": True,
+ "max_length": 10000,
+ "word_limit": USER_AGREEMENT_WORD_LIMIT,
+ "placeholder": (
+ "Enter the agreement text that users must accept before uploading "
+ "files..."
+ ),
+ "depends_on": {"key": "enable_user_agreement", "equals": True},
+ },
+ {
+ "key": "enable_user_agreement_daily",
+ "type": "switch",
+ "label": "Allow users to accept once per day",
+ "help": (
+ "Users accept once per day instead of every time they upload files."
+ ),
+ "default": False,
+ "depends_on": {"key": "enable_user_agreement", "equals": True},
+ },
+ {
+ "type": "component",
+ "component": "user-agreement-preview",
+ "label": "Test Preview",
+ "depends_on": {"key": "enable_user_agreement", "equals": True},
+ },
+ ],
+ "custom-pages-section": [
+ {
+ "key": "enable_custom_pages",
+ "type": "switch",
+ "label": "Enable Custom Pages",
+ "help": (
+ "Serves trusted pages deployed under custom_pages at /custom. When "
+ "off, /custom returns Not Found before any custom metadata, file or "
+ "Python extension is loaded."
+ ),
+ "default": False,
+ # Enabling only takes full effect after an App Service restart, so the
+ # V2 surface must collect the same acknowledgement the V1 form does.
+ "requires_acknowledgement": {
+ "key": "custom_pages_restart_acknowledged",
+ "when": "enabled",
+ "title": "Custom Pages requires a restart",
+ "message": (
+ "Custom Pages is not fully enabled until the App Service is "
+ "restarted. Python-backed pages register their routes at startup."
+ ),
+ },
+ },
+ {
+ "key": "custom_pages_menu_name",
+ "type": "text",
+ "label": "Menu Name",
+ "help": "Shown when custom pages are grouped into a menu.",
+ "default": "Custom Pages",
+ "max_length": 60,
+ "fallback_when_empty": True,
+ "depends_on": {"key": "enable_custom_pages", "equals": True},
+ },
+ {
+ "key": "custom_pages_force_menu",
+ "type": "switch",
+ "label": "Force Menu Display",
+ "help": (
+ "When off, 1-2 pages appear as top-level nav items and 3 or more "
+ "become a menu."
+ ),
+ "default": False,
+ "depends_on": {"key": "enable_custom_pages", "equals": True},
+ },
+ {
+ "type": "component",
+ "component": "custom-pages-table",
+ "label": "Static Page Metadata",
+ "help": (
+ "Metadata contracts for pages built from files in custom_pages/html, "
+ "css, js, assets and json."
+ ),
+ "depends_on": {"key": "enable_custom_pages", "equals": True},
+ },
+ ],
+ "external-links-section": [
+ {
+ "key": "enable_external_links",
+ "type": "switch",
+ "label": "Enable External Links in Navigation",
+ "help": "Adds administrator-approved links to the navigation bar.",
+ "default": False,
+ },
+ {
+ "key": "external_links_menu_name",
+ "type": "text",
+ "label": "Menu Name",
+ "help": "Appears in the navigation bar as the menu title.",
+ "default": "External Links",
+ "max_length": 60,
+ "fallback_when_empty": True,
+ "depends_on": {"key": "enable_external_links", "equals": True},
+ },
+ {
+ "key": "external_links_force_menu",
+ "type": "switch",
+ "label": "Force Menu Display",
+ "help": (
+ "When off, 1-2 links appear as top-level nav items and 3 or more "
+ "become a dropdown."
+ ),
+ "default": False,
+ "depends_on": {"key": "enable_external_links", "equals": True},
+ },
+ {
+ "key": "external_links",
+ "type": "link_list",
+ "label": "External Links",
+ "help": "Links open in a new tab. Only http and https addresses are allowed.",
+ "default": [],
+ "item_fields": [
+ {"key": "label", "type": "text", "label": "Label", "max_length": 80},
+ {"key": "url", "type": "text", "label": "URL", "max_length": 2000},
+ ],
+ "depends_on": {"key": "enable_external_links", "equals": True},
+ },
+ ],
+}
+
+
+# Maps each schema key to the field name(s) the server-rendered form submits.
+# Most match exactly and are omitted. The entries below are the places where the
+# two shapes genuinely differ, and the parity test uses them to resolve a V1
+# form field to its V2 equivalent.
+LEGACY_FIELD_NAMES = {
+ # V1 submits four independent checkboxes and assembles the array server-side.
+ "user_agreement_apply_to": [
+ "user_agreement_apply_personal",
+ "user_agreement_apply_group",
+ "user_agreement_apply_public",
+ "user_agreement_apply_chat",
+ ],
+ # V1 round-trips the list through a hidden JSON field maintained by script.
+ "external_links": ["external_links_json"],
+ # V1 posts the images as part of the settings form; V2 uploads them
+ # separately, so the stored keys are what the schema names.
+ "custom_logo_base64": ["logo_file"],
+ "custom_logo_dark_base64": ["logo_dark_file"],
+ "custom_favicon_base64": ["favicon_file"],
+ # Collected as an acknowledgement on the toggle rather than a stored value.
+ "enable_custom_pages": ["enable_custom_pages", "custom_pages_restart_acknowledged"],
+}
+
+# Field names present in the V1 Appearance panes that intentionally have no V2
+# equivalent, with the reason. The parity test reads this, so an unexplained
+# omission fails rather than passing silently.
+LEGACY_FIELDS_WITHOUT_V2_EQUIVALENT = {}
+
+
+def get_admin_settings_fields():
+ """Return the section-id keyed field schema."""
+ return ADMIN_SETTINGS_FIELDS
+
+
+def iter_fields():
+ """Yield ``(section_id, field)`` for every declared field."""
+ for section_id, fields in ADMIN_SETTINGS_FIELDS.items():
+ for field in fields:
+ yield section_id, field
+
+
+def get_field_definition(key):
+ """Return the field definition for a settings key, or None."""
+ for _section_id, field in iter_fields():
+ if field.get("key") == key:
+ return field
+ return None
+
+
+def get_declared_setting_keys():
+ """Return every settings key the schema describes.
+
+ The V2 surface uses this to suppress its ``enable_*`` fallback scan for keys
+ that already have a proper field, so a toggle is never rendered twice.
+ """
+ return {field["key"] for _section_id, field in iter_fields() if field.get("key")}
+
+
+def get_legacy_field_names():
+ """Return the V1 form field names claimed by the schema."""
+ claimed = set()
+ for _section_id, field in iter_fields():
+ key = field.get("key")
+ if not key:
+ continue
+ claimed.update(LEGACY_FIELD_NAMES.get(key, [key]))
+ return claimed
+
+
+def _coerce_bool(value):
+ """Coerce a JSON or form-shaped truthy value into a bool."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in ("true", "on", "yes", "1")
+ return bool(value)
+
+
+def _normalize_text(value, field):
+ """Strip and truncate a single-line value, applying an empty-value fallback."""
+ text = str(value if value is not None else "").replace("\r\n", "\n").replace("\r", "\n").strip()
+ if not text and field.get("fallback_when_empty"):
+ text = str(field.get("default") or "")
+ max_length = field.get("max_length")
+ if max_length:
+ text = text[:max_length]
+ return text
+
+
+def _validate_external_link_url(url):
+ """Return an error message when a navigation link URL is not safe to render."""
+ candidate = str(url or "").strip()
+ if not candidate:
+ return "URL is required."
+ if candidate.startswith("/") and not candidate.startswith("//"):
+ return None
+
+ parsed = urlparse(candidate)
+ if parsed.scheme.lower() not in EXTERNAL_LINK_ALLOWED_SCHEMES:
+ return (
+ "URL must be a local path or use "
+ f"{' or '.join(EXTERNAL_LINK_ALLOWED_SCHEMES)}."
+ )
+ if not parsed.netloc:
+ return "URL is missing a host."
+ return None
+
+
+def _normalize_link_list(value):
+ """Return ``(links, error)`` for an administrator-managed navigation list."""
+ if not isinstance(value, list):
+ return None, "Expected a list of links."
+
+ links = []
+ for index, item in enumerate(value, start=1):
+ if not isinstance(item, dict):
+ return None, f"Link {index} is not an object."
+
+ label = str(item.get("label") or "").strip()
+ url = str(item.get("url") or "").strip()
+ if not label:
+ return None, f"Link {index} is missing a label."
+
+ url_error = _validate_external_link_url(url)
+ if url_error:
+ return None, f"Link {index}: {url_error}"
+
+ links.append({"label": label[:80], "url": url[:2000]})
+
+ return links, None
+
+
+def _normalize_checkbox_set(value, field):
+ """Return ``(selection, error)`` for a multi-select checkbox group."""
+ if isinstance(value, str):
+ value = [value]
+ if not isinstance(value, list):
+ return None, "Expected a list of values."
+
+ allowed = [option["value"] for option in field.get("options", [])]
+ # Preserve the declared option order so the stored array is stable no matter
+ # which order the browser sent the boxes in.
+ selection = [option for option in allowed if option in value]
+
+ unknown = sorted({str(item) for item in value} - set(allowed))
+ if unknown:
+ return None, f"Unsupported value(s): {', '.join(unknown)}."
+
+ return selection, None
+
+
+def _normalize_number(value, field):
+ """Return ``(number, error)`` clamped to the field's declared bounds."""
+ try:
+ number = int(float(value))
+ except (TypeError, ValueError):
+ return None, "Expected a number."
+
+ minimum = field.get("min")
+ maximum = field.get("max")
+ if minimum is not None:
+ number = max(minimum, number)
+ if maximum is not None:
+ number = min(maximum, number)
+ return number, None
+
+
+# Keys whose normalization already exists elsewhere. Reusing those functions is
+# what stops the two admin surfaces from disagreeing about, for example, which
+# frequency aliases are accepted or how a terms message is trimmed.
+_DELEGATED_NORMALIZERS = {
+ "ai_notice_message": lambda value, field: normalize_ai_notice_message(value),
+ "ai_notice_frequency": lambda value, field: normalize_ai_notice_frequency(value),
+ "terms_of_use_frequency": lambda value, field: normalize_terms_of_use_frequency(value),
+ "terms_of_use_title": lambda value, field: normalize_terms_of_use_text(
+ value, fallback="Terms of Use", max_length=TERMS_OF_USE_MAX_TITLE_LENGTH
+ ),
+ "terms_of_use_message": lambda value, field: normalize_terms_of_use_text(
+ value, max_length=TERMS_OF_USE_MAX_MESSAGE_LENGTH
+ ),
+ "terms_of_use_accept_button_text": lambda value, field: normalize_terms_of_use_text(
+ value, fallback="Accept and continue", max_length=TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH
+ ),
+ "terms_of_use_decline_button_text": lambda value, field: normalize_terms_of_use_text(
+ value, fallback="Cancel", max_length=TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH
+ ),
+}
+
+
+def _normalize_field_value(key, value, field):
+ """Return ``(normalized, error, warning)`` for one declared field."""
+ field_type = field.get("type")
+
+ if field_type in NON_PATCHABLE_TYPES:
+ return None, f"{key} cannot be changed through this endpoint.", None
+
+ if key in _DELEGATED_NORMALIZERS:
+ return _DELEGATED_NORMALIZERS[key](value, field), None, None
+
+ if field_type == "switch":
+ return _coerce_bool(value), None, None
+
+ if field_type == "select":
+ allowed = [option["value"] for option in field.get("options", [])]
+ candidate = str(value or "").strip()
+ if candidate not in allowed:
+ return None, f"Expected one of: {', '.join(allowed)}.", None
+ return candidate, None, None
+
+ if field_type == "color":
+ candidate = str(value or "").strip()
+ if not HEX_COLOR_PATTERN.match(candidate):
+ return None, "Expected a hex colour such as #ffc107.", None
+ return candidate.lower(), None, None
+
+ if field_type in ("range", "number"):
+ number, error = _normalize_number(value, field)
+ return number, error, None
+
+ if field_type == "checkbox_set":
+ selection, error = _normalize_checkbox_set(value, field)
+ return selection, error, None
+
+ if field_type == "link_list":
+ links, error = _normalize_link_list(value)
+ return links, error, None
+
+ if field_type == "textarea":
+ text = str(value if value is not None else "")
+ text = text.replace("\r\n", "\n").replace("\r", "\n").strip()
+ max_length = field.get("max_length")
+ if max_length:
+ text = text[:max_length]
+
+ warning = None
+ word_limit = field.get("word_limit")
+ if word_limit and len(text.split()) > word_limit:
+ # Advisory only, matching the server-rendered form, which warns and
+ # saves rather than blocking the whole submission.
+ warning = (
+ f"{len(text.split())} words exceeds the recommended "
+ f"{word_limit} word limit."
+ )
+ return text, None, warning
+
+ if field_type == "text":
+ return _normalize_text(value, field), None, None
+
+ return value, None, None
+
+
+def _validate_redirect_url(value):
+ """Return ``(normalized, error)`` for the Terms of Use cancel redirect.
+
+ ``normalize_terms_of_use_redirect_url`` silently substitutes ``/`` for an
+ unsafe target. That is the right behaviour when reading stored settings, but
+ on an explicit save an administrator should be told their URL was refused
+ rather than discovering later that it reverted.
+ """
+ candidate = str(value or "").strip()
+ normalized = normalize_terms_of_use_redirect_url(candidate)
+ if candidate and normalized != candidate:
+ return None, (
+ "Use a local path such as / or an HTTPS URL without credentials."
+ )
+ return normalized, None
+
+
+def _check_acknowledgements(updates, current_settings, errors):
+ """Enforce the acknowledgements a field requires before it may be enabled."""
+ for _section_id, field in iter_fields():
+ acknowledgement = field.get("requires_acknowledgement")
+ key = field.get("key")
+ if not acknowledgement or not key or key not in updates:
+ continue
+
+ turning_on = _coerce_bool(updates[key])
+ already_on = _coerce_bool(current_settings.get(key, False))
+ if not turning_on or already_on:
+ continue
+
+ if not _coerce_bool(updates.get(acknowledgement["key"])):
+ errors[key] = acknowledgement["message"]
+
+
+def normalize_admin_settings_updates(updates, current_settings=None):
+ """Validate and coerce a partial admin settings update.
+
+ Returns ``(normalized, errors, warnings)``. ``normalized`` is safe to hand to
+ ``update_settings``; it is only meaningful when ``errors`` is empty.
+
+ Keys with no declared field pass through unchanged. That is deliberate: the
+ V2 surface still renders undescribed groups from its ``enable_*`` scan, and
+ those toggles must keep saving while the remaining groups are described.
+ """
+ current = current_settings or {}
+ normalized = {}
+ errors = {}
+ warnings = {}
+
+ # Acknowledgement flags gate a change rather than being stored themselves.
+ acknowledgement_keys = {
+ field["requires_acknowledgement"]["key"]
+ for _section_id, field in iter_fields()
+ if field.get("requires_acknowledgement")
+ }
+
+ for key, value in updates.items():
+ if key in acknowledgement_keys:
+ continue
+
+ field = get_field_definition(key)
+ if field is None:
+ normalized[key] = value
+ continue
+
+ if key == "terms_of_use_decline_redirect_url":
+ redirect_value, redirect_error = _validate_redirect_url(value)
+ if redirect_error:
+ errors[key] = redirect_error
+ else:
+ normalized[key] = redirect_value
+ continue
+
+ field_value, error, warning = _normalize_field_value(key, value, field)
+ if error:
+ errors[key] = error
+ continue
+ if warning:
+ warnings[key] = warning
+ normalized[key] = field_value
+
+ _check_acknowledgements(updates, current, errors)
+
+ # "At least one" style constraints can only be judged once the whole payload
+ # is known, because the capability toggle and its selection may arrive apart.
+ _check_minimum_selections(normalized, current, errors)
+
+ return normalized, errors, warnings
+
+
+def _check_minimum_selections(normalized, current_settings, errors):
+ """Enforce ``min_selected`` once the merged state of a save is known."""
+ for _section_id, field in iter_fields():
+ key = field.get("key")
+ minimum = field.get("min_selected")
+ if not key or not minimum:
+ continue
+
+ depends_on = field.get("depends_on")
+ if depends_on:
+ gate_key = depends_on["key"]
+ gate_value = (
+ normalized[gate_key] if gate_key in normalized
+ else current_settings.get(gate_key, False)
+ )
+ if _coerce_bool(gate_value) != depends_on.get("equals", True):
+ continue
+
+ selection = (
+ normalized[key] if key in normalized else current_settings.get(key) or []
+ )
+ if len(selection) < minimum:
+ errors[key] = f"Select at least {minimum} option."
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 5f72356c2..3105bc78f 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.038"
+VERSION = "0.261.039"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/functions_branding_images.py b/application/single_app/functions_branding_images.py
new file mode 100644
index 000000000..e2f0b7d7c
--- /dev/null
+++ b/application/single_app/functions_branding_images.py
@@ -0,0 +1,132 @@
+# functions_branding_images.py
+"""Image processing for administrator-supplied branding assets.
+
+Logos and favicons are uploaded by an administrator, converted to a canonical
+form, and stored base64-encoded in the settings document. Two interfaces now
+accept those uploads -- the server-rendered Admin Settings form and the V2
+React admin surface -- so the conversion lives here rather than inside either
+route module. A logo that is resized in one interface and not the other would
+render at a different size depending on where it was uploaded from.
+
+The conversion rules are the ones the server-rendered form has always applied:
+
+- Only PNG and JPEG are decoded, regardless of the file extension supplied, so
+ a renamed file cannot reach Pillow's other decoders.
+- Palette images become RGBA and anything else that is not already RGB or RGBA
+ becomes RGB, because neither PNG nor ICO can encode every Pillow mode.
+- Logos taller than ``MAX_CUSTOM_LOGO_STORAGE_HEIGHT`` are scaled down with
+ their aspect ratio preserved, which keeps the settings document small while
+ still being sharp enough for the landing page to enlarge.
+- Favicons are squared off at 32x32 and encoded as ICO.
+"""
+
+import base64
+from io import BytesIO
+
+from PIL import Image
+
+# Pillow is asked for these formats explicitly. Passing an allow-list to
+# Image.open means an attacker-supplied file cannot select a different decoder
+# by claiming a different format internally.
+ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG')
+
+# Tall enough for the landing page to render a logo scaled up to 500% without
+# visible softening, small enough that the base64 copy stays a reasonable size
+# inside the settings document.
+MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500
+
+FAVICON_SIZE = (32, 32)
+
+ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg'}
+ALLOWED_FAVICON_EXTENSIONS = {'png', 'jpg', 'jpeg', 'ico'}
+
+
+def is_allowed_branding_image_filename(filename, allowed_extensions):
+ """Return True when ``filename`` carries one of ``allowed_extensions``.
+
+ This is a first-pass check only. The extension is attacker-controlled, so
+ the real format check happens in ``open_allowed_uploaded_image`` once the
+ bytes have been decoded.
+ """
+ if not filename or '.' not in filename:
+ return False
+ return filename.rsplit('.', 1)[1].lower() in allowed_extensions
+
+
+def open_allowed_uploaded_image(file_bytes, filename):
+ """Decode ``file_bytes`` as PNG or JPEG, or raise ``ValueError``.
+
+ Returns the loaded image and the format Pillow actually detected, which is
+ what callers should log rather than the supplied extension.
+ """
+ img = Image.open(BytesIO(file_bytes), formats=list(ALLOWED_PIL_IMAGE_UPLOAD_FORMATS))
+ img.load()
+
+ detected_format = (img.format or '').upper()
+ if detected_format not in ALLOWED_PIL_IMAGE_UPLOAD_FORMATS:
+ raise ValueError(
+ f"Unsupported image format for {filename}. Allowed formats: "
+ f"{', '.join(ALLOWED_PIL_IMAGE_UPLOAD_FORMATS)}"
+ )
+
+ return img, detected_format
+
+
+def _normalize_image_mode(img):
+ """Convert ``img`` into a mode both PNG and ICO can encode."""
+ if img.mode == 'P':
+ return img.convert('RGBA')
+ if img.mode not in ('RGB', 'RGBA'):
+ return img.convert('RGB')
+ return img
+
+
+def prepare_logo_image_for_storage(file_bytes, filename, max_height=MAX_CUSTOM_LOGO_STORAGE_HEIGHT):
+ """Return a PNG-encoded, height-capped copy of an uploaded logo.
+
+ The returned dict carries both the raw PNG bytes and their base64 form,
+ plus the original and stored dimensions so callers can record what the
+ resize actually did.
+ """
+ img, detected_format = open_allowed_uploaded_image(file_bytes, filename)
+ original_size = img.size
+
+ img = _normalize_image_mode(img)
+
+ if max_height and img.height > max_height:
+ aspect_ratio = img.width / img.height
+ resized_width = max(1, int(round(aspect_ratio * max_height)))
+ img = img.resize((resized_width, max_height), Image.Resampling.LANCZOS)
+
+ img_bytes_io = BytesIO()
+ img.save(img_bytes_io, format='PNG', optimize=True)
+ png_data = img_bytes_io.getvalue()
+
+ return {
+ 'detected_format': detected_format,
+ 'original_size': original_size,
+ 'stored_size': img.size,
+ 'png_data': png_data,
+ 'base64_str': base64.b64encode(png_data).decode('utf-8'),
+ }
+
+
+def prepare_favicon_image_for_storage(file_bytes, filename, size=FAVICON_SIZE):
+ """Return an ICO-encoded 32x32 copy of an uploaded favicon."""
+ img, detected_format = open_allowed_uploaded_image(file_bytes, filename)
+ original_size = img.size
+
+ img = _normalize_image_mode(img)
+ img = img.resize(size, Image.Resampling.LANCZOS)
+
+ img_bytes_io = BytesIO()
+ img.save(img_bytes_io, format='ICO')
+ ico_data = img_bytes_io.getvalue()
+
+ return {
+ 'detected_format': detected_format,
+ 'original_size': original_size,
+ 'stored_size': img.size,
+ 'ico_data': ico_data,
+ 'base64_str': base64.b64encode(ico_data).decode('utf-8'),
+ }
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index 0d17ba323..042111987 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -21,10 +21,25 @@
import logging
-from flask import jsonify, request, session
+from flask import current_app, jsonify, request, session
+from admin_settings_fields import (
+ get_admin_settings_fields,
+ normalize_admin_settings_updates,
+)
from admin_settings_nav import ADMIN_NAV
+from config import (
+ ensure_custom_favicon_file_exists,
+ ensure_custom_logo_file_exists,
+)
from functions_appinsights import log_event
+from functions_branding_images import (
+ ALLOWED_FAVICON_EXTENSIONS,
+ ALLOWED_LOGO_EXTENSIONS,
+ is_allowed_branding_image_filename,
+ prepare_favicon_image_for_storage,
+ prepare_logo_image_for_storage,
+)
from functions_authentication import (
admin_required,
get_current_user_id,
@@ -65,6 +80,77 @@
logger = logging.getLogger(__name__)
+# Describes each branding asset an administrator can replace: how to convert the
+# upload, which settings keys hold it, and the static path it is written to.
+# ``_build_branding` returns URLs derived from these same keys, so a change here
+# cannot leave the SPA pointing at a stale path.
+BRANDING_IMAGE_TARGETS = {
+ "logo": {
+ "settings_key": "custom_logo_base64",
+ "version_key": "logo_version",
+ "static_url": "/static/images/custom_logo.png",
+ "extensions": ALLOWED_LOGO_EXTENSIONS,
+ "prepare": prepare_logo_image_for_storage,
+ },
+ "logo_dark": {
+ "settings_key": "custom_logo_dark_base64",
+ "version_key": "logo_dark_version",
+ "static_url": "/static/images/custom_logo_dark.png",
+ "extensions": ALLOWED_LOGO_EXTENSIONS,
+ "prepare": prepare_logo_image_for_storage,
+ },
+ "favicon": {
+ "settings_key": "custom_favicon_base64",
+ "version_key": "favicon_version",
+ "static_url": "/static/images/favicon.ico",
+ "extensions": ALLOWED_FAVICON_EXTENSIONS,
+ "prepare": prepare_favicon_image_for_storage,
+ },
+}
+
+
+def _build_branding_assets(settings):
+ """Describe the stored branding images without returning the encoded blobs.
+
+ The admin surface needs to show which assets exist and render a preview of
+ each. The base64 payloads are large and already served as static files, so
+ only presence, version and URL are returned.
+ """
+ assets = {}
+ for target, spec in BRANDING_IMAGE_TARGETS.items():
+ version = settings.get(spec["version_key"]) or 1
+ has_asset = bool(settings.get(spec["settings_key"]))
+ assets[target] = {
+ "present": has_asset,
+ "version": version,
+ "url": f"{spec['static_url']}?v={version}" if has_asset else None,
+ }
+ return assets
+
+
+def _refresh_branding_static_files():
+ """Rewrite the logo and favicon static files from the stored settings.
+
+ The files are generated from the settings document rather than uploaded to
+ disk directly, so any save that could have changed them has to regenerate
+ them or the browser keeps being served the previous image.
+ """
+ try:
+ refreshed_settings = get_settings()
+ if not refreshed_settings:
+ return
+ ensure_custom_logo_file_exists(current_app, refreshed_settings)
+ ensure_custom_favicon_file_exists(current_app, refreshed_settings)
+ except Exception as exc:
+ # A stale static file is a cosmetic problem; it must not turn a saved
+ # settings change into a failed request.
+ log_event(
+ f"[V2_ADMIN_SETTINGS] Could not refresh branding static files: {exc}",
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+
+
def _build_branding(raw_settings, public_settings):
"""Describe branding for the SPA without leaking the encoded logo payloads.
@@ -358,18 +444,25 @@ def register_route_backend_v2_admin(bp):
@login_required
@admin_required
def v2_admin_get_settings():
- """Return the raw settings document plus the admin navigation structure.
+ """Return the raw settings document, the admin navigation and the field schema.
Admin settings are not sanitized. Sanitization removes keys, secrets and endpoint
configuration, which are exactly the values an administrator is here to manage.
Access is restricted to the Admin role by the blueprint guard and the decorator.
+
+ ``field_schema`` describes the concrete controls each section owns. Sections with
+ no entry are rendered by the SPA's ``enable_*`` fallback scan, so groups that have
+ not been described yet keep working.
"""
try:
+ settings = get_settings()
return (
jsonify(
{
- "settings": get_settings(),
+ "settings": settings,
"admin_nav": ADMIN_NAV,
+ "field_schema": get_admin_settings_fields(),
+ "branding_assets": _build_branding_assets(settings),
"version": VERSION,
}
),
@@ -390,8 +483,12 @@ def v2_admin_get_settings():
def v2_admin_patch_settings():
"""Apply a partial settings update.
- The V2 admin surface edits individual capabilities rather than posting the whole
+ The V2 admin surface edits a section at a time rather than posting the whole
settings form, so only the supplied keys are forwarded to ``update_settings``.
+
+ Values are normalized against the field schema first, which is what keeps the two
+ admin interfaces agreeing on what a valid value is. The update is applied only if
+ every supplied key validates, so a save never lands half-applied.
"""
payload = request.get_json(silent=True) or {}
updates = payload.get("settings")
@@ -400,13 +497,53 @@ def v2_admin_patch_settings():
return jsonify({"error": "No settings supplied"}), 400
try:
- update_settings(updates)
+ current_settings = get_settings()
+ normalized, errors, warnings = normalize_admin_settings_updates(
+ updates, current_settings
+ )
+
+ if errors:
+ log_event(
+ f"[V2_ADMIN_SETTINGS] Rejected update for "
+ f"{', '.join(sorted(errors.keys()))}",
+ level=logging.WARNING,
+ )
+ return (
+ jsonify(
+ {
+ "error": "Some settings could not be saved.",
+ "field_errors": errors,
+ }
+ ),
+ 400,
+ )
+
+ if not normalized:
+ return jsonify({"error": "No settings supplied"}), 400
+
+ update_settings(normalized)
log_event(
- f"[V2_ADMIN_SETTINGS] Updated {len(updates)} setting(s): "
- f"{', '.join(sorted(updates.keys()))}",
+ f"[V2_ADMIN_SETTINGS] Updated {len(normalized)} setting(s): "
+ f"{', '.join(sorted(normalized.keys()))}",
level=logging.INFO,
)
- return jsonify({"success": True, "updated_keys": sorted(updates.keys())}), 200
+
+ # Logo scale and title changes are read from the settings document on the
+ # next request, but the favicon and logo static files are written from it, so
+ # a branding change has to refresh them.
+ _refresh_branding_static_files()
+
+ return (
+ jsonify(
+ {
+ "success": True,
+ "updated_keys": sorted(normalized.keys()),
+ "settings": normalized,
+ "warnings": warnings,
+ }
+ ),
+ 200,
+ )
except Exception as exc:
log_event(
f"[V2_ADMIN_SETTINGS] Failed to update settings: {exc}",
@@ -414,3 +551,101 @@ def v2_admin_patch_settings():
exceptionTraceback=True,
)
return jsonify({"error": "Failed to update settings"}), 500
+
+ @bp.route("/api/v2/admin/settings/branding-image", methods=["POST"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @admin_required
+ def v2_admin_upload_branding_image():
+ """Store an uploaded logo or favicon and return its new static URL.
+
+ Branding images cannot travel through the JSON settings PATCH, so they get their
+ own multipart endpoint. The conversion is the shared one in
+ ``functions_branding_images``, so an asset uploaded here is byte-for-byte what the
+ server-rendered form would have stored.
+
+ The version counter is bumped on every successful upload because the static file
+ keeps a stable name; without the counter, browsers would keep serving the previous
+ image from cache.
+ """
+ target = str(request.form.get("target") or "").strip().lower()
+ spec = BRANDING_IMAGE_TARGETS.get(target)
+ if not spec:
+ return (
+ jsonify(
+ {
+ "error": "Unsupported branding image target. Expected one of: "
+ f"{', '.join(sorted(BRANDING_IMAGE_TARGETS))}."
+ }
+ ),
+ 400,
+ )
+
+ upload = request.files.get("file")
+ if not upload or not upload.filename:
+ return jsonify({"error": "No file was supplied."}), 400
+
+ if not is_allowed_branding_image_filename(upload.filename, spec["extensions"]):
+ return (
+ jsonify(
+ {
+ "error": "Unsupported file type. Allowed extensions: "
+ f"{', '.join(sorted(spec['extensions']))}."
+ }
+ ),
+ 400,
+ )
+
+ try:
+ file_bytes = upload.read()
+ processed = spec["prepare"](file_bytes, upload.filename)
+ except Exception as exc:
+ # A decode failure is administrator error, not a server fault, and the
+ # existing asset must survive it.
+ log_event(
+ f"[V2_ADMIN_SETTINGS] Rejected {target} upload: {exc}",
+ level=logging.WARNING,
+ )
+ return (
+ jsonify({"error": f"That image could not be processed: {exc}"}),
+ 400,
+ )
+
+ try:
+ settings = get_settings()
+ next_version = int(settings.get(spec["version_key"], 1) or 1) + 1
+
+ update_settings(
+ {
+ spec["settings_key"]: processed["base64_str"],
+ spec["version_key"]: next_version,
+ }
+ )
+ _refresh_branding_static_files()
+
+ log_event(
+ f"[V2_ADMIN_SETTINGS] Stored {target} image "
+ f"({processed['detected_format']}, {processed['original_size']} -> "
+ f"{processed['stored_size']}, version {next_version})",
+ level=logging.INFO,
+ )
+
+ return (
+ jsonify(
+ {
+ "success": True,
+ "target": target,
+ "url": f"{spec['static_url']}?v={next_version}",
+ "version": next_version,
+ "stored_size": list(processed["stored_size"]),
+ }
+ ),
+ 200,
+ )
+ except Exception as exc:
+ log_event(
+ f"[V2_ADMIN_SETTINGS] Failed to store {target} image: {exc}",
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return jsonify({"error": "Failed to store the uploaded image"}), 500
diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py
index 6be82e919..c6afe46e3 100644
--- a/application/single_app/route_frontend_admin_settings.py
+++ b/application/single_app/route_frontend_admin_settings.py
@@ -37,6 +37,10 @@
normalize_cosmos_throughput_settings,
validate_cosmos_throughput_policy_settings,
)
+from functions_branding_images import (
+ prepare_favicon_image_for_storage,
+ prepare_logo_image_for_storage,
+)
from functions_activity_logging import log_web_search_consent_acceptance, log_general_admin_action, log_governance_change
from functions_notifications import broadcast_system_notification
from functions_logging import *
@@ -72,8 +76,6 @@
normalize_support_latest_features_visibility,
)
-ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG')
-MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500
AGENTS_PAGE_DEFAULTS = {
'agents_page_title': 'Find your next AI partner',
'agents_page_subtitle': 'Explore specialized agents built to accelerate how you work.',
@@ -106,44 +108,6 @@ def allowed_file(filename, allowed_extensions):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
-def open_allowed_uploaded_image(file_bytes, filename):
- img = Image.open(BytesIO(file_bytes), formats=list(ALLOWED_PIL_IMAGE_UPLOAD_FORMATS))
- img.load()
-
- detected_format = (img.format or '').upper()
- if detected_format not in ALLOWED_PIL_IMAGE_UPLOAD_FORMATS:
- raise ValueError(
- f"Unsupported image format for {filename}. Allowed formats: {', '.join(ALLOWED_PIL_IMAGE_UPLOAD_FORMATS)}"
- )
-
- return img, detected_format
-
-def prepare_logo_image_for_storage(file_bytes, filename, max_height=MAX_CUSTOM_LOGO_STORAGE_HEIGHT):
- img, detected_format = open_allowed_uploaded_image(file_bytes, filename)
- original_size = img.size
-
- if img.mode == 'P':
- img = img.convert('RGBA')
- elif img.mode != 'RGB' and img.mode != 'RGBA':
- img = img.convert('RGB')
-
- if max_height and img.height > max_height:
- aspect_ratio = img.width / img.height
- resized_width = max(1, int(round(aspect_ratio * max_height)))
- img = img.resize((resized_width, max_height), Image.Resampling.LANCZOS)
-
- img_bytes_io = BytesIO()
- img.save(img_bytes_io, format='PNG', optimize=True)
- png_data = img_bytes_io.getvalue()
-
- return {
- 'detected_format': detected_format,
- 'original_size': original_size,
- 'stored_size': img.size,
- 'png_data': png_data,
- 'base64_str': base64.b64encode(png_data).decode('utf-8'),
- }
-
def normalize_agents_page_color(value, fallback):
candidate = str(value or '').strip()
fallback_value = fallback if HEX_COLOR_PATTERN.fullmatch(str(fallback or '')) else '#0f172a'
@@ -3080,7 +3044,6 @@ def is_valid_url(url):
favicon_file = request.files.get('favicon_file')
if favicon_file and allowed_file(favicon_file.filename, ALLOWED_EXTENSIONS_IMG):
try:
- # 1) Read file fully into memory:
file_bytes = favicon_file.read()
add_file_task_to_file_processing_log(
document_id='Image_Upload', # Placeholder if needed
@@ -3088,58 +3051,22 @@ def is_valid_url(url):
content=f"Favicon file uploaded: {favicon_file.filename}"
)
- # 2) Load into Pillow from the original bytes for processing
- img, detected_format = open_allowed_uploaded_image(file_bytes, favicon_file.filename)
-
- add_file_task_to_file_processing_log(
- document_id='Image_Upload', # Placeholder if needed
- user_id='New_image',
- content=f"Loaded favicon image for processing: {favicon_file.filename} (format: {detected_format})"
- )
-
- # 3) Ensure image mode is compatible (e.g., convert palette modes)
- if img.mode == 'P':
- img = img.convert('RGBA')
- elif img.mode != 'RGB' and img.mode != 'RGBA':
- img = img.convert('RGB')
+ processed_favicon = prepare_favicon_image_for_storage(file_bytes, favicon_file.filename)
add_file_task_to_file_processing_log(
document_id='Image_Upload', # Placeholder if needed
user_id='New_image',
- content=f"Converted favicon image mode for processing: {favicon_file.filename} (mode: {img.mode})"
- )
-
- # 4) Resize to appropriate favicon size (16x16 or 32x32)
- img = img.resize((32, 32), Image.Resampling.LANCZOS)
-
- add_file_task_to_file_processing_log(
- document_id='Image_Upload', # Placeholder if needed
- user_id='New_image',
- content=f"Resized favicon image for processing: {favicon_file.filename} (new size: {img.size})"
- )
-
- # 5) Convert to ICO in-memory
- img_bytes_io = BytesIO()
- img.save(img_bytes_io, format='ICO')
- ico_data = img_bytes_io.getvalue()
-
- add_file_task_to_file_processing_log(
- document_id='Image_Upload', # Placeholder if needed
- user_id='New_image',
- content=f"Converted favicon image to ICO for processing: {favicon_file.filename}"
- )
-
- # 6) Turn to base64
- base64_str = base64.b64encode(ico_data).decode('utf-8')
-
- add_file_task_to_file_processing_log(
- document_id='Image_Upload', # Placeholder if needed
- user_id='New_image',
- content=f"Converted favicon image to base64 for processing: {base64_str}"
+ content=(
+ f"Prepared favicon asset: {favicon_file.filename} "
+ f"(format: {processed_favicon['detected_format']}, "
+ f"original size: {processed_favicon['original_size']}, "
+ f"stored size: {processed_favicon['stored_size']}, "
+ f"ico bytes: {len(processed_favicon['ico_data'])})"
+ )
)
# Update only on success
- new_settings['custom_favicon_base64'] = base64_str
+ new_settings['custom_favicon_base64'] = processed_favicon['base64_str']
current_version = settings.get('favicon_version', 1) # Get version from settings loaded at start
new_settings['favicon_version'] = current_version + 1 # Increment
diff --git a/application/v2_ui/src/components/admin/AdminMarkdown.tsx b/application/v2_ui/src/components/admin/AdminMarkdown.tsx
new file mode 100644
index 000000000..ff977dc01
--- /dev/null
+++ b/application/v2_ui/src/components/admin/AdminMarkdown.tsx
@@ -0,0 +1,57 @@
+// AdminMarkdown.tsx
+// Markdown preview for administrator-authored content.
+//
+// Deliberately not `AssistantMarkdown`: that renderer parses citations, applies masking
+// ranges and hosts diagram and chart blocks, none of which apply to a landing page or an
+// agreement, and all of which would misread ordinary admin copy.
+//
+// `react-markdown` does not render raw HTML unless `rehype-raw` is added, which it is not
+// here. Admin-authored markdown therefore cannot inject script or event handlers into the
+// settings page.
+
+import Markdown from 'react-markdown';
+import remarkBreaks from 'remark-breaks';
+import remarkGfm from 'remark-gfm';
+import { clsx } from 'clsx';
+
+export function AdminMarkdown({
+ content,
+ className,
+ align = 'left',
+}: {
+ content: string;
+ className?: string;
+ /** Mirrors `landing_page_alignment` so the preview matches the real page. */
+ align?: 'left' | 'center' | 'right';
+}) {
+ const trimmed = content.trim();
+
+ if (!trimmed) {
+ return
Nothing to preview yet.
;
+ }
+
+ return (
+
+ {trimmed}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/AdminModal.tsx b/application/v2_ui/src/components/admin/AdminModal.tsx
new file mode 100644
index 000000000..14e99ce80
--- /dev/null
+++ b/application/v2_ui/src/components/admin/AdminModal.tsx
@@ -0,0 +1,92 @@
+// AdminModal.tsx
+// Dialog shell shared by the Admin Settings modals.
+//
+// Follows the conventions the chat dialogs already established: a click-to-close backdrop,
+// Escape to dismiss, focus moved in on open and handed back on close, and no focus-trap
+// utility, since no other dialog in this UI uses one.
+
+import { useEffect, useRef, type ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { X } from 'lucide-react';
+import { GlassPanel } from '../ui/primitives';
+
+export function AdminModal({
+ title,
+ description,
+ onClose,
+ footer,
+ size = 'md',
+ children,
+}: {
+ title: string;
+ description?: string;
+ onClose: () => void;
+ footer?: ReactNode;
+ size?: 'md' | 'lg';
+ children: ReactNode;
+}) {
+ const closeRef = useRef(null);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [onClose]);
+
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null;
+ closeRef.current?.focus();
+ return () => previous?.focus?.();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+
{children}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/BrandingImageField.tsx b/application/v2_ui/src/components/admin/BrandingImageField.tsx
new file mode 100644
index 000000000..b3772da4d
--- /dev/null
+++ b/application/v2_ui/src/components/admin/BrandingImageField.tsx
@@ -0,0 +1,159 @@
+// BrandingImageField.tsx
+// Upload control for a logo or favicon.
+//
+// Branding images cannot ride along with the JSON settings PATCH, so this control saves
+// immediately through the multipart branding endpoint rather than joining the page's
+// buffered draft. That is a deliberate exception to the save-bar model: a file input has no
+// meaningful "unsaved" state to show, and the server has to convert the image before
+// anything can be previewed.
+
+import { useRef, useState } from 'react';
+import { clsx } from 'clsx';
+import { AlertCircle, Check, ImageOff, Loader2, Upload } from 'lucide-react';
+import { ApiError, uploadFile } from '../../lib/apiClient';
+import type { AdminField, BrandingAsset, BrandingUploadResponse } from '../../lib/adminFields';
+import { GlassButton } from '../ui/primitives';
+
+export function BrandingImageField({
+ field,
+ asset,
+ scalePercent,
+ onUploaded,
+}: {
+ field: AdminField;
+ asset?: BrandingAsset;
+ /** Applied to the preview so the logo size control can be judged against the real image. */
+ scalePercent?: number;
+ onUploaded: (target: string, result: BrandingUploadResponse) => void;
+}) {
+ const inputRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+ const [savedAt, setSavedAt] = useState(null);
+
+ const target = field.upload_target;
+ const isFavicon = target === 'favicon';
+
+ const handleFile = async (file: File) => {
+ if (!target) {
+ return;
+ }
+ setUploading(true);
+ setError(null);
+ setSavedAt(null);
+
+ const formData = new FormData();
+ formData.append('target', target);
+ formData.append('file', file);
+
+ try {
+ const result = await uploadFile(
+ '/api/v2/admin/settings/branding-image',
+ formData,
+ );
+ onUploaded(target, result);
+ setSavedAt(Date.now());
+ } catch (uploadError) {
+ setError(
+ uploadError instanceof ApiError || uploadError instanceof Error
+ ? uploadError.message
+ : 'The upload failed.',
+ );
+ } finally {
+ setUploading(false);
+ // Clearing the input lets the same file be chosen again after a failure.
+ if (inputRef.current) {
+ inputRef.current.value = '';
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/ExternalLinksEditor.tsx b/application/v2_ui/src/components/admin/ExternalLinksEditor.tsx
new file mode 100644
index 000000000..d008dd323
--- /dev/null
+++ b/application/v2_ui/src/components/admin/ExternalLinksEditor.tsx
@@ -0,0 +1,211 @@
+// ExternalLinksEditor.tsx
+// Repeatable label/URL editor for the navigation links.
+//
+// The server-rendered form keeps this list in a hidden JSON field maintained by script.
+// Here it is ordinary state that flows into the page's draft like any other field, so the
+// list is saved by the same save bar as everything else in the section.
+//
+// Order is meaningful: navigation renders the links in array order, so rows can be moved.
+
+import { clsx } from 'clsx';
+import { ArrowDown, ArrowUp, Link2, Plus, Trash2 } from 'lucide-react';
+import { AlertCircle } from 'lucide-react';
+import type { AdminField } from '../../lib/adminFields';
+import { GlassButton } from '../ui/primitives';
+
+export interface ExternalLink {
+ label: string;
+ url: string;
+}
+
+/** Read the stored value defensively; it is administrator data from the database. */
+export function readExternalLinks(value: unknown): ExternalLink[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .filter((item): item is Record => Boolean(item) && typeof item === 'object')
+ .map((item) => ({
+ label: typeof item.label === 'string' ? item.label : '',
+ url: typeof item.url === 'string' ? item.url : '',
+ }));
+}
+
+/**
+ * Local mirror of the server's link check, so a bad row is flagged while typing rather
+ * than only when the save is rejected. The server check remains authoritative.
+ */
+function describeRowProblem(link: ExternalLink): string | null {
+ if (!link.label.trim() && !link.url.trim()) {
+ return null;
+ }
+ if (!link.label.trim()) {
+ return 'Label is required.';
+ }
+ const url = link.url.trim();
+ if (!url) {
+ return 'URL is required.';
+ }
+ if (url.startsWith('/') && !url.startsWith('//')) {
+ return null;
+ }
+ if (!/^https?:\/\/[^/]/i.test(url)) {
+ return 'Use a local path, or an http or https address.';
+ }
+ return null;
+}
+
+const inputClass = clsx(
+ 'w-full rounded-lg border border-edge bg-surface-1 px-2.5 py-1.5',
+ 'text-sm text-text-1 placeholder:text-text-3',
+ 'focus:border-accent focus:outline-none',
+);
+
+export function ExternalLinksEditor({
+ field,
+ value,
+ error,
+ onChange,
+}: {
+ field: AdminField;
+ value: unknown;
+ error?: string;
+ onChange: (next: ExternalLink[]) => void;
+}) {
+ const links = readExternalLinks(value);
+
+ const replace = (index: number, patch: Partial) => {
+ onChange(links.map((link, i) => (i === index ? { ...link, ...patch } : link)));
+ };
+
+ const move = (index: number, delta: number) => {
+ const target = index + delta;
+ if (target < 0 || target >= links.length) {
+ return;
+ }
+ const next = [...links];
+ [next[index], next[target]] = [next[target], next[index]];
+ onChange(next);
+ };
+
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/SaveBar.tsx b/application/v2_ui/src/components/admin/SaveBar.tsx
new file mode 100644
index 000000000..30124e0c2
--- /dev/null
+++ b/application/v2_ui/src/components/admin/SaveBar.tsx
@@ -0,0 +1,99 @@
+// SaveBar.tsx
+// Sticky bar summarising unsaved edits, with save and discard.
+//
+// The V2 admin surface buffers edits rather than saving each keystroke. That is not only a
+// UI preference: Terms of Use and the AI notice derive a content version from their text
+// and frequency, and every new version re-prompts every user. Saving per keystroke would
+// mint a version per character.
+//
+// The unload guard covers the other half of buffering: edits that only exist in memory
+// have to be defended when the tab is closed.
+
+import { useEffect } from 'react';
+import { clsx } from 'clsx';
+import { Loader2, RotateCcw, Save } from 'lucide-react';
+import { GlassButton } from '../ui/primitives';
+
+export function SaveBar({
+ dirtyCount,
+ saving,
+ onSave,
+ onDiscard,
+}: {
+ dirtyCount: number;
+ saving: boolean;
+ onSave: () => void;
+ onDiscard: () => void;
+}) {
+ const hasChanges = dirtyCount > 0;
+
+ useEffect(() => {
+ if (!hasChanges) {
+ return;
+ }
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ // Browsers ignore custom text now, but returnValue still triggers the prompt.
+ event.returnValue = '';
+ };
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
+ }, [hasChanges]);
+
+ // Ctrl/Cmd+S is what an administrator editing a form will reach for.
+ useEffect(() => {
+ if (!hasChanges || saving) {
+ return;
+ }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
+ event.preventDefault();
+ onSave();
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [hasChanges, saving, onSave]);
+
+ if (!hasChanges) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/fields.tsx b/application/v2_ui/src/components/admin/fields.tsx
new file mode 100644
index 000000000..36fd0668f
--- /dev/null
+++ b/application/v2_ui/src/components/admin/fields.tsx
@@ -0,0 +1,375 @@
+// fields.tsx
+// Generic controls that render one server-declared Admin Settings field.
+//
+// Every control here is driven entirely by the field definition, so describing a new
+// setting in `admin_settings_fields.py` is enough to make it appear and save. Nothing in
+// this file knows about a specific setting.
+//
+// Two conventions worth stating: edits are reported upward and buffered by the page rather
+// than saved per keystroke, and a field's own validation error is rendered beneath it so a
+// rejected save points at the control that caused it.
+
+import { clsx } from 'clsx';
+import { AlertCircle } from 'lucide-react';
+import type { ReactNode } from 'react';
+import {
+ asBoolean,
+ asNumber,
+ asString,
+ asStringArray,
+ countWords,
+ type AdminField,
+} from '../../lib/adminFields';
+import { Toggle } from '../ui/primitives';
+
+const inputClass = clsx(
+ 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2',
+ 'text-sm text-text-1 placeholder:text-text-3',
+ 'focus:border-accent focus:outline-none',
+ 'disabled:cursor-not-allowed disabled:opacity-60',
+);
+
+/** Label, help text, control and error, laid out consistently for every field type. */
+export function FieldShell({
+ field,
+ error,
+ warning,
+ htmlFor,
+ children,
+ trailing,
+}: {
+ field: AdminField;
+ error?: string;
+ warning?: string;
+ htmlFor?: string;
+ children: ReactNode;
+ trailing?: ReactNode;
+}) {
+ return (
+
+ onChange(event.target.value)}
+ />
+ {/* The hex is editable too: picking a brand colour by eye is harder than
+ pasting the value from a brand guide. */}
+ onChange(event.target.value)}
+ />
+
+
+ );
+}
+
+/**
+ * Render one declared field.
+ *
+ * `image`, `link_list` and `component` fields are handled by the page, which owns the
+ * upload endpoint and the bespoke widgets, so they are not reached here.
+ */
+export function SettingField(props: FieldControlProps) {
+ switch (props.field.type) {
+ case 'text':
+ return ;
+ case 'textarea':
+ return ;
+ case 'select':
+ return ;
+ case 'switch':
+ return ;
+ case 'color':
+ return ;
+ case 'range':
+ return ;
+ case 'number':
+ return ;
+ case 'checkbox_set':
+ return ;
+ default:
+ return null;
+ }
+}
diff --git a/application/v2_ui/src/components/admin/previews.tsx b/application/v2_ui/src/components/admin/previews.tsx
new file mode 100644
index 000000000..567a262a7
--- /dev/null
+++ b/application/v2_ui/src/components/admin/previews.tsx
@@ -0,0 +1,98 @@
+// previews.tsx
+// Live previews for the notice settings.
+//
+// Both mirror what a user will actually see, because these settings are judged visually:
+// a banner colour pair either reads clearly or it does not, and an agreement written in
+// markdown is hard to check as source. Both preview the unsaved draft, so the effect of an
+// edit is visible before it is committed.
+
+import { useState } from 'react';
+import { Eye } from 'lucide-react';
+import { AdminMarkdown } from './AdminMarkdown';
+import { AdminModal } from './AdminModal';
+import { GlassButton } from '../ui/primitives';
+
+const HEX = /^#[0-9a-fA-F]{6}$/;
+
+export function ClassificationBannerPreview({
+ text,
+ color,
+ textColor,
+}: {
+ text: string;
+ color: string;
+ textColor: string;
+}) {
+ // An invalid hex is shown as a neutral swatch rather than being passed to the style
+ // attribute, so a half-typed value cannot produce a confusing render.
+ const background = HEX.test(color) ? color : '#ffc107';
+ const foreground = HEX.test(textColor) ? textColor : '#ffffff';
+
+ return (
+
- Settings that need more than a switch — endpoints, keys,
- prompts and connection tests — remain on the{' '}
+ Settings in this group that need more than a switch —
+ endpoints, keys, prompts and connection tests — are still on
+ the{' '}
classic admin page
.
+ ) : null}
>
);
}
diff --git a/docs/explanation/features/REACT_V2_UI.md b/docs/explanation/features/REACT_V2_UI.md
index 7c5cf9c34..7b4cf3e3e 100644
--- a/docs/explanation/features/REACT_V2_UI.md
+++ b/docs/explanation/features/REACT_V2_UI.md
@@ -185,13 +185,41 @@ images are already served as static files.
Blueprint `backend_v2_admin` — `login_required`, `admin_required`.
-Returns the **raw** settings document plus the admin navigation. Admin settings are
-deliberately not sanitized: sanitization removes the keys, secrets and endpoint
-configuration that an administrator is there to manage. Access is gated on the Admin role
-at both the blueprint guard and the route decorator.
-
-`PATCH` applies a partial update, so the V2 admin surface can toggle a single capability
-without posting the entire settings form.
+Returns the **raw** settings document, the admin navigation, the field schema and a
+description of the stored branding images. Admin settings are deliberately not sanitized:
+sanitization removes the keys, secrets and endpoint configuration that an administrator is
+there to manage. Access is gated on the Admin role at both the blueprint guard and the
+route decorator.
+
+| Key | What it carries |
+| --- | --- |
+| `settings` | The raw settings document. |
+| `admin_nav` | Group → tab → section structure, from `admin_settings_nav.py`. |
+| `field_schema` | Section id → declared fields, from `admin_settings_fields.py`. |
+| `branding_assets` | Presence, version and URL of each logo and the favicon. The base64 blobs are never returned. |
+
+`PATCH` applies a partial update. Supplied values are normalized against the field schema
+before anything is written, and the update is applied only if every key validates, so a
+save never lands half-applied. A rejected save returns `400` with a `field_errors` map so
+the UI can put each message next to the control that caused it.
+
+Values that are advisory rather than invalid — an agreement longer than the recommended
+200 words, for example — are returned in a `warnings` map and still saved, matching what
+the server-rendered form does.
+
+### `POST /api/v2/admin/settings/branding-image`
+
+Blueprint `backend_v2_admin` — `login_required`, `admin_required`. Multipart.
+
+Accepts `target` (`logo`, `logo_dark` or `favicon`) and `file`. Branding images cannot ride
+along with a JSON `PATCH`, and they have to be converted before they can be stored, so they
+get their own endpoint.
+
+The conversion is `functions_branding_images.py`, shared with the server-rendered form, so
+an image uploaded through either interface is stored identically: PNG and JPEG only, logos
+capped at 500px tall, favicons squared off to a 32×32 ICO. Each successful upload bumps the
+matching version counter, because the static file keeps a stable name and browsers would
+otherwise keep serving the previous image.
## Interface
@@ -679,18 +707,92 @@ The server-rendered admin page nests 14 groups → 46 tabs → 96 sections, so f
toggle can take several clicks through two levels of tabs.
V2 flattens the same structure: a slim category rail for the 14 groups, a single scrollable
-pane of sections, and a search box that matches across every section, tab, group and
-capability key at once. Pressing `/` focuses search from anywhere on the page. Typing
-`retention` or `data lifecycle` both find the retention settings.
+pane of sections, and a search box that matches across every section, tab, group, setting
+key, label and help string at once. Pressing `/` focuses search from anywhere on the page.
+Typing `retention` or `data lifecycle` both find the retention settings.
The structure still comes from `admin_settings_nav.py`, so it cannot drift from the classic
-page. Capability keys are associated with sections by matching word stems between the key
-and the section id; anything that cannot be matched is collected under "Other capabilities"
-rather than hidden, because a silently missing toggle is worse than a misfiled one.
-
-Toggles save individually via `PATCH`, with the switch rolling back if the request fails.
-Settings that need more than a switch — endpoints, keys, prompts, connection tests — remain
-on the classic admin page, which is linked from the bottom of the V2 page.
+page.
+
+#### Where the controls come from
+
+Two sources feed the page, and which one a section uses depends on whether it has been
+described yet.
+
+**Declared fields.** `admin_settings_fields.py` maps a section id to an ordered list of
+fields, each carrying the type, label, help text, default, bounds, options and visibility
+dependency a generic renderer needs. Everything in the Appearance group is described this
+way, which is what allows V2 to show titles, colour pickers, selects, ranges, markdown
+editors, image uploads and repeatable lists rather than switches alone.
+
+**The `enable_*` fallback.** A section with no declaration is still discovered by scanning
+the settings document for booleans and matching each key to a section by shared word stems.
+Anything unmatched is collected under "Other capabilities" rather than hidden, because a
+silently missing toggle is worse than a misfiled one. This is how the whole page originally
+worked; it stays so undescribed groups keep functioning, and it retires one group at a time
+as each is described.
+
+A key that has a declared field is excluded from the fallback scan, so a setting is never
+rendered twice.
+
+#### Saving
+
+Edits buffer into a draft and save together through a single `PATCH`, from a sticky bar
+that reports how many changes are pending and offers **Save changes** or **Discard**.
+`Ctrl`/`Cmd`+`S` saves, and closing the tab with pending edits prompts first.
+
+Buffering is not only a UI preference. Terms of Use and the AI notice derive a content
+version from their text and frequency, and every new version re-prompts every user. Saving
+on each keystroke would mint a version per character.
+
+Two things save immediately instead, because they are not part of the settings document:
+branding image uploads, which need server-side conversion before anything can be previewed,
+and custom page metadata, which lives in its own Cosmos container.
+
+#### Appearance group
+
+| Tab | What V2 now renders |
+| --- | --- |
+| Branding | Application title, show-logo and hide-title switches, a 50–500% home page logo size slider, light and dark logo uploads and a favicon upload, each with a live preview |
+| Branding → Home Page Text | Markdown alignment, editor toggle, and the landing page text with a live preview that follows the chosen alignment |
+| Branding → Appearance | Dark mode and left navigation defaults |
+| Notices & Agreements | Classification banner with two colour pickers and a live preview; the AI notice text and display behaviour; the full Terms of Use configuration; the user agreement with its four apply-to targets, a word counter and a **Test preview** dialog |
+| Pages & Links | Custom pages with a restart acknowledgement, menu options and the full static page designer; external links as an add, remove and reorder editor |
+
+The static page designer writes to the same `/api/admin/custom-pages` CRUD as the classic
+designer, so a page created in either interface is identical. Python-registered pages are
+listed but not editable, because they are defined in code.
+
+#### Keeping the two interfaces in step
+
+The schema is a second description of settings the server-rendered panes also describe, so
+it could drift. `functional_tests/test_v2_admin_appearance_parity.py` reads the three
+Appearance panes, collects every form field name they submit, and fails unless each one is
+claimed by the schema — directly, through the documented `LEGACY_FIELD_NAMES` aliases where
+the two shapes differ, or through an explicit exemption. It also checks the reverse
+direction, so the schema cannot invent a setting nothing reads, and compares select option
+values and range bounds between the two.
+
+`LEGACY_FIELD_NAMES` records the places the shapes genuinely differ:
+
+| Server-rendered form field | Settings key |
+| --- | --- |
+| `user_agreement_apply_personal` / `_group` / `_public` / `_chat` | `user_agreement_apply_to` (array) |
+| `external_links_json` | `external_links` (array of `{label, url}`) |
+| `logo_file` / `logo_dark_file` / `favicon_file` | `custom_logo_base64` / `custom_logo_dark_base64` / `custom_favicon_base64` |
+| `custom_pages_restart_acknowledged` | An acknowledgement on `enable_custom_pages`, never stored |
+
+#### Adding a group
+
+Describing another group is a Python-only change: add its sections to
+`ADMIN_SETTINGS_FIELDS`, extend the parity test to cover the group's panes, and the V2 page
+renders the new controls without a front-end change. A field type the renderer does not
+implement is caught by `test_v2_admin_field_renderer_coverage.py` rather than silently
+drawing nothing.
+
+Groups that still rely on the fallback scan link to the classic admin page for the settings
+that need more than a switch. That link is now shown only for those groups, since it is
+misleading once a group is fully described.
### Workspace
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 8cdcd1625..3c2342e3c 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,39 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.039)**
+
+#### New Features
+
+* **The New Admin Settings Page Now Has The Rest Of The Appearance Settings**
+ * The new interface's admin page could only ever show on/off switches, because it worked out what to display by looking for settings that happened to be true or false. Everything else was invisible — so the whole Appearance group amounted to a handful of switches and a note telling you to go back to the classic page.
+ * Appearance is now complete. **Branding** has the application title, the home page logo size slider, and uploads for the light logo, dark logo and favicon, each showing the image you currently have. **Home Page Text** has the alignment control and the landing page editor, with a live preview that follows the alignment you pick. **Notices & Agreements** has the classification banner with both colour pickers and a preview strip, the AI notice, the full Terms of Use configuration, and the user agreement with its four apply-to options, a word counter and a **Test preview** button. **Pages & Links** has the custom pages settings, the full static page designer, the developer guide, and an external links editor you can add to, remove from and reorder.
+ * (Ref: V2 admin settings, Appearance group, `admin_settings_fields.py`)
+
+* **Changes Are Saved Together, When You Say So**
+ * Switches used to save the instant you clicked them, which does not translate to typing into a text box. Edits now collect in a bar at the bottom of the page that tells you how many changes are waiting, with **Save changes** and **Discard**. `Ctrl`/`Cmd`+`S` saves, and closing the tab with unsaved edits asks first.
+ * This also fixes something subtler: the Terms of Use and AI notice re-prompt every user whenever their wording changes. Saving on every keystroke would have re-prompted everyone once per character typed.
+ * (Ref: V2 admin settings, save bar)
+
+* **Rejected Settings Now Explain Themselves**
+ * Values are checked before anything is written, and a rejected save comes back with the reason attached to the control that caused it — a colour that is not valid hex, a link that is not an http or https address, a cancel redirect that would leave the site unsafely. Nothing is saved unless everything in the batch is valid, so a save can no longer land half-applied.
+ * The checks reuse the same code the classic page uses, so the two interfaces agree about what a valid value is.
+ * (Ref: V2 admin settings, settings validation)
+
+#### Bug Fixes
+
+* **Navigation Links Can No Longer Be Given An Unsafe Address**
+ * External navigation links saved through the new admin page are now restricted to local paths and http or https addresses. Previously any text was accepted and placed directly into the link, which allowed a `javascript:` address into the navigation bar on every page.
+ * (Ref: external links, navigation)
+
+* **Logo And Favicon Handling Now Lives In One Place**
+ * Image conversion was written into the classic settings page. Both admin pages now share one implementation, so a logo is stored identically no matter where it was uploaded from, and the PNG/JPEG-only restriction that keeps unexpected image formats away from the image library applies to every upload path.
+ * (Ref: `functions_branding_images.py`, logo and favicon uploads)
+
+* **Three Tests That Could Not Fail Correctly**
+ * The Pillow security test pinned an exact dependency version and the custom logo test looked for help text in a file it had moved out of, so both reported problems that were not real while no longer checking the thing they were written for. They now assert a minimum version and read the composed template.
+ * (Ref: functional tests, version assertions)
+
### **(v0.261.038)**
#### Bug Fixes
diff --git a/functional_tests/test_logo_upload_storage_resolution.py b/functional_tests/test_logo_upload_storage_resolution.py
index 75697203c..419b9472c 100644
--- a/functional_tests/test_logo_upload_storage_resolution.py
+++ b/functional_tests/test_logo_upload_storage_resolution.py
@@ -3,43 +3,61 @@
"""
Functional regression test for home page logo upload storage quality.
-Version: 0.241.059
+Version: 0.261.039
Implemented in: 0.241.059
This test ensures that uploaded logos are no longer reduced to 100px tall
before storage. Instead, the admin upload pipeline preserves enough
resolution for the home page logo control while capping stored height at
500px to keep settings payloads bounded.
+
+The conversion helpers moved to ``functions_branding_images.py`` in 0.261.039
+so the V2 admin surface could accept the same uploads without a second
+implementation, so the source assertions follow them there.
"""
import os
import re
import sys
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+from test_support.templates import read_admin_settings_template
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROUTE_FILE = os.path.join(REPO_ROOT, "application", "single_app", "route_frontend_admin_settings.py")
-ADMIN_TEMPLATE = os.path.join(REPO_ROOT, "application", "single_app", "templates", "admin_settings.html")
+BRANDING_IMAGES_FILE = os.path.join(
+ REPO_ROOT, "application", "single_app", "functions_branding_images.py"
+)
def test_logo_storage_helper_exists():
- """Route file should define a dedicated helper and 500px storage cap."""
- print("Testing route helper for logo storage quality...")
+ """The shared branding module should define the helper and 500px storage cap."""
+ print("Testing shared helper for logo storage quality...")
errors = []
- with open(ROUTE_FILE, encoding="utf-8") as handle:
+ with open(BRANDING_IMAGES_FILE, encoding="utf-8") as handle:
content = handle.read()
if "MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500" not in content:
- errors.append("MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500 not found in route_frontend_admin_settings.py")
+ errors.append("MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500 not found in functions_branding_images.py")
if "def prepare_logo_image_for_storage" not in content:
- errors.append("prepare_logo_image_for_storage helper not found in route_frontend_admin_settings.py")
+ errors.append("prepare_logo_image_for_storage helper not found in functions_branding_images.py")
if "img.save(img_bytes_io, format='PNG', optimize=True)" not in content:
errors.append("Logo storage helper does not save optimized PNG output")
- return _summarise(errors, "route helper existence")
+ with open(ROUTE_FILE, encoding="utf-8") as handle:
+ route_content = handle.read()
+
+ # Both admin surfaces must share one conversion, or a logo would be stored
+ # at a different size depending on where it was uploaded from.
+ if "from functions_branding_images import" not in route_content:
+ errors.append("route_frontend_admin_settings.py does not import the shared branding helpers")
+
+ return _summarise(errors, "shared helper existence")
def test_logo_upload_no_longer_forces_100px_height():
@@ -70,12 +88,15 @@ def test_logo_upload_no_longer_forces_100px_height():
def test_admin_template_documents_500px_storage_cap():
- """Admin branding UI should explain the higher-resolution storage behavior."""
+ """Admin branding UI should explain the higher-resolution storage behavior.
+
+ The branding controls live in ``templates/admin/_panes/branding.html``, so
+ the parent template has to be composed before the help text is visible.
+ """
print("\nTesting admin branding help text for logo storage cap...")
errors = []
- with open(ADMIN_TEMPLATE, encoding="utf-8") as handle:
- content = handle.read()
+ content = read_admin_settings_template()
if "stored at up to 500px tall" not in content:
errors.append("Admin settings help text does not mention the 500px logo storage cap")
diff --git a/functional_tests/test_pillow_psd_upload_hardening.py b/functional_tests/test_pillow_psd_upload_hardening.py
index e2409030c..2b42248ad 100644
--- a/functional_tests/test_pillow_psd_upload_hardening.py
+++ b/functional_tests/test_pillow_psd_upload_hardening.py
@@ -1,24 +1,44 @@
# test_pillow_psd_upload_hardening.py
"""
Functional test for Pillow PSD upload hardening.
-Version: 0.239.136
+Version: 0.261.039
Implemented in: 0.239.134
This test ensures the application pins Pillow to a patched version and limits
-admin image uploads to the PNG and JPEG formats that the route already allows.
+admin image uploads to the PNG and JPEG formats that the admin surfaces allow.
+
+The decoding allowlist moved into ``functions_branding_images.py`` in 0.261.039
+so the V2 admin surface shares it. That makes the property to assert stronger
+than before: every upload path must reach Pillow through the one helper that
+passes an explicit ``formats`` allowlist, so no route can decode a PSD by
+accepting a renamed file.
"""
import os
+import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from test_support.versioning import assert_app_version_at_least, assert_version_at_least
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REQUIREMENTS_PATH = os.path.join(ROOT_DIR, 'application', 'single_app', 'requirements.txt')
ROUTE_PATH = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_frontend_admin_settings.py')
-CONFIG_PATH = os.path.join(ROOT_DIR, 'application', 'single_app', 'config.py')
+BRANDING_IMAGES_PATH = os.path.join(
+ ROOT_DIR, 'application', 'single_app', 'functions_branding_images.py'
+)
+
+
+PILLOW_PIN_RE = re.compile(r'^pillow==([0-9.]+)\s*$', re.MULTILINE | re.IGNORECASE)
+
+# The PSD decoder advisory this test was written for was fixed in 12.1.1.
+# Asserting a floor rather than an exact pin means an ordinary dependency
+# upgrade does not fail the test, while a downgrade past the fix still does.
+MINIMUM_PILLOW_VERSION = '12.1.1'
def read_text(path):
@@ -30,46 +50,76 @@ def test_pillow_version_is_patched():
print('Testing patched Pillow dependency pin...')
content = read_text(REQUIREMENTS_PATH)
- if 'pillow==12.1.1' not in content:
- print('Patched Pillow version pin not found in requirements.txt')
+ pin_match = PILLOW_PIN_RE.search(content)
+ if not pin_match:
+ print('No pinned pillow== requirement found in requirements.txt')
+ return False
+
+ pinned_version = pin_match.group(1)
+ try:
+ assert_version_at_least(
+ pinned_version,
+ MINIMUM_PILLOW_VERSION,
+ label='pinned pillow version',
+ reason='Earlier releases carry the PSD decoder advisory.',
+ )
+ except AssertionError as exc:
+ print(f'Pillow pin check failed: {exc}')
return False
- print('Patched Pillow version pin found in requirements.txt')
+ print(f'Pillow pinned at {pinned_version}, at or beyond the patched release')
return True
def test_admin_image_uploads_allowlist_formats():
print('Testing admin image upload format allowlist...')
- content = read_text(ROUTE_PATH)
- checks = [
+ branding_content = read_text(BRANDING_IMAGES_PATH)
+ route_content = read_text(ROUTE_PATH)
+
+ branding_checks = [
"ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG')",
'Image.open(BytesIO(file_bytes), formats=list(ALLOWED_PIL_IMAGE_UPLOAD_FORMATS))',
- 'open_allowed_uploaded_image(file_bytes, logo_file.filename)',
- 'open_allowed_uploaded_image(file_bytes, logo_dark_file.filename)',
- 'open_allowed_uploaded_image(file_bytes, favicon_file.filename)'
+ # Both preparers must go through the allowlisted open, or one asset
+ # type would decode with Pillow's full decoder set.
+ 'def prepare_logo_image_for_storage',
+ 'def prepare_favicon_image_for_storage',
+ ]
+
+ route_checks = [
+ 'prepare_logo_image_for_storage(file_bytes, logo_file.filename)',
+ 'prepare_logo_image_for_storage(file_bytes, logo_dark_file.filename)',
+ 'prepare_favicon_image_for_storage(file_bytes, favicon_file.filename)',
]
- missing_checks = [check for check in checks if check not in content]
+ missing_checks = [check for check in branding_checks if check not in branding_content]
+ missing_checks += [check for check in route_checks if check not in route_content]
+
+ if branding_content.count('open_allowed_uploaded_image(file_bytes, filename)') < 2:
+ missing_checks.append(
+ 'both branding preparers calling open_allowed_uploaded_image'
+ )
+
if missing_checks:
print('Missing upload hardening checks:')
for missing_check in missing_checks:
print(f' - {missing_check}')
return False
- print('Admin image upload route restricts Pillow to PNG and JPEG parsing')
+ print('Admin image uploads restrict Pillow to PNG and JPEG parsing')
return True
def test_config_version_updated():
print('Testing config version bump...')
- content = read_text(CONFIG_PATH)
- if 'VERSION = "0.239.136"' not in content:
- print('Expected config version 0.239.136 not found')
+ try:
+ assert_app_version_at_least('0.239.136')
+ except AssertionError as exc:
+ print(f'Config version check failed: {exc}')
return False
- print('Config version updated to 0.239.136')
+ print('Config version is at or beyond the hardening release')
return True
diff --git a/functional_tests/test_support/app_stubs.py b/functional_tests/test_support/app_stubs.py
new file mode 100644
index 000000000..65f507f29
--- /dev/null
+++ b/functional_tests/test_support/app_stubs.py
@@ -0,0 +1,92 @@
+# app_stubs.py
+"""Import application modules in tests without standing up Azure clients.
+
+``config.py`` builds a Cosmos client at import time, so any module that reaches
+it transitively -- directly or through ``functions_settings`` and
+``functions_activity_logging`` -- cannot be imported by a functional test on a
+developer machine.
+
+``test_terms_of_use.py`` solved this by installing stub modules in
+``sys.modules`` before importing the module under test. This centralises that
+approach so several tests can share it, and so the stub surface is described in
+one place rather than drifting between copies.
+
+Only the seams that keep a pure-logic module from importing are stubbed. A test
+that needs real behaviour from one of these dependencies should not be using
+this helper.
+"""
+
+import importlib
+import sys
+import types
+from contextlib import contextmanager
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+APP_ROOT = REPO_ROOT / "application" / "single_app"
+
+_MISSING = object()
+
+
+def _build_stub_modules():
+ """Return the stand-in modules that break the import chain to config.py."""
+ activity_logging = types.ModuleType("functions_activity_logging")
+ activity_logging.log_terms_of_use_accepted = lambda **payload: None
+ activity_logging.log_terms_of_use_declined = lambda **payload: None
+ activity_logging.log_general_admin_action = lambda **payload: None
+ activity_logging.log_governance_change = lambda **payload: None
+ activity_logging.log_web_search_consent_acceptance = lambda **payload: None
+
+ appinsights = types.ModuleType("functions_appinsights")
+ appinsights.log_event = lambda *args, **kwargs: None
+
+ settings = types.ModuleType("functions_settings")
+ settings.get_settings = lambda: {}
+ settings.update_settings = lambda payload: True
+ settings.get_user_settings = lambda user_id: {"id": user_id, "settings": {}}
+ settings.update_user_settings = lambda user_id, payload: True
+ settings.sanitize_settings_for_user = lambda values: dict(values or {})
+
+ return {
+ "functions_activity_logging": activity_logging,
+ "functions_appinsights": appinsights,
+ "functions_settings": settings,
+ }
+
+
+@contextmanager
+def stubbed_app_imports():
+ """Temporarily install the stub modules and put the app root on sys.path."""
+ added_path = str(APP_ROOT) not in sys.path
+ if added_path:
+ sys.path.insert(0, str(APP_ROOT))
+
+ stubs = _build_stub_modules()
+ originals = {}
+ for name, module in stubs.items():
+ originals[name] = sys.modules.get(name, _MISSING)
+ sys.modules[name] = module
+
+ try:
+ yield
+ finally:
+ for name, original in originals.items():
+ if original is _MISSING:
+ sys.modules.pop(name, None)
+ else:
+ sys.modules[name] = original
+
+
+def import_app_module(module_name):
+ """Import an application module with the Azure-dependent seams stubbed.
+
+ The module is removed from ``sys.modules`` afterwards so a later import in
+ the same process, made without stubs, is not served the stubbed copy.
+ """
+ with stubbed_app_imports():
+ previously_loaded = module_name in sys.modules
+ module = importlib.import_module(module_name)
+ if not previously_loaded:
+ sys.modules.pop(module_name, None)
+ return module
diff --git a/functional_tests/test_v2_admin_appearance_parity.py b/functional_tests/test_v2_admin_appearance_parity.py
new file mode 100644
index 000000000..839ce598c
--- /dev/null
+++ b/functional_tests/test_v2_admin_appearance_parity.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+# test_v2_admin_appearance_parity.py
+"""
+Functional test pinning V1/V2 parity for the Admin Settings Appearance group.
+Version: 0.261.039
+Implemented in: 0.261.039
+
+The V2 React admin surface renders from ``admin_settings_fields.py`` rather than
+from the server-rendered panes, so the two descriptions of the same settings can
+drift apart silently: a field added to a V1 pane simply never appears in V2, and
+nothing fails.
+
+This test closes that gap for the Appearance group. It reads the three panes that
+make up the group, collects the form field names V1 submits, and requires each
+one to be claimed by the schema -- either because the schema declares the same
+key, or because ``LEGACY_FIELD_NAMES`` records the shape difference, or because
+``LEGACY_FIELDS_WITHOUT_V2_EQUIVALENT`` documents why there is no equivalent.
+
+It also checks the parts of a field that a generic renderer must get right and
+that a name-only comparison would miss: select option values, numeric bounds,
+and the section ids the schema files fields under.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import import_app_module
+from test_support.nav import ADMIN_NAV
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+PANES_DIR = REPO_ROOT / "application" / "single_app" / "templates" / "admin" / "_panes"
+
+# The three tabs that make up the Appearance group, and the sections each one
+# contributes. Sourced from ADMIN_NAV, verified against it below.
+APPEARANCE_GROUP_ID = "appearance"
+APPEARANCE_PANES = {
+ "branding": ("branding-section", "home-page-text-section", "appearance-section"),
+ "notices": (
+ "classification-banner-section",
+ "ai-notice-section",
+ "terms-of-use-section",
+ "user-agreement-section",
+ ),
+ "custom-pages": ("custom-pages-section", "external-links-section"),
+}
+
+FIELD_NAME_RE = re.compile(r'\sname="([^"]+)"')
+JINJA_RE = re.compile(r"\{\{|\{%")
+
+OPTION_RE = re.compile(r'