Skip to content
Merged
19 changes: 12 additions & 7 deletions framework/hosting/simple_module_hosting/_inertia_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,25 @@ def branding_head(request: Request) -> dict:
"""Branding metadata for the root template's ``<head>``.

Reads the optional branding module's settings off ``app.state`` by name
(duck-typed, never imported) so the static ``<title>`` and ``theme-color``
are already branded *before* React hydrates. Degrades to the framework
default when branding isn't installed. Only plain settings strings are
surfaced here — the favicon is applied client-side by ``BrandingHead`` via
Inertia's ``<Head>``, keeping file_storage's download-route shape out of
framework code.
(duck-typed, never imported) so the ``<title>``, ``theme-color`` and
favicon are already branded *before* React hydrates — otherwise the browser
paints the default favicon and only swaps on hydration, a visible flicker on
every full page load. Degrades to the framework default when branding isn't
installed.

The favicon URL is read from the module rather than assembled here: branding
owns its route shape, and framework code must not reach into a plugin
(SM009). ``BrandingHead`` still applies it client-side too, so a favicon
changed at runtime updates without a reload.
"""
services = getattr(request.app.state, "branding", None)
settings = getattr(services, "settings", None)
if settings is None:
return {"app_name": _DEFAULT_APP_NAME, "theme_color": None}
return {"app_name": _DEFAULT_APP_NAME, "theme_color": None, "favicon_url": None}
return {
"app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME,
"theme_color": getattr(settings, "primary_color", "") or None,
"favicon_url": getattr(services, "favicon_url", None),
}


Expand Down
21 changes: 19 additions & 2 deletions framework/hosting/tests/test_branding_head.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def _request(branding: object | None) -> SimpleNamespace:

def test_defaults_when_branding_not_installed() -> None:
meta = branding_head(_request(None))
assert meta == {"app_name": "SimpleModule", "theme_color": None}
assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}


def test_reads_app_name_and_theme_color() -> None:
Expand All @@ -29,4 +29,21 @@ def test_reads_app_name_and_theme_color() -> None:
def test_blank_values_fall_back() -> None:
settings = SimpleNamespace(app_name="", primary_color="")
meta = branding_head(_request(SimpleNamespace(settings=settings)))
assert meta == {"app_name": "SimpleModule", "theme_color": None}
assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}


def test_favicon_url_comes_from_the_module_not_from_here() -> None:
# The framework must not know branding's route shape (SM009), so it reads
# whatever the module exposes rather than assembling a URL itself.
services = SimpleNamespace(
settings=SimpleNamespace(app_name="Acme", primary_color=""),
favicon_url="/api/branding/favicon?v=abc",
)
assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"


def test_favicon_url_is_none_on_a_host_without_that_attribute() -> None:
# An older branding release exposes no favicon_url; the shell just omits
# the link rather than erroring, and BrandingHead still sets it client-side.
services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color=""))
assert branding_head(_request(services))["favicon_url"] is None
1 change: 1 addition & 0 deletions host/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{% set _brand = branding_head(request) %}
<title>{{ _brand.app_name }}</title>
{% if _brand.theme_color %}<meta name="theme-color" content="{{ _brand.theme_color }}" />{% endif %}
{% if _brand.favicon_url %}<link rel="icon" href="{{ _brand.favicon_url }}" />{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Sora:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
Expand Down
61 changes: 56 additions & 5 deletions modules/branding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.

An administrator can set the **application name**, **logo**, **favicon** and
**primary brand colour** from the admin UI (`/branding`), and those values are
An administrator can set the **application name**, **logo** (plus an optional
**dark-background variant**), **favicon** and **primary brand colour** from the
admin UI (`/branding`), and those values are
applied everywhere the framework would otherwise show the default identity —
the sidebar/header logo and name, the browser tab title, the favicon, and the
primary accent colour.
Expand Down Expand Up @@ -41,16 +42,63 @@ modules to be installed too.
favicon. Changes apply immediately across the app.

Programmatically, the current branding is available on every page through the
`branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`,
`faviconUrl`).
`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
`darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies
the `logoDarkUrl → logoUrl` fallback in one place.

## How it works

- **Storage.** The four values are persisted via the `settings` module's store
(SYSTEM scope) — there is no branding database table. They hydrate into
`app.state.branding.settings` at boot and hot-swap on save.
- **Images.** Logo and favicon uploads are stored through the `file_storage`
module (referenced by UUID) and served from its download endpoint.
module (referenced by UUID). Branding serves them back from its own
**anonymous** routes, `GET /api/branding/logo` and `GET /api/branding/favicon`
— `file_storage`'s download endpoint requires `file-storage.download`, which
no logged-out visitor has, and the sign-in page is exactly where the logo
must appear. Only the two ids currently held in branding settings are served,
so this is not a way to read arbitrary files. Uploading and clearing on those
same paths stay behind `branding.manage` (the exemption is GET-only).
- **Caching.** The published URL carries `?v=<file id>`; a replaced image is a
new `file_storage` id, so the URL is content-addressed. Versioned requests
are served `public, max-age=31536000, immutable`; a request without a usable
version gets `public, max-age=3600` so it self-corrects, and a 404 is never
cached.
- **Announcement banner.** A message plus a severity (`info` / `warning` /
`danger`) rendered above every shell — app, public and auth — because an
outage notice is most useful to people who cannot sign in. An empty message
hides it. Severity colours are semantic, not brand-tinted: a warning wearing
the deployment's accent colour stops reading as a warning.
- **Presets.** One-click looks (`POST /api/branding/presets/{key}`), applied
through the ordinary update path so every validator still runs. A preset only
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)*
variant swaps it in on those surfaces only. It is optional: with none set the
shared prop reports `logoDarkUrl: null` and the frontend falls back to
`logoUrl`, so single-logo deployments look exactly as they did.
- **Lifecycle.** Replacing or clearing an image deletes the file it stopped
referencing, so repeated logo tweaks don't leave orphans in `file_storage`.
Cleanup is best effort: the setting change has already been persisted, so a
storage fault is logged rather than failing an otherwise-successful rebrand.
- **Upload validation.** PNG, JPEG, WEBP, GIF and ICO up to 2 MB. The declared
content-type is caller-controlled, so the first bytes are also checked
against each format's magic number — a payload renamed `logo.png` is
rejected. SVG is excluded on purpose: it is an XML document that can carry
`<script>`, so serving one from the app's origin would be stored XSS.
- **Delivery.** A registered Inertia shared-props provider injects a `branding`
block into every page's shared props (authenticated *and* guest), which the
frontend reads for the name, logo, favicon and colour.
Expand All @@ -60,6 +108,9 @@ Programmatically, the current branding is available on every page through the
- `branding.view` — view the branding admin page.
- `branding.manage` — change branding (name, colour, logo, favicon).

The logo and favicon **GET** routes are anonymous by design (registered through
the `register_public_routes` hook); everything else requires a permission.

## Dependencies

Depends on the `Settings` and `FileStorage` modules.
Expand Down
75 changes: 75 additions & 0 deletions modules/branding/branding/components/BannerField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { keys, useT } from '@simple-module-py/i18n';
import { Input } from '@simple-module-py/ui/components/ui/input';
import { Label } from '@simple-module-py/ui/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@simple-module-py/ui/components/ui/select';

/** Mirrors `BANNER_SEVERITIES` in `branding/constants.py`. */
export const BANNER_SEVERITIES = ['info', 'warning', 'danger'] as const;
export type BannerSeverity = (typeof BANNER_SEVERITIES)[number];

/** Matches `MAX_BANNER_MESSAGE_LEN`, so the server never has to reject length. */
export const MAX_BANNER_MESSAGE = 500;

interface BannerFieldProps {
message: string;
severity: BannerSeverity;
onMessageChange: (next: string) => void;
onSeverityChange: (next: BannerSeverity) => void;
disabled: boolean;
}

/** Site-wide announcement bar: message + severity. Empty message hides it. */
export function BannerField({
message,
severity,
onMessageChange,
onSeverityChange,
disabled,
}: BannerFieldProps) {
const { t } = useT();
const severityLabels: Record<BannerSeverity, string> = {
info: t(keys.branding.manage.banner_severity_info),
warning: t(keys.branding.manage.banner_severity_warning),
danger: t(keys.branding.manage.banner_severity_danger),
};

return (
<div className="space-y-2">
<Label htmlFor="banner_message">{t(keys.branding.manage.banner_label)}</Label>
<div className="flex flex-wrap items-center gap-3">
<Input
id="banner_message"
value={message}
maxLength={MAX_BANNER_MESSAGE}
disabled={disabled}
placeholder={t(keys.branding.manage.banner_placeholder)}
onChange={(e) => onMessageChange(e.target.value)}
className="min-w-60 flex-1"
/>
<Select
value={severity}
disabled={disabled}
onValueChange={(next) => onSeverityChange(next as BannerSeverity)}
>
<SelectTrigger id="banner_severity" className="w-40" aria-label="Banner severity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BANNER_SEVERITIES.map((value) => (
<SelectItem key={value} value={value}>
{severityLabels[value]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t(keys.branding.manage.banner_help)}</p>
</div>
);
}
76 changes: 76 additions & 0 deletions modules/branding/branding/components/FooterCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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<FooterDraft>(() => 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 (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t(keys.branding.manage.footer_title)}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FooterEditor value={draft} onChange={setDraft} disabled={disabled} />
<Button type="button" disabled={disabled} onClick={save}>
{busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.footer_save_button)}
</Button>
</CardContent>
</Card>
);
}
Loading
Loading