diff --git a/docs/modules/branding.md b/docs/modules/branding.md index a7f657b0..f5f5906b 100644 --- a/docs/modules/branding.md +++ b/docs/modules/branding.md @@ -1,8 +1,8 @@ # branding -White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, a site-wide **announcement banner**, and a **configurable footer** — from an admin page, with no code change or redeploy. +White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, and a site-wide **announcement banner** — from an admin page, with no code change or redeploy. -Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner and footer everywhere. +Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner everywhere. Footer content is owned by the framework site layouts, not branding. ## ModuleMeta @@ -27,8 +27,6 @@ Every JSON endpoint — including the reads — requires `branding.manage`; they | `GET /api/branding/` | → `BrandingOut` | | `PUT /api/branding/` | `BrandingUpdate` → `BrandingOut` | | `POST /api/branding/presets/{key}` | → `BrandingOut` (`404` for an unknown key) | -| `GET /api/branding/footer` | → `FooterConfig` | -| `PUT /api/branding/footer` | `FooterConfig` → `FooterConfig` (whole-object replace) | | `POST /api/branding/logo` | `multipart` (field `file`) → `BrandingOut` | | `POST /api/branding/logo-dark` | `multipart` (field `file`) → `BrandingOut` | | `POST /api/branding/favicon` | `multipart` (field `file`) → `BrandingOut` | @@ -36,7 +34,7 @@ Every JSON endpoint — including the reads — requires `branding.manage`; they | `DELETE /api/branding/logo-dark` | → `BrandingOut` (dark logo cleared) | | `DELETE /api/branding/favicon` | → `BrandingOut` (favicon cleared) | -`PUT /` only touches the text fields (`app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`); images are set and cleared through their dedicated upload/delete routes, and the footer through `PUT /footer`. A `design_pack` slug that no installed module registered is rejected with `422` — accepting it would put `"-root"` on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why. +`PUT /` only touches the text fields (`app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`); images are set and cleared through their dedicated upload/delete routes. A `design_pack` slug that no installed module registered is rejected with `422` — accepting it would put `"-root"` on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why. Uploads are validated **before** the bytes reach `file_storage`: an unsupported or unconvincing type returns `415`, an oversized image `413` (see [Image guard-rails](#image-guard-rails)). @@ -79,16 +77,12 @@ An unversioned URL can serve new bytes later, so it must never be immutable; the ```python from branding.contracts import BrandingOut, BrandingUpdate -from branding.contracts.footer import FooterConfig, FooterColumn, FooterLink ``` | Class | Purpose | |---|---| | `BrandingOut` | Current branding with images resolved to URLs: `app_name`, `primary_color`, `design_pack`, `logo_url`, `logo_dark_url`, `favicon_url`, `banner_message`, `banner_severity`. | | `BrandingUpdate` | Editable text fields, all optional: `app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`. | -| `FooterConfig` | The whole footer: `tagline`, `copyright_owner`, `note`, `columns`, `social_links`. | -| `FooterColumn` | A titled group: `title` + `links`. | -| `FooterLink` | One `label` + `href`. | `BrandingUpdate` is the strict one. An unknown `banner_severity` is a clear `422` here, while the settings validator normalises it to `info` — settings hydrate from the DB, where a hand-edited row must degrade to a readable banner rather than stop the app from booting. `design_pack` and `primary_color` are shape-checked in the DTO for the same reason: a malformed value becomes a `422` instead of a `500` when `BrandingSettings` re-validates. @@ -110,16 +104,9 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited | `favicon_file_id` | `""` | UUID of the favicon; `""` ⇒ no custom favicon. | | `banner_message` | `""` | Site-wide announcement text (≤ 500 chars); `""` ⇒ no banner. | | `banner_severity` | `"info"` | One of `info`, `warning`, `danger`. Unknown values normalise to `info`. | -| `footer_tagline` | `""` | Footer tagline line. | -| `footer_copyright_owner` | `""` | Name in the copyright line. | -| `footer_note` | `""` | Small footer caption. | -| `footer_columns` | `""` | JSON blob of `FooterColumn` entries (see [Configurable footer](#configurable-footer)). | -| `footer_social_links` | `""` | JSON blob of `FooterLink` entries. | `app_name` rejects control characters, not just blanks: the name is used in HTML titles and — critically — email `Subject` headers, where an embedded CR/LF would survive a bare `strip()` and then raise, breaking every transactional email. -The two footer lists are strings because the settings store holds strings; `branding.footer` owns (de)serialising them, leniently, so a malformed row degrades to "no configured footer" rather than an error. Each blob is capped at 8 000 characters so a single setting row can't grow unbounded. - ### Image guard-rails Enforced in the API before the upload reaches `file_storage`: @@ -160,23 +147,6 @@ A message plus a severity, rendered above every shell — app, public and auth Severity colours are semantic, not brand-tinted: a warning wearing the deployment's accent colour stops reading as a warning. -## Configurable footer - -Whole-object replace via `PUT /api/branding/footer` — a partial merge into nested link lists has no obvious semantics. - -| Limit | Value | -|---|---| -| Columns | 6 | -| Links per column | 8 | -| Social links | 8 | -| Link label / column title | 40 chars | -| Link URL | 500 chars | -| Tagline / copyright / note | 200 chars | - -Link URLs are restricted to `http(s)` or a single-leading-slash app path. This is the security-relevant part: these are authored by an admin and rendered into an anchor on *every* page, including guest ones, so the scheme allow-list is what keeps a `javascript:` URL out of the document. `//host` is a protocol-relative absolute URL rather than a path, so it falls through to the scheme check and is rejected. - -With nothing configured the shared prop reports `footer: null` and the framework's built-in footer renders unchanged. - ## Dark-background logo The sidebar and mobile bar sit on a near-black surface in every theme, while the sign-in card and public page are light — so a single logo cannot read on both. Uploading a *Logo (dark backgrounds)* variant swaps it in on those surfaces only. @@ -196,19 +166,12 @@ On startup the module registers a shared-props provider (`register_inertia_share "logoUrl": "/api/branding/logo?v=", "logoDarkUrl": "/api/branding/logo-dark?v=", "faviconUrl": "/api/branding/favicon?v=", - "banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" }, - "footer": { - "tagline": "Maps for everyone", - "copyrightOwner": "Acme Corp", - "note": "", - "columns": [{ "title": "Product", "links": [{ "label": "Docs", "href": "/docs" }] }], - "socialLinks": [{ "label": "GitHub", "href": "https://github.com/acme" }] - } + "banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" } } } ``` -`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar, and `footer` is `null` when nothing is configured, so the built-in framework footer stays. +`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar. The provider is defensive — it returns `{}` if branding state isn't mounted yet, so a half-booted app never errors a render. Because changes go through the settings store, a save hot-reloads `app.state.branding.settings`; the next render reflects the new values without a restart. @@ -217,7 +180,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye | Code | Granted to | Purpose | |---|---|---| | `branding.view` | `admin` | open the Branding admin page (`/branding`) | -| `branding.manage` | `admin` | read + write branding via the API (name, colour, design pack, banner, footer, image upload/clear) | +| `branding.manage` | `admin` | read + write branding via the API (name, colour, design pack, banner, image upload/clear) | ## Menu @@ -227,7 +190,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye ## Inertia pages -- `Branding/Manage.tsx` — the admin editor: app name, colour and design pack, preset picker, logo / dark logo / favicon upload and clear, banner editor, footer editor, and a live preview. +- `Branding/Manage.tsx` — the admin editor: app name, colour and design pack, preset picker, logo / dark logo / favicon upload and clear, banner editor, and a live preview. ## Locales diff --git a/docs/modules/index.md b/docs/modules/index.md index 7843de5d..46390459 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -11,7 +11,7 @@ simple_module_python ships with twelve first-party modules. Each is a regular Py | [`settings`](/modules/settings) | — | DB-backed key/value store with system / tenant / user precedence; per-module pydantic settings registration; hot reload; `smpy settings` CLI. | | [`feature_flags`](/modules/feature_flags) | — | Runtime feature toggles with system + tenant overrides. | | [`file_storage`](/modules/file_storage) | `settings` | Pluggable file storage (filesystem, S3-compatible) with upload validation, presigned URLs, browse/download/delete UI. | -| [`branding`](/modules/branding) | `settings`, `file_storage` | White-labelling — app name, logo (plus a dark-background variant), favicon, primary colour, design pack, announcement banner and configurable footer, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. | +| [`branding`](/modules/branding) | `settings`, `file_storage` | White-labelling — app name, logo (plus a dark-background variant), favicon, primary colour, design pack and announcement banner, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. | | [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. | | [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. | | [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). | diff --git a/modules/branding/README.md b/modules/branding/README.md index c90d4c42..9976089c 100644 --- a/modules/branding/README.md +++ b/modules/branding/README.md @@ -43,10 +43,9 @@ modules to be installed too. Programmatically, the current branding is available on every page through the `branding` Inertia shared prop (`appName`, `primaryColor`, `designPack`, -`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`, `footer`). `banner` and -`footer` are `null` when unconfigured, which is what makes the frontend fall -back to rendering nothing and to the framework footer respectively. For a dark -surface use +`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`). `banner` is `null` when +unconfigured, which makes the frontend render nothing. Footer content is owned +by the framework site layouts rather than branding. For a dark surface use `darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies the `logoDarkUrl → logoUrl` fallback in one place. @@ -78,12 +77,6 @@ the `logoDarkUrl → logoUrl` fallback in one place. ever sets *appearance* (`PRESET_FIELDS` — primary colour, design pack); it can never overwrite the app name, an uploaded logo or a live banner, and `BrandingPreset` rejects any other field at construction. -- **Configurable footer.** Tagline, copyright owner, caption, up to 6 columns - of 8 links, and up to 8 social links (`PUT /api/branding/footer`, whole-object - replace). Link URLs are restricted to http(s) or a single-leading-slash app - path — `javascript:` and `data:` are refused, and `//host` is treated as the - off-site absolute URL it is rather than a path. With nothing configured the - framework's built-in footer renders unchanged. - **Dark-background logo.** The sidebar and mobile bar sit on a near-black surface in every theme, while the sign-in card and public page are light — so a single logo cannot read on both. Uploading a *Logo (dark backgrounds)* diff --git a/modules/branding/branding/components/FooterCard.tsx b/modules/branding/branding/components/FooterCard.tsx deleted file mode 100644 index 1be7c1c8..00000000 --- a/modules/branding/branding/components/FooterCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { keys, useT } from '@simple-module-py/i18n'; -import { Button } from '@simple-module-py/ui/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; -import type { FooterShared } from '@simple-module-py/ui/types'; -import { useState } from 'react'; -import { EMPTY_FOOTER, type FooterDraft, FooterEditor } from './FooterEditor'; -import { newRowId, stripIds } from './LinkRows'; - -/** Payload shape of `PUT /api/branding/footer` (snake_case, like the DTO). */ -export interface FooterPayload { - tagline: string; - copyright_owner: string; - note: string; - columns: { title: string; links: { label: string; href: string }[] }[]; - social_links: { label: string; href: string }[]; -} - -interface FooterCardProps { - /** Current server-side footer, or null when none is configured. */ - initial: FooterShared | null; - disabled: boolean; - busy: boolean; - onSave: (payload: FooterPayload) => void; -} - -/** Rows get a client-only id so React keys stay stable across add/remove. */ -function toDraft(footer: FooterShared | null): FooterDraft { - if (!footer) return EMPTY_FOOTER; - return { - tagline: footer.tagline, - copyrightOwner: footer.copyrightOwner, - note: footer.note, - columns: footer.columns.map((c) => ({ - id: newRowId(), - title: c.title, - links: c.links.map((l) => ({ id: newRowId(), ...l })), - })), - socialLinks: footer.socialLinks.map((l) => ({ id: newRowId(), ...l })), - }; -} - -/** - * The footer section of the branding page — its own card, its own draft state - * and its own save, because a footer edit replaces the whole structure and is - * independent of the identity fields above it. - */ -export function FooterCard({ initial, disabled, busy, onSave }: FooterCardProps) { - const { t } = useT(); - const [draft, setDraft] = useState(() => toDraft(initial)); - - const save = () => - onSave({ - tagline: draft.tagline, - copyright_owner: draft.copyrightOwner, - note: draft.note, - columns: stripIds(draft.columns).map((c) => ({ - title: c.title, - links: stripIds(c.links), - })), - social_links: stripIds(draft.socialLinks), - }); - - return ( - - - {t(keys.branding.manage.footer_title)} - - - - - - - ); -} diff --git a/modules/branding/branding/components/FooterEditor.tsx b/modules/branding/branding/components/FooterEditor.tsx deleted file mode 100644 index 7e98d8d2..00000000 --- a/modules/branding/branding/components/FooterEditor.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { keys, useT } from '@simple-module-py/i18n'; -import { Button } from '@simple-module-py/ui/components/ui/button'; -import { Input } from '@simple-module-py/ui/components/ui/input'; -import { Label } from '@simple-module-py/ui/components/ui/label'; -import { type EditableLink, LinkRows, newRowId } from './LinkRows'; - -/** Mirror the server limits in `branding/footer.py`. */ -export const MAX_COLUMNS = 6; -export const MAX_LINKS_PER_COLUMN = 8; -export const MAX_SOCIAL_LINKS = 8; - -export interface EditableColumn { - /** Client-only key; see LinkRows. Stripped before sending. */ - id: string; - title: string; - links: EditableLink[]; -} - -export interface FooterDraft { - tagline: string; - copyrightOwner: string; - note: string; - columns: EditableColumn[]; - socialLinks: EditableLink[]; -} - -export const EMPTY_FOOTER: FooterDraft = { - tagline: '', - copyrightOwner: '', - note: '', - columns: [], - socialLinks: [], -}; - -interface FooterEditorProps { - value: FooterDraft; - onChange: (next: FooterDraft) => void; - disabled: boolean; -} - -/** Multi-column footer builder: brand text, link columns and a social row. */ -export function FooterEditor({ value, onChange, disabled }: FooterEditorProps) { - const { t } = useT(); - const patch = (next: Partial) => onChange({ ...value, ...next }); - - const updateColumn = (id: string, next: Partial) => - patch({ columns: value.columns.map((c) => (c.id === id ? { ...c, ...next } : c)) }); - - return ( -
-
-
- - patch({ tagline: e.target.value })} - /> -
-
- - patch({ copyrightOwner: e.target.value })} - /> -
-
- - patch({ note: e.target.value })} - /> -
-
- -
- {value.columns.map((column, index) => ( -
-
- updateColumn(column.id, { title: e.target.value })} - className="w-56 font-medium" - /> - -
- updateColumn(column.id, { links })} - /> -
- ))} - {value.columns.length < MAX_COLUMNS && ( - - )} -
- -
- - patch({ socialLinks })} - /> -
- -

{t(keys.branding.manage.footer_help)}

-
- ); -} diff --git a/modules/branding/branding/components/LinkRows.tsx b/modules/branding/branding/components/LinkRows.tsx deleted file mode 100644 index 796f58f5..00000000 --- a/modules/branding/branding/components/LinkRows.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { keys, useT } from '@simple-module-py/i18n'; -import { Button } from '@simple-module-py/ui/components/ui/button'; -import { Input } from '@simple-module-py/ui/components/ui/input'; - -/** - * Rows carry a client-only `id`. Keying on the array index instead would make - * React reuse the wrong DOM node when a middle row is removed — the inputs - * below the gap keep the deleted row's text. The id never leaves the browser; - * `stripIds` drops it before the payload is sent. - */ -export interface EditableLink { - id: string; - label: string; - href: string; -} - -let nextRowId = 0; - -/** Stable key for a newly added row. Not persisted, so a counter is enough. */ -export function newRowId(): string { - nextRowId += 1; - return `row-${nextRowId}`; -} - -export function stripIds(rows: T[]): Omit[] { - return rows.map(({ id: _id, ...rest }) => rest); -} - -interface LinkRowsProps { - links: EditableLink[]; - max: number; - disabled: boolean; - onChange: (next: EditableLink[]) => void; -} - -/** - * Repeating label + URL rows with add/remove. Shared by the column editor and - * the social row, which differ only in where their list is stored. - * - * No drag-reorder: order is edit order, and the server stores the array as - * given. Adding one would need a DnD dependency for a list capped at 8. - */ -export function LinkRows({ links, max, disabled, onChange }: LinkRowsProps) { - const { t } = useT(); - - const update = (id: string, patch: Partial) => - onChange(links.map((link) => (link.id === id ? { ...link, ...patch } : link))); - - return ( -
- {links.map((link, index) => ( -
- update(link.id, { label: e.target.value })} - className="w-40" - /> - update(link.id, { href: e.target.value })} - className="min-w-52 flex-1 font-mono text-xs" - /> - -
- ))} - {links.length < max && ( - - )} -
- ); -} diff --git a/modules/branding/branding/constants.py b/modules/branding/branding/constants.py index aa9021d1..5a7f66b1 100644 --- a/modules/branding/branding/constants.py +++ b/modules/branding/branding/constants.py @@ -81,7 +81,6 @@ def clean_banner_message(value: str) -> str: # this is not a way to read arbitrary files out of file_storage. #: One-click look. ``{key}`` names a preset from ``branding.presets``. PATH_PRESET: Final = "/presets/{key}" -PATH_FOOTER: Final = "/footer" PATH_LOGO: Final = "/logo" PATH_LOGO_DARK: Final = "/logo-dark" diff --git a/modules/branding/branding/contracts/footer.py b/modules/branding/branding/contracts/footer.py deleted file mode 100644 index 8dc27689..00000000 --- a/modules/branding/branding/contracts/footer.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Footer DTOs — the public surface for the configurable site footer. - -Mirrors IIASA.GeoWiki's ``FooterDto`` / ``FooterColumnDto`` / ``FooterLinkDto`` -/ ``FooterSocialLinkDto``. A footer update replaces the whole structure, as it -does there — partial merges into nested lists have no obvious semantics. -""" - -from __future__ import annotations - -from pydantic import field_validator -from sqlmodel import SQLModel - -from branding.footer import ( - MAX_COLUMNS, - MAX_LINKS_PER_COLUMN, - MAX_SOCIAL_LINKS, - clean_label, - clean_text, - validate_href, -) - - -class FooterLink(SQLModel): - """One labelled link, in a column or in the social row.""" - - label: str - href: str - - @field_validator("label") - @classmethod - def _label(cls, value: str) -> str: - return clean_label(value) - - @field_validator("href") - @classmethod - def _href(cls, value: str) -> str: - # Admin-authored and rendered into an anchor on every page, so the - # scheme allow-list here is what keeps `javascript:` out. - return validate_href(value) - - -class FooterColumn(SQLModel): - """A titled group of links.""" - - title: str - links: list[FooterLink] = [] - - @field_validator("title") - @classmethod - def _title(cls, value: str) -> str: - return clean_label(value, what="title") - - @field_validator("links") - @classmethod - def _bounded(cls, value: list[FooterLink]) -> list[FooterLink]: - if len(value) > MAX_LINKS_PER_COLUMN: - raise ValueError(f"A column can have at most {MAX_LINKS_PER_COLUMN} links") - return value - - -class FooterConfig(SQLModel): - """The whole configurable footer.""" - - tagline: str = "" - copyright_owner: str = "" - note: str = "" - columns: list[FooterColumn] = [] - social_links: list[FooterLink] = [] - - @field_validator("tagline", "copyright_owner", "note") - @classmethod - def _text(cls, value: str) -> str: - return clean_text(value) - - @field_validator("columns") - @classmethod - def _bounded_columns(cls, value: list[FooterColumn]) -> list[FooterColumn]: - if len(value) > MAX_COLUMNS: - raise ValueError(f"A footer can have at most {MAX_COLUMNS} columns") - return value - - @field_validator("social_links") - @classmethod - def _bounded_social(cls, value: list[FooterLink]) -> list[FooterLink]: - if len(value) > MAX_SOCIAL_LINKS: - raise ValueError(f"A footer can have at most {MAX_SOCIAL_LINKS} social links") - return value diff --git a/modules/branding/branding/endpoints/api.py b/modules/branding/branding/endpoints/api.py index 1b910639..a7458b99 100644 --- a/modules/branding/branding/endpoints/api.py +++ b/modules/branding/branding/endpoints/api.py @@ -8,7 +8,6 @@ from simple_module_hosting.permissions import RequiresPermission from branding import constants -from branding.contracts.footer import FooterConfig from branding.contracts.schemas import BrandingOut, BrandingUpdate from branding.deps import BrandingServiceDep from branding.images import validate_image @@ -68,18 +67,6 @@ async def upload_logo( return await service.set_logo(str(stored.id)) -@router.get(constants.PATH_FOOTER, response_model=FooterConfig, dependencies=[_MANAGE]) -async def get_footer(service: BrandingServiceDep) -> FooterConfig: - return service.current_footer() - - -@router.put(constants.PATH_FOOTER, response_model=FooterConfig, dependencies=[_MANAGE]) -async def update_footer(data: FooterConfig, service: BrandingServiceDep) -> FooterConfig: - # Whole-object replace, as in the reference — a partial merge into nested - # link lists has no obvious semantics. - return await service.set_footer(data) - - @router.post(constants.PATH_PRESET, response_model=BrandingOut, dependencies=[_MANAGE]) async def apply_preset(request: Request, key: str, service: BrandingServiceDep) -> BrandingOut: """Apply a named look, leaving app name, images and banner untouched.""" diff --git a/modules/branding/branding/footer.py b/modules/branding/branding/footer.py deleted file mode 100644 index 41e5d3bc..00000000 --- a/modules/branding/branding/footer.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Configurable footer — limits, link validation and settings (de)serialisation. - -Ported from IIASA.GeoWiki's ``FooterAppService``. The footer is stored as two -JSON blobs in the shared settings store (there is no branding table), so this -module owns turning them into structures and back. - -``validate_href`` is the security-relevant part: these URLs are authored by an -admin and rendered into an anchor on *every* page, including guest ones. Only -http(s) and single-leading-slash app paths are allowed, which is what keeps a -``javascript:`` URL out of the document. -""" - -from __future__ import annotations - -import json -from typing import Any, Final -from urllib.parse import urlparse - -MAX_COLUMNS: Final = 6 -MAX_LINKS_PER_COLUMN: Final = 8 -MAX_SOCIAL_LINKS: Final = 8 -MAX_LABEL_LEN: Final = 40 -MAX_HREF_LEN: Final = 500 -MAX_TEXT_LEN: Final = 200 -#: Ceiling on either serialised blob, so one setting row can't grow unbounded. -MAX_SERIALISED_LEN: Final = 8_000 - -_ALLOWED_SCHEMES: Final = frozenset({"http", "https"}) - -HREF_ERROR: Final = "is not a valid link. Use a relative path (/page/...) or an http(s) URL." - - -def validate_href(href: str) -> str: - """Return a trimmed, safe href or raise ``ValueError``. - - Rejects every scheme but http(s) — notably ``javascript:``, which would - otherwise execute from a link an admin pasted. A single leading slash is an - in-app path and allowed; ``//host`` is *not* a path but a protocol-relative - absolute URL, so it falls through to the scheme check and is rejected. - """ - cleaned = href.strip() - if not cleaned: - raise ValueError("A link URL is required.") - if len(cleaned) > MAX_HREF_LEN: - raise ValueError(f"A link URL must be at most {MAX_HREF_LEN} characters") - - if cleaned.startswith("/") and not cleaned.startswith("//"): - return cleaned - - parsed = urlparse(cleaned) - if parsed.scheme in _ALLOWED_SCHEMES and parsed.netloc: - return cleaned - - raise ValueError(f"{cleaned!r} {HREF_ERROR}") - - -def clean_label(value: str, *, what: str = "label") -> str: - """Trim + bound a user-visible label.""" - cleaned = value.strip() - if not cleaned: - raise ValueError(f"A link {what} is required.") - if len(cleaned) > MAX_LABEL_LEN: - raise ValueError(f"A link {what} must be at most {MAX_LABEL_LEN} characters") - return cleaned - - -def clean_text(value: str) -> str: - """Trim + bound a free-text footer line (tagline, copyright, note).""" - cleaned = value.strip() - if len(cleaned) > MAX_TEXT_LEN: - raise ValueError(f"Footer text must be at most {MAX_TEXT_LEN} characters") - return cleaned - - -def dumps(items: list[dict[str, Any]]) -> str: - """Serialise a footer structure for the settings store, bounded in size.""" - payload = json.dumps(items, separators=(",", ":")) - if len(payload) > MAX_SERIALISED_LEN: - raise ValueError("Footer configuration is too large. Please remove some links.") - return payload - - -def loads(raw: str) -> list[dict[str, Any]]: - """Parse a stored footer blob, tolerating anything unusable. - - Deliberately lenient: settings hydrate from the DB, where a hand-edited or - truncated row must degrade to "no configured footer" rather than break - every page render. - """ - if not raw.strip(): - return [] - try: - parsed = json.loads(raw) - except (ValueError, TypeError): - return [] - if not isinstance(parsed, list): - return [] - return [item for item in parsed if isinstance(item, dict)] diff --git a/modules/branding/branding/locales/en.json b/modules/branding/branding/locales/en.json index 2fab0c2d..80e19ce5 100644 --- a/modules/branding/branding/locales/en.json +++ b/modules/branding/branding/locales/en.json @@ -12,21 +12,6 @@ "design_pack_empty": "No installed module provides a design pack.", "logo_label": "Logo", "logo_help": "Square PNG or WEBP works best, up to 2 MB. Replaces the default badge in the sidebar.", - "footer_title": "Footer", - "footer_help": "Links must be an in-app path (/pricing) or an http(s) URL. Leave everything empty to keep the default footer.", - "footer_tagline_label": "Tagline", - "footer_copyright_label": "Copyright owner", - "footer_note_label": "Caption", - "footer_column_title": "Column title", - "footer_add_column": "Add column", - "footer_remove_column": "Remove column", - "footer_link_label": "Label", - "footer_link_href": "/path or https://…", - "footer_add_link": "Add link", - "footer_remove_link": "Remove", - "footer_social_label": "Social links", - "footer_save_button": "Save footer", - "footer_saved_toast": "Footer updated", "preset_label": "Presets", "preset_help": "Applies a ready-made colour scheme straight away. Your app name, images and banner are left alone.", "banner_label": "Announcement banner", diff --git a/modules/branding/branding/pages/Manage.tsx b/modules/branding/branding/pages/Manage.tsx index 616c2b34..1a98439f 100644 --- a/modules/branding/branding/pages/Manage.tsx +++ b/modules/branding/branding/pages/Manage.tsx @@ -18,7 +18,6 @@ import { toast } from 'sonner'; import { BannerField, type BannerSeverity } from '../components/BannerField'; import { BrandingPreview } from '../components/BrandingPreview'; import { DesignPackField, type DesignPackOption } from '../components/DesignPackField'; -import { FooterCard, type FooterPayload } from '../components/FooterCard'; import { ImageField } from '../components/ImageField'; import { PresetField, type PresetOption } from '../components/PresetField'; @@ -117,17 +116,6 @@ function Manage() { ); }; - const saveFooter = (payload: FooterPayload) => - run( - () => - fetch('/api/branding/footer', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }), - t(keys.branding.manage.error_toast), - ); - const applyPreset = (key: string) => run( () => fetch(`/api/branding/presets/${key}`, { method: 'POST' }), @@ -258,13 +246,6 @@ function Manage() { - - BrandingOut: await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes) return self.current() - def current_footer(self) -> FooterConfig: - """The stored footer, parsed. Unusable JSON reads as an empty footer.""" - from branding.footer import loads - - s = self.app.state.branding.settings - return FooterConfig( - tagline=s.footer_tagline, - copyright_owner=s.footer_copyright_owner, - note=s.footer_note, - columns=loads(s.footer_columns), - social_links=loads(s.footer_social_links), - ) - - async def set_footer(self, config: FooterConfig) -> FooterConfig: - """Replace the whole footer (matching the reference's update semantics).""" - from branding.footer import dumps - - await self.apply( - { - "footer_tagline": config.tagline, - "footer_copyright_owner": config.copyright_owner, - "footer_note": config.note, - "footer_columns": dumps([c.model_dump() for c in config.columns]), - "footer_social_links": dumps([link.model_dump() for link in config.social_links]), - } - ) - return self.current_footer() - async def _swap_asset(self, field: str, file_id: str) -> BrandingOut: """Point *field* at *file_id* ("" to clear) and reap what it replaced. diff --git a/modules/branding/branding/settings.py b/modules/branding/branding/settings.py index bca271d5..92eeb53f 100644 --- a/modules/branding/branding/settings.py +++ b/modules/branding/branding/settings.py @@ -43,14 +43,6 @@ class BrandingSettings(BaseSettings): design_pack: str = "" # "" = base tokens only; otherwise a registered slug banner_message: str = "" # "" = no site-wide banner banner_severity: str = BANNER_SEVERITY_INFO - # Configurable footer. The two lists are JSON blobs because the shared - # settings store holds strings; branding.footer owns (de)serialising them, - # leniently, so a malformed row degrades to "no configured footer". - footer_tagline: str = "" - footer_copyright_owner: str = "" - footer_note: str = "" - footer_columns: str = "" - footer_social_links: str = "" @field_validator("app_name") @classmethod diff --git a/modules/branding/branding/shared_props.py b/modules/branding/branding/shared_props.py index 3e5234f5..bafd0393 100644 --- a/modules/branding/branding/shared_props.py +++ b/modules/branding/branding/shared_props.py @@ -33,27 +33,6 @@ def asset_url(base: str, file_id: str) -> str | None: return f"{base}?{ASSET_VERSION_QUERY_KEY}={file_id}" if file_id else None -def footer_payload(settings: BrandingSettings) -> dict | None: - """The camelCase footer block, or ``None`` when nothing is configured. - - ``None`` lets the frontend keep its built-in framework footer, so a - deployment that never touches this looks exactly as it did. - """ - from branding.footer import loads - - columns = loads(settings.footer_columns) - social = loads(settings.footer_social_links) - if not (columns or social or settings.footer_tagline or settings.footer_copyright_owner): - return None - return { - "tagline": settings.footer_tagline, - "copyrightOwner": settings.footer_copyright_owner, - "note": settings.footer_note, - "columns": columns, - "socialLinks": social, - } - - def branding_payload(settings: BrandingSettings) -> dict: """The camelCase branding block shared with the frontend.""" return { @@ -72,7 +51,6 @@ def branding_payload(settings: BrandingSettings) -> dict: if settings.banner_message else None ), - "footer": footer_payload(settings), } diff --git a/modules/branding/tests/test_branding.py b/modules/branding/tests/test_branding.py index d325e170..b26f5c5a 100644 --- a/modules/branding/tests/test_branding.py +++ b/modules/branding/tests/test_branding.py @@ -61,8 +61,6 @@ def test_branding_payload_unset() -> None: "faviconUrl": None, # None, not an empty dict — no message means render no bar at all. "banner": None, - # None keeps the framework's built-in footer. - "footer": None, } @@ -106,6 +104,14 @@ async def test_get_branding_returns_defaults(authenticated_client: httpx.AsyncCl assert body["logo_url"] is None +@pytest.mark.parametrize("method", ["GET", "PUT"]) +async def test_footer_routes_are_not_part_of_branding( + authenticated_client: httpx.AsyncClient, method: str +) -> None: + response = await authenticated_client.request(method, "/api/branding/footer", json={}) + assert response.status_code == 404 + + async def test_update_persists_and_hot_swaps(app, authenticated_client: httpx.AsyncClient) -> None: resp = await authenticated_client.put( "/api/branding/", diff --git a/modules/branding/tests/test_footer.py b/modules/branding/tests/test_footer.py deleted file mode 100644 index 9c28b386..00000000 --- a/modules/branding/tests/test_footer.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Configurable multi-column footer. - -Ported from IIASA.GeoWiki's ``FooterAppService`` — same shape (brand text, -columns of links, a social row), same limits, and the same ``ValidateHref`` -allow-list. That last one is the security-relevant part: these URLs are -authored by an admin and rendered into an anchor on every page, guest pages -included, so anything but http(s) or an in-app path has to be refused. -""" - -from __future__ import annotations - -import httpx -import pytest -from branding.contracts.footer import FooterColumn, FooterConfig, FooterLink -from branding.footer import ( - MAX_COLUMNS, - MAX_LINKS_PER_COLUMN, - MAX_SERIALISED_LEN, - MAX_SOCIAL_LINKS, - dumps, - loads, - validate_href, -) -from branding.settings import BrandingSettings -from branding.shared_props import footer_payload - -# ── Unit: href allow-list ────────────────────────────────────────────── - - -@pytest.mark.parametrize( - "href", - ["/pricing", "/", "https://example.com", "http://example.com/x?y=1#z"], -) -def test_accepts_app_paths_and_http_urls(href: str) -> None: - assert validate_href(href) == href - - -@pytest.mark.parametrize( - "href", - [ - "javascript:alert(1)", - "JavaScript:alert(1)", - " javascript:alert(1) ", - "data:text/html;base64,PHNjcmlwdD4=", - "vbscript:msgbox(1)", - "file:///etc/passwd", - ], -) -def test_rejects_every_scheme_that_is_not_http(href: str) -> None: - # A javascript: href in the footer would execute on every page load. - with pytest.raises(ValueError): - validate_href(href) - - -def test_rejects_a_protocol_relative_url() -> None: - # "//evil.com" reads like a path but is an absolute URL to another origin. - with pytest.raises(ValueError): - validate_href("//evil.com/phish") - - -def test_rejects_an_empty_href() -> None: - with pytest.raises(ValueError): - validate_href(" ") - - -def test_trims_surrounding_whitespace() -> None: - assert validate_href(" /pricing ") == "/pricing" - - -# ── Unit: (de)serialisation is lenient on read, bounded on write ─────── - - -@pytest.mark.parametrize("raw", ["", " ", "not json", "{}", "null", '["a", 1]']) -def test_unusable_stored_json_reads_as_an_empty_footer(raw: str) -> None: - # Settings hydrate from the DB; a mangled row must not break every render. - assert loads(raw) == [] - - -def test_round_trips_a_structure() -> None: - items = [{"title": "Product", "links": [{"label": "Pricing", "href": "/pricing"}]}] - assert loads(dumps(items)) == items - - -def test_refuses_to_store_an_oversized_structure() -> None: - with pytest.raises(ValueError, match="too large"): - dumps([{"label": "x" * MAX_SERIALISED_LEN, "href": "/x"}]) - - -# ── Unit: DTO limits ─────────────────────────────────────────────────── - - -def test_rejects_too_many_columns() -> None: - columns = [FooterColumn(title=f"C{i}", links=[]) for i in range(MAX_COLUMNS + 1)] - with pytest.raises(ValueError): - FooterConfig(columns=columns) - - -def test_rejects_too_many_links_in_one_column() -> None: - links = [FooterLink(label=f"L{i}", href="/x") for i in range(MAX_LINKS_PER_COLUMN + 1)] - with pytest.raises(ValueError): - FooterColumn(title="Too many", links=links) - - -def test_rejects_too_many_social_links() -> None: - links = [FooterLink(label=f"S{i}", href="/x") for i in range(MAX_SOCIAL_LINKS + 1)] - with pytest.raises(ValueError): - FooterConfig(social_links=links) - - -def test_rejects_a_blank_label() -> None: - with pytest.raises(ValueError): - FooterLink(label=" ", href="/x") - - -# ── Unit: shared-props payload ───────────────────────────────────────── - - -def test_payload_is_none_when_nothing_is_configured() -> None: - # None keeps the framework's built-in footer, so untouched sites are as-is. - assert footer_payload(BrandingSettings()) is None - - -def test_payload_appears_once_a_column_exists() -> None: - settings = BrandingSettings( - footer_columns=dumps([{"title": "Product", "links": [{"label": "P", "href": "/p"}]}]) - ) - payload = footer_payload(settings) - assert payload is not None - assert payload["columns"][0]["title"] == "Product" - - -# ── Integration ──────────────────────────────────────────────────────── - - -_CONFIG = { - "tagline": "Maps for everyone", - "copyright_owner": "Acme Corp", - "note": "Built on SimpleModule", - "columns": [ - {"title": "Product", "links": [{"label": "Pricing", "href": "/pricing"}]}, - {"title": "Company", "links": [{"label": "About", "href": "https://acme.test/about"}]}, - ], - "social_links": [{"label": "GitHub", "href": "https://github.com/acme"}], -} - - -async def test_saving_a_footer_persists_and_reads_back( - app, authenticated_client: httpx.AsyncClient -) -> None: - resp = await authenticated_client.put("/api/branding/footer", json=_CONFIG) - - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["copyright_owner"] == "Acme Corp" - assert [c["title"] for c in body["columns"]] == ["Product", "Company"] - - again = await authenticated_client.get("/api/branding/footer") - assert again.json()["columns"][0]["links"][0]["href"] == "/pricing" - - -async def test_a_saved_footer_reaches_the_shared_props( - app, authenticated_client: httpx.AsyncClient -) -> None: - await authenticated_client.put("/api/branding/footer", json=_CONFIG) - - from branding.shared_props import branding_payload - - payload = branding_payload(app.state.branding.settings) - assert payload["footer"]["socialLinks"][0]["label"] == "GitHub" - - -async def test_the_api_rejects_a_javascript_link( - authenticated_client: httpx.AsyncClient, -) -> None: - bad = { - **_CONFIG, - "columns": [ - {"title": "Evil", "links": [{"label": "Click", "href": "javascript:alert(1)"}]} - ], - } - - resp = await authenticated_client.put("/api/branding/footer", json=bad) - - assert resp.status_code == 422, resp.text - - -async def test_saving_a_footer_replaces_rather_than_merges( - authenticated_client: httpx.AsyncClient, -) -> None: - await authenticated_client.put("/api/branding/footer", json=_CONFIG) - - resp = await authenticated_client.put( - "/api/branding/footer", - json={ - "tagline": "", - "copyright_owner": "Acme", - "note": "", - "columns": [], - "social_links": [], - }, - ) - - assert resp.status_code == 200, resp.text - assert resp.json()["columns"] == [] - - -async def test_the_footer_requires_the_manage_permission(client: httpx.AsyncClient) -> None: - assert (await client.get("/api/branding/footer")).status_code in (401, 403) - assert (await client.put("/api/branding/footer", json=_CONFIG)).status_code in (401, 403) - - -async def test_the_footer_reaches_a_logged_out_visitor( - client: httpx.AsyncClient, authenticated_client: httpx.AsyncClient -) -> None: - # The public marketing page renders it, and that page is guest-facing. - await authenticated_client.put("/api/branding/footer", json=_CONFIG) - - page = await client.get("/users/login", follow_redirects=False) - - assert page.status_code == 200, page.status_code - assert "Maps for everyone" in page.text diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 859a03f8..1af10b43 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -117,21 +117,6 @@ export default { 'branding.manage.error_toast': '', 'branding.manage.favicon_help': '', 'branding.manage.favicon_label': '', - 'branding.manage.footer_add_column': '', - 'branding.manage.footer_add_link': '', - 'branding.manage.footer_column_title': '', - 'branding.manage.footer_copyright_label': '', - 'branding.manage.footer_help': '', - 'branding.manage.footer_link_href': '', - 'branding.manage.footer_link_label': '', - 'branding.manage.footer_note_label': '', - 'branding.manage.footer_remove_column': '', - 'branding.manage.footer_remove_link': '', - 'branding.manage.footer_save_button': '', - 'branding.manage.footer_saved_toast': '', - 'branding.manage.footer_social_label': '', - 'branding.manage.footer_tagline_label': '', - 'branding.manage.footer_title': '', 'branding.manage.logo_dark_help': '', 'branding.manage.logo_dark_label': '', 'branding.manage.logo_help': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index c445fd34..a47d9412 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -160,21 +160,6 @@ export const keys = { error_toast: 'branding.manage.error_toast', favicon_help: 'branding.manage.favicon_help', favicon_label: 'branding.manage.favicon_label', - footer_add_column: 'branding.manage.footer_add_column', - footer_add_link: 'branding.manage.footer_add_link', - footer_column_title: 'branding.manage.footer_column_title', - footer_copyright_label: 'branding.manage.footer_copyright_label', - footer_help: 'branding.manage.footer_help', - footer_link_href: 'branding.manage.footer_link_href', - footer_link_label: 'branding.manage.footer_link_label', - footer_note_label: 'branding.manage.footer_note_label', - footer_remove_column: 'branding.manage.footer_remove_column', - footer_remove_link: 'branding.manage.footer_remove_link', - footer_save_button: 'branding.manage.footer_save_button', - footer_saved_toast: 'branding.manage.footer_saved_toast', - footer_social_label: 'branding.manage.footer_social_label', - footer_tagline_label: 'branding.manage.footer_tagline_label', - footer_title: 'branding.manage.footer_title', logo_dark_help: 'branding.manage.logo_dark_help', logo_dark_label: 'branding.manage.logo_dark_label', logo_help: 'branding.manage.logo_help', diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx index 1e17d0c0..703b8a12 100644 --- a/packages/ui/src/components/BrandingFooter.tsx +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -1,5 +1,4 @@ import { BRAND_ACCENT, BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; -import type { FooterShared } from '../types'; import { BrandingMark } from './BrandingMark'; /** Stable for the lifetime of the bundle — the year only matters at page load. */ @@ -15,110 +14,42 @@ interface BrandingFooterProps { * the full content width of the sidebar shell. */ variant?: 'app' | 'public'; - /** - * Admin-configured footer. When absent the framework footer below is used - * unchanged, so a deployment that never configures one is unaffected. - */ - footer?: FooterShared | null; } -function FooterLinkAnchor({ label, href }: { label: string; href: string }) { - // Server-side `validate_href` restricts these to http(s) and in-app paths. - // `noopener` still matters: an external target must not get window.opener. - const external = !href.startsWith('/'); - return ( - - {label} - - ); -} - -/** - * App-wide footer: brand lockup on the left, links on the right. - * - * Renders one of two shapes. With no configured footer it keeps the framework's - * single row of project links. Once an admin configures columns or social - * links it becomes a multi-column footer with the brand block above a bottom - * bar. Presentational (props-driven) so it renders without Inertia context and - * is shared by both the authenticated shell and the public layout. - */ +/** Framework-owned footer shared by the authenticated and public layouts. */ export function BrandingFooter({ appName, logoUrl, variant = 'app', - footer = null, }: BrandingFooterProps): React.ReactElement { const container = variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; - const configured = footer && (footer.columns.length > 0 || footer.socialLinks.length > 0); - const caption = footer?.note || `© ${FOOTER_YEAR} · ${BRAND_LICENSE}`; - const mark = ( - - ); - - if (!configured) { - return ( -
-
-
{mark}
- -
-
- ); - } return (
-
-
-
-
{mark}
- {footer?.tagline && ( -

{footer.tagline}

- )} -
-
- {footer?.columns.map((column) => ( - - ))} -
+
+
+
- {(footer?.copyrightOwner || (footer?.socialLinks.length ?? 0) > 0) && ( -
- {footer?.copyrightOwner ? `© ${FOOTER_YEAR} ${footer.copyrightOwner}` : ''} - -
- )} +
); diff --git a/packages/ui/src/layouts/PublicLayout.tsx b/packages/ui/src/layouts/PublicLayout.tsx index b05bdb34..d5aa51de 100644 --- a/packages/ui/src/layouts/PublicLayout.tsx +++ b/packages/ui/src/layouts/PublicLayout.tsx @@ -125,12 +125,7 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
{children}
- + ); } diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index 72ae84ac..ebf304ee 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -81,8 +81,8 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S const appName = branding?.appName ?? theme.mobileTitleLabel; const logoUrl = branding?.logoUrl ?? null; // The sidebar and mobile bar are near-black whatever the theme, so they take - // the dark logo variant when one exists. The footer sits on `bg-background` - // and follows the theme, so it keeps the primary logo. + // the dark logo variant when one exists. The layout footer follows the theme, + // so it keeps the primary logo. const darkLogoUrl = darkSurfaceLogo(branding); const [sidebarOpen, setSidebarOpen] = useState(false); const closeSidebar = () => setSidebarOpen(false); @@ -254,12 +254,7 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S activeMenuItem={active} />
{children}
- + diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 4247a2c3..43178f40 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -6,20 +6,6 @@ export interface MenuItem { group?: string; } -export interface FooterLinkShared { - label: string; - href: string; -} - -/** Admin-configured footer; `null` keeps the framework's built-in one. */ -export interface FooterShared { - tagline: string; - copyrightOwner: string; - note: string; - columns: { title: string; links: FooterLinkShared[] }[]; - socialLinks: FooterLinkShared[]; -} - export interface BrandingShared { appName: string; primaryColor: string | null; @@ -37,7 +23,6 @@ export interface BrandingShared { faviconUrl: string | null; /** Site-wide announcement, or `null` when no message is set. */ banner: { message: string; severity: string } | null; - footer: FooterShared | null; } export interface SharedProps {