diff --git a/docs/branding/screenshots/01-branding-admin-default.png b/docs/branding/screenshots/01-branding-admin-default.png new file mode 100644 index 00000000..0d9f3471 Binary files /dev/null and b/docs/branding/screenshots/01-branding-admin-default.png differ diff --git a/docs/branding/screenshots/02-branding-admin-applied.png b/docs/branding/screenshots/02-branding-admin-applied.png new file mode 100644 index 00000000..f4bcba6a Binary files /dev/null and b/docs/branding/screenshots/02-branding-admin-applied.png differ diff --git a/docs/branding/screenshots/03-branded-dashboard.png b/docs/branding/screenshots/03-branded-dashboard.png new file mode 100644 index 00000000..68966039 Binary files /dev/null and b/docs/branding/screenshots/03-branded-dashboard.png differ diff --git a/docs/plans/2026-06-17-branding-feature-design.md b/docs/plans/2026-06-17-branding-feature-design.md new file mode 100644 index 00000000..b262066c --- /dev/null +++ b/docs/plans/2026-06-17-branding-feature-design.md @@ -0,0 +1,176 @@ +# Branding feature — design + +**Date:** 2026-06-17 +**Status:** design (intended state) + +## Goal + +Let an administrator customise the application's identity — **app name, logo, +favicon, and primary brand colour** — from the admin UI, and have those values +applied everywhere the framework currently hard-codes "SimpleModule" (browser +title, sidebar/header label and logo, favicon, primary colour). + +## Scope (MVP) and explicit non-goals + +In scope: + +- A `branding` module exposing **app name**, **logo image**, **favicon image**, + and **primary colour (hex)**. +- A dedicated admin page (`/branding`) with uploads, a colour picker and a live + preview — gated behind a `branding.manage` permission. +- Logo/favicon **image upload** reusing the existing `file_storage` module + (stored by UUID, served via its download endpoint). +- Values persisted via the existing **settings store** (no new branding DB + table), hydrated at boot and hot-reloaded on save. +- Values surfaced to **every** Inertia page (authenticated *and* guest) via a + new generic framework extension point, then consumed in the React layout, + the document ``, the favicon `<link>`, and primary-colour CSS vars. + +Non-goals (deliberately deferred — note where the design leaves room): + +- **Per-tenant branding.** MVP is SYSTEM scope only. The settings store already + supports TENANT/USER scope, so this is an additive change later (resolve the + tenant in the shared-props provider). +- **Full colour-scale theming.** We override the shadcn semantic tokens + (`--primary`, `--primary-foreground`, `--sidebar-primary`) from the single + hex. We do *not* regenerate the `primary-50…900` OKLCH scale used by some + gradients; that is a future enhancement. +- Custom fonts, login-page background imagery, dark/light logo variants. + +## Why this shape + +The framework already has every primitive this needs; the feature is mostly +*wiring*, not new infrastructure: + +- **Settings store** persists arbitrary module config (SYSTEM/TENANT/USER + scope), hydrates a pydantic `BaseSettings` at boot into + `app.state.<module>.settings`, and hot-swaps it on save via + `apply_changes_and_reload(...)` which also publishes `SettingsReloaded`. + → Branding stores its four values here; **no new table**. +- **file_storage** stores an uploaded image and returns a stable UUID; the + image is served from `GET /api/file-storage/files/{id}/download` for every + backend (local disk or S3/MinIO). + → Branding stores `logo_file_id` / `favicon_file_id` and derives URLs. +- **`InertiaLayoutDataMiddleware`** already assembles per-page shared props + (`auth`, `menus`, `i18n`) and — crucially — avoids the `SM009` + framework→plugin import ban by reading a *module-registered callable* off + `app.state` (`principal_serializer`). Branding follows that exact precedent. + +## Architecture + +### New framework extension point: Inertia shared-prop providers + +A generic, module-agnostic hook so any plugin can contribute layout-wide shared +props without the framework importing the plugin (mirrors `principal_serializer`). + +- `app.state.inertia_shared_providers: list[Callable[[Request], dict]]`, + initialised to `[]` in `app_builder`. +- A tiny hosting helper `register_inertia_shared_provider(app, fn)` appends to it. +- `InertiaLayoutDataMiddleware` iterates the providers and merges each returned + dict into `shared` (after the built-in `auth`/`menus`/`i18n`). Providers must + be cheap and total (no exceptions); a provider that raises is skipped and + logged, never failing the request. + +This is generic framework value, not a branding special-case. + +### The `branding` module (plugin) + +``` +modules/branding/branding/ +├── module.py # registers settings, permission, menu, routes, provider +├── settings.py # BrandingSettings(BaseSettings): app_name, logo_file_id, +│ # favicon_file_id, primary_color +├── services.py # BrandingState(settings=...) +├── service.py # BrandingService: read current branding, apply updates, +│ # upload+set logo/favicon via file_storage + settings store +├── shared_props.py # provider: (request) -> {"branding": {...}} from app.state +├── contracts/ # BrandingOut, BrandingUpdate DTOs +├── deps.py +├── endpoints/api.py # JSON: GET current, PUT name/colour, POST logo, POST +│ # favicon, DELETE logo/favicon (all branding.manage) +├── endpoints/views.py # Inertia: GET /branding -> "Branding/Manage" +├── pages/Manage.tsx # admin page: name, uploads, colour picker, live preview +└── locales/en.json +``` + +- `meta = ModuleMeta(name="Branding", route_prefix="/api/branding", + view_prefix="/branding", depends_on=["Settings", "FileStorage"])`. +- `register_permissions`: group "Branding" with `branding.view`, `branding.manage`. +- `register_menu_items`: "Branding" under the "Administration" group, + `roles=["admin"]`. +- `register_settings`: `register_module_settings(app, "branding", + BrandingSettings, lambda s: BrandingState(settings=s))`. +- Shared-props provider registered in `on_startup` (after settings hydrated): + returns `{"branding": {appName, logoUrl, faviconUrl, primaryColor}}`, reading + the live `app.state.branding.settings`. URLs derived from file ids; `None` + when unset so the frontend falls back to defaults. + +### Data flow + +``` +admin saves on /branding + → POST /api/branding/logo (UploadFile) + → file_storage.upload() -> StoredFileOut.id (UUID) + → settings apply_changes_and_reload(app, bus, store, "branding", + {"logo_file_id": str(id)}) + → app.state.branding.settings hot-swapped + SettingsReloaded event + → next request: + InertiaLayoutDataMiddleware merges branding provider output into shared + → inertia.share(**shared) + → usePage().props.branding in React + → SidebarLayout (name + logo), app.tsx title, <Head> favicon, + CSS var override for --primary +``` + +### Frontend consumption (packages/ui + host) + +- `app.tsx` title callback: `title ? \`${title} — ${appName}\` : appName`, + reading the initial-page branding prop (fallback "SimpleModule"). +- `SidebarLayout.tsx`: render `branding.appName` and, when `logoUrl` set, an + `<img>` instead of the "SM" badge — both mobile and desktop. Fallback to the + current "SM"/"SimpleModule" treatment. +- New `BrandingHead` component (packages/ui) rendered in the authenticated and + public layouts: emits `<Head>` with a favicon `<link>` (when set) and a + `<style>` overriding `--primary` / `--primary-foreground` / + `--sidebar-primary` from the hex (when set). +- Branding shared prop also reaches **guest** pages (login etc.) because the + provider runs for every request. + +## Validation & error handling + +- Image uploads constrained to an allow-list (`image/png`, `image/jpeg`, + `image/svg+xml`, `image/x-icon`, `image/webp`) and a max size (e.g. 2 MB), + enforced in the branding endpoint before handing to file_storage. +- `primary_color` validated as a `#rrggbb` hex (pydantic field validator); + invalid input rejected with 422. +- `app_name` length-bounded (1–60 chars). +- Shared-props provider is defensive: any failure → omit branding block, log, + never 500 a page. + +## Testing + +- **Unit (pytest):** `BrandingSettings` defaults/validation; `BrandingService` + upload→settings-write happy path with a fake file_storage; shared-props + provider output (set vs unset values); hex validator. +- **Integration (pytest + authenticated_client):** `PUT /api/branding` + persists name/colour and the value appears in the next page's shared props; + `POST /api/branding/logo` stores a file and sets `logo_file_id`; permission + gate returns 403 without `branding.manage`. +- **Framework:** provider registry — a registered provider's dict is merged + into shared; a throwing provider is skipped, not fatal. +- **JS (vitest):** `SidebarLayout` renders custom name/logo when prop present, + falls back when absent; `BrandingHead` emits favicon + CSS var style. +- **Doctor:** `make doctor` clean (no SM00x/SM01x regressions; menu+permission + present so no SM019). + +## Build sequence + +1. Framework extension point (registry + middleware merge + helper) — TDD. +2. Scaffold `branding` module (`make new-module`), strip CRUD shape to the + settings/singleton shape above. +3. `BrandingSettings` + state + service + contracts + provider — TDD. +4. Endpoints (JSON + Inertia view) + permissions + menu — TDD. +5. Frontend: shared-prop typing, SidebarLayout, app.tsx title, BrandingHead, + Manage.tsx page. +6. Wire provider registration; `make gen-pages`; migrations check. +7. `make lint`, `make test`, `make doctor`; e2e smoke of `/branding`. diff --git a/framework/hosting/simple_module_hosting/__init__.py b/framework/hosting/simple_module_hosting/__init__.py index c5f6dbe3..9431d8d9 100644 --- a/framework/hosting/simple_module_hosting/__init__.py +++ b/framework/hosting/simple_module_hosting/__init__.py @@ -3,5 +3,16 @@ from simple_module_hosting.app_builder import create_app from simple_module_hosting.logging import correlation_id, setup_logging from simple_module_hosting.settings import Settings +from simple_module_hosting.shared_props import ( + SharedPropsProvider, + register_inertia_shared_provider, +) -__all__ = ["Settings", "correlation_id", "create_app", "setup_logging"] +__all__ = [ + "Settings", + "SharedPropsProvider", + "correlation_id", + "create_app", + "register_inertia_shared_provider", + "setup_logging", +] diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index bd3522f1..896c2814 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from starlette.datastructures import Headers from starlette.requests import Request @@ -59,3 +60,30 @@ def build_i18n_block(scope: Scope, request: Request) -> dict: "supportedLocales": registry.available_locales(), "messages": registry.messages_snapshot(locale) if send_messages else None, } + + +def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None: + """Merge module-registered Inertia shared-prop providers into ``shared`` in place. + + Providers are read off ``app.state.inertia_shared_providers`` (never importing + the plugin — preserves SM009). A provider that raises is skipped and logged; a + provider may not clobber a framework-owned key (auth/menus/i18n) or an earlier + provider's key. + """ + providers = getattr(app.state, "inertia_shared_providers", None) or () + for provider in providers: + name = getattr(provider, "__name__", provider) + try: + extra = provider(request) + except Exception: # a bad provider must not break the page render + logger.warning("shared-prop provider %r raised; skipping", name, exc_info=True) + continue + for key, value in (extra or {}).items(): + if key in shared: + logger.warning( + "provider %r tried to overwrite reserved shared-prop %r; ignoring", + name, + key, + ) + continue + shared[key] = value diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index a02e02df..6dbb5cfe 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -18,7 +18,7 @@ from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send -from simple_module_hosting._inertia_shared import build_i18n_block +from simple_module_hosting._inertia_shared import build_i18n_block, merge_shared_prop_providers from simple_module_hosting._observability import ( CorrelationIdMiddleware, RequestLoggingMiddleware, @@ -277,6 +277,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ), "i18n": i18n_block, } + + # Merge module-registered shared-prop providers (e.g. branding) — read off + # app.state without importing the plugin (SM009), defensively. + merge_shared_prop_providers(scope["app"], request, shared) + request.state.inertia_shared = shared await self.app(scope, receive, send) diff --git a/framework/hosting/simple_module_hosting/shared_props.py b/framework/hosting/simple_module_hosting/shared_props.py new file mode 100644 index 00000000..a60160ee --- /dev/null +++ b/framework/hosting/simple_module_hosting/shared_props.py @@ -0,0 +1,42 @@ +"""Module-registered Inertia shared-prop providers. + +A generic extension point so plugin modules can contribute layout-wide Inertia +shared props (e.g. branding) on every page, without the framework importing the +plugin. This mirrors the ``principal_serializer`` precedent: the framework reads +a registered callable off ``app.state`` rather than reaching into module code, +keeping the ``SM009`` framework→plugin import ban intact. + +A provider is ``Callable[[Request], dict]``. It must be cheap and total — it runs +for every request. :class:`InertiaLayoutDataMiddleware` merges each provider's +returned dict into the ``shared`` payload after the built-in ``auth``/``menus``/ +``i18n`` blocks; a provider that raises is skipped and logged, never failing the +request. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import FastAPI + from starlette.requests import Request + +SharedPropsProvider = Callable[["Request"], dict] +"""A function mapping a request to a dict merged into Inertia shared props.""" + +_STATE_ATTR = "inertia_shared_providers" + + +def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider) -> None: + """Register a shared-props provider on the app. + + Idempotently initialises ``app.state.inertia_shared_providers`` (a list) and + appends ``provider``. Safe to call from a module lifecycle hook before the + framework has set up the list. + """ + providers = getattr(app.state, _STATE_ATTR, None) + if providers is None: + providers = [] + setattr(app.state, _STATE_ATTR, providers) + providers.append(provider) diff --git a/framework/hosting/tests/test_inertia_shared_providers.py b/framework/hosting/tests/test_inertia_shared_providers.py new file mode 100644 index 00000000..19a9549f --- /dev/null +++ b/framework/hosting/tests/test_inertia_shared_providers.py @@ -0,0 +1,91 @@ +"""Verify InertiaLayoutDataMiddleware merges module-registered shared-prop providers. + +Modules contribute layout-wide Inertia shared props (e.g. branding) without the +framework importing the plugin — mirroring the ``principal_serializer`` precedent. +Providers are registered on ``app.state.inertia_shared_providers`` and merged into +the ``shared`` dict for every request. +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from simple_module_core.menu import MenuRegistry +from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting.middleware import InertiaLayoutDataMiddleware +from simple_module_hosting.shared_props import register_inertia_shared_provider +from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.testclient import TestClient + + +def _build_app() -> FastAPI: + app = FastAPI() + + @app.get("/shared") + def shared(request: Request) -> JSONResponse: + return JSONResponse(request.state.inertia_shared) + + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=MenuRegistry(), + permission_registry=PermissionRegistry(), + ) + app.add_middleware(SessionMiddleware, secret_key="test-secret") + return app + + +def test_registered_provider_dict_merged_into_shared() -> None: + app = _build_app() + register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}}) + + body = TestClient(app).get("/shared").json() + + assert body["branding"] == {"appName": "Acme"} + # Built-in blocks still present. + assert "auth" in body + assert "menus" in body + + +def test_provider_that_raises_is_skipped_not_fatal(caplog) -> None: + app = _build_app() + + def boom(_req: Request) -> dict: + raise RuntimeError("provider blew up") + + register_inertia_shared_provider(app, boom) + register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}}) + + with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): + resp = TestClient(app).get("/shared") + + assert resp.status_code == 200 + body = resp.json() + # The good provider still applied; the failing one was skipped. + assert body["branding"] == {"appName": "Acme"} + assert "auth" in body + assert any("shared-prop" in rec.message.lower() for rec in caplog.records) + + +def test_no_providers_leaves_shared_unchanged() -> None: + body = TestClient(_build_app()).get("/shared").json() + assert "branding" not in body + assert "auth" in body and "menus" in body + + +def test_provider_cannot_clobber_framework_keys(caplog) -> None: + app = _build_app() + # A misbehaving provider tries to overwrite the framework-owned auth block. + register_inertia_shared_provider( + app, lambda _req: {"auth": "HIJACKED", "branding": {"ok": True}} + ) + + with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): + body = TestClient(app).get("/shared").json() + + # auth stays the framework's dict; only the non-reserved key is added. + assert isinstance(body["auth"], dict) + assert body["branding"] == {"ok": True} + assert any("reserved shared-prop" in rec.message for rec in caplog.records) diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index 86a8de39..dbd3b24d 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -5,6 +5,11 @@ import { createRoot } from 'react-dom/client'; import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; import { resolvePage } from './pages'; +// NOTE: the browser-tab title is not branded with the configured app name. +// The in-app branding (sidebar name/logo, favicon, primary colour) is applied +// via the `branding` shared prop + BrandingHead; wiring the configured name +// into the document <title> is a deferred follow-up (Inertia's title callback +// can't read live page props, and this app's title updates are page-driven). createInertiaApp({ title: (title) => (title ? `${title} — SimpleModule` : 'SimpleModule'), resolve: async (name) => { diff --git a/host/pyproject.toml b/host/pyproject.toml index fe5b8a1c..ef02ea95 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "simple_module_settings", "simple_module_feature_flags", "simple_module_audit_log", + "simple_module_branding", "python-multipart>=0.0.6", ] @@ -26,3 +27,4 @@ simple_module_file_storage = { workspace = true } simple_module_settings = { workspace = true } simple_module_feature_flags = { workspace = true } simple_module_audit_log = { workspace = true } +simple_module_branding = { workspace = true } diff --git a/modules/branding/README.md b/modules/branding/README.md new file mode 100644 index 00000000..6d9a96f8 --- /dev/null +++ b/modules/branding/README.md @@ -0,0 +1,75 @@ +# simple_module_branding + +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 +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. + +## Screenshots + +The admin page at `/branding`, and the same app rebranded as "Acme Analytics" +(custom logo + name + primary colour) across the sidebar and dashboard: + +| Admin page | Branding applied | Across the app | +|---|---|---| +| ![Branding admin](../../docs/branding/screenshots/01-branding-admin-default.png) | ![Branding applied](../../docs/branding/screenshots/02-branding-admin-applied.png) | ![Branded dashboard](../../docs/branding/screenshots/03-branded-dashboard.png) | + +## Install + +The module ships with the default app. To add it to a custom host, declare it +as a dependency and let entry-point discovery pick it up: + +```toml +# host/pyproject.toml +dependencies = ["simple_module_branding"] + +[tool.uv.sources] +simple_module_branding = { workspace = true } +``` + +Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage` +modules to be installed too. + +## Usage + +1. Sign in as an admin and open **Branding** in the sidebar (or visit + `/branding`). +2. Set the application name, pick a primary colour, and upload a logo and/or + 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`). + +## 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. +- **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. + +## Permissions + +- `branding.view` — view the branding admin page. +- `branding.manage` — change branding (name, colour, logo, favicon). + +## Dependencies + +Depends on the `Settings` and `FileStorage` modules. + +## Notes + +- Branding is currently SYSTEM-scoped (one identity per deployment). The + settings store already supports tenant/user scope, leaving room for + per-tenant branding later. +- The primary colour overrides the `--primary` / `--sidebar-primary` CSS + variables from a single hex; the full OKLCH colour scale is not regenerated. + +License: MIT diff --git a/modules/branding/branding/__init__.py b/modules/branding/branding/__init__.py new file mode 100644 index 00000000..fe9ceb0b --- /dev/null +++ b/modules/branding/branding/__init__.py @@ -0,0 +1 @@ +"""Branding module.""" diff --git a/modules/branding/branding/constants.py b/modules/branding/branding/constants.py new file mode 100644 index 00000000..4ada9919 --- /dev/null +++ b/modules/branding/branding/constants.py @@ -0,0 +1,47 @@ +"""Branding module constants.""" + +from __future__ import annotations + +import re +from typing import Final + +from file_storage.constants import PATH_FILE_DOWNLOAD, ROUTE_PREFIX_API + +PACKAGE: Final = "branding" + +# Modules this one depends on (kept as constants so the depends_on list doesn't +# carry bare string literals — see scripts/check_hardcoded_strings.py). +_MODULE_SETTINGS: Final = "Settings" +_MODULE_FILE_STORAGE: Final = "FileStorage" + +# Inertia page identifier. The view endpoint renders this as a literal (so the +# SM003/SM004 static-AST diagnostics can pair it with pages/Manage.tsx); a test +# asserts the literal matches this constant. +_PAGE_MANAGE: Final = "Branding/Manage" + +PERM_VIEW: Final = "branding.view" +PERM_MANAGE: Final = "branding.manage" + +# A #rrggbb hex colour (single source of truth for both the settings and the +# update-DTO validators). +HEX_COLOR_RE: Final = re.compile(r"^#[0-9a-fA-F]{6}$") +MAX_APP_NAME_LEN: Final = 60 + +# Image upload guard-rails (enforced before handing the file to file_storage). +MAX_IMAGE_BYTES: Final = 2 * 1024 * 1024 # 2 MB +ALLOWED_IMAGE_TYPES: Final = frozenset( + { + "image/png", + "image/jpeg", + "image/svg+xml", + "image/webp", + "image/gif", + "image/x-icon", + "image/vnd.microsoft.icon", + } +) + +# file_storage download URL, derived from file_storage's own route constants +# (branding depends on FileStorage) so it tracks any route change. The +# ``{file_id}`` placeholder is filled per stored-file id. +FILE_DOWNLOAD_URL: Final = ROUTE_PREFIX_API + PATH_FILE_DOWNLOAD diff --git a/modules/branding/branding/contracts/__init__.py b/modules/branding/branding/contracts/__init__.py new file mode 100644 index 00000000..3be68fe9 --- /dev/null +++ b/modules/branding/branding/contracts/__init__.py @@ -0,0 +1,8 @@ +"""Branding contracts — public interface for other modules.""" + +from branding.contracts.schemas import BrandingOut, BrandingUpdate + +__all__ = [ + "BrandingOut", + "BrandingUpdate", +] diff --git a/modules/branding/branding/contracts/schemas.py b/modules/branding/branding/contracts/schemas.py new file mode 100644 index 00000000..0d4b1a28 --- /dev/null +++ b/modules/branding/branding/contracts/schemas.py @@ -0,0 +1,45 @@ +"""SQLModel DTOs for the Branding module — the public surface.""" + +from __future__ import annotations + +from pydantic import field_validator +from sqlmodel import Field, SQLModel + +from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN + + +class BrandingOut(SQLModel): + """Current branding, with logo/favicon resolved to download URLs.""" + + app_name: str + primary_color: str = "" + logo_url: str | None = None + favicon_url: str | None = None + + +class BrandingUpdate(SQLModel): + """Editable text fields. Logo/favicon are set via dedicated upload routes.""" + + app_name: str | None = Field(default=None, max_length=MAX_APP_NAME_LEN) + primary_color: str | None = Field(default=None) + + @field_validator("app_name") + @classmethod + def _non_empty_name(cls, value: str | None) -> str | None: + # Reject blank/whitespace here so it surfaces as a 422 rather than a 500 + # when BrandingSettings (which strips + requires non-empty) re-validates. + if value is None: + return None + cleaned = value.strip() + if not cleaned: + raise ValueError("app_name must not be blank") + return cleaned + + @field_validator("primary_color") + @classmethod + def _valid_hex(cls, value: str | None) -> str | None: + if value is None: + return None + if value != "" and not HEX_COLOR_RE.match(value): + raise ValueError("primary_color must be a #rrggbb hex string or empty") + return value.lower() diff --git a/modules/branding/branding/deps.py b/modules/branding/branding/deps.py new file mode 100644 index 00000000..18a7f4d4 --- /dev/null +++ b/modules/branding/branding/deps.py @@ -0,0 +1,21 @@ +"""FastAPI dependencies for the Branding module.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from branding.service import BrandingService + + +async def get_branding_service( + request: Request, + db: AsyncSession = Depends(get_db), +) -> BrandingService: + return BrandingService(request.app, db) + + +BrandingServiceDep = Annotated[BrandingService, Depends(get_branding_service)] diff --git a/modules/branding/branding/endpoints/__init__.py b/modules/branding/branding/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/branding/branding/endpoints/api.py b/modules/branding/branding/endpoints/api.py new file mode 100644 index 00000000..9de0ad83 --- /dev/null +++ b/modules/branding/branding/endpoints/api.py @@ -0,0 +1,74 @@ +"""REST API endpoints for Branding (JSON). All writes require branding.manage.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from file_storage.deps import get_file_storage_service +from file_storage.service import FileStorageService +from simple_module_hosting.permissions import RequiresPermission + +from branding import constants +from branding.contracts.schemas import BrandingOut, BrandingUpdate +from branding.deps import BrandingServiceDep + +router = APIRouter() + +_MANAGE = Depends(RequiresPermission(constants.PERM_MANAGE)) + + +def _validate_image(file: UploadFile) -> None: + if file.content_type not in constants.ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=415, + detail=f"Unsupported image type {file.content_type!r}.", + ) + if file.size is not None and file.size > constants.MAX_IMAGE_BYTES: + raise HTTPException( + status_code=413, + detail=f"Image exceeds {constants.MAX_IMAGE_BYTES} bytes.", + ) + + +@router.get("/", response_model=BrandingOut, dependencies=[_MANAGE]) +async def get_branding(service: BrandingServiceDep) -> BrandingOut: + return service.current() + + +@router.put("/", response_model=BrandingOut, dependencies=[_MANAGE]) +async def update_branding(data: BrandingUpdate, service: BrandingServiceDep) -> BrandingOut: + changes = {k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None} + if not changes: + return service.current() + return await service.apply(changes) + + +@router.post("/logo", response_model=BrandingOut, dependencies=[_MANAGE]) +async def upload_logo( + service: BrandingServiceDep, + file: UploadFile, + storage: FileStorageService = Depends(get_file_storage_service), +) -> BrandingOut: + _validate_image(file) + stored = await storage.upload(file) + return await service.set_logo(str(stored.id)) + + +@router.post("/favicon", response_model=BrandingOut, dependencies=[_MANAGE]) +async def upload_favicon( + service: BrandingServiceDep, + file: UploadFile, + storage: FileStorageService = Depends(get_file_storage_service), +) -> BrandingOut: + _validate_image(file) + stored = await storage.upload(file) + return await service.set_favicon(str(stored.id)) + + +@router.delete("/logo", response_model=BrandingOut, dependencies=[_MANAGE]) +async def clear_logo(service: BrandingServiceDep) -> BrandingOut: + return await service.clear_logo() + + +@router.delete("/favicon", response_model=BrandingOut, dependencies=[_MANAGE]) +async def clear_favicon(service: BrandingServiceDep) -> BrandingOut: + return await service.clear_favicon() diff --git a/modules/branding/branding/endpoints/views.py b/modules/branding/branding/endpoints/views.py new file mode 100644 index 00000000..6961141b --- /dev/null +++ b/modules/branding/branding/endpoints/views.py @@ -0,0 +1,29 @@ +"""Inertia view endpoints for Branding.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission + +from branding import constants + +router = APIRouter() + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))], +) +async def manage(inertia: InertiaDep) -> InertiaResponse: + # Current branding is delivered through the shared ``branding`` prop + # (the branding shared-props provider), so no page props are needed. + # The page name is inlined as a literal (rather than constants._PAGE_MANAGE) + # so the SM003/SM004 diagnostics — which do static AST analysis and can't + # resolve attribute access — pair this call with pages/Manage.tsx. A unit + # test asserts the literal matches constants._PAGE_MANAGE. + return await inertia.render( + "Branding/Manage", + ) diff --git a/modules/branding/branding/locales/en.json b/modules/branding/branding/locales/en.json new file mode 100644 index 00000000..fbc94005 --- /dev/null +++ b/modules/branding/branding/locales/en.json @@ -0,0 +1,23 @@ +{ + "manage": { + "title": "Branding", + "description": "Customise your application's name, logo, favicon and primary colour.", + "app_name_label": "Application name", + "app_name_help": "Shown in the sidebar, the browser tab and on sign-in.", + "primary_color_label": "Primary colour", + "primary_color_help": "Accent colour used across buttons and highlights. Leave blank for the default.", + "logo_label": "Logo", + "logo_help": "Square PNG or SVG works best. Replaces the default badge in the sidebar.", + "favicon_label": "Favicon", + "favicon_help": "Small icon shown in the browser tab. PNG, SVG or ICO.", + "upload_button": "Upload", + "replace_button": "Replace", + "remove_button": "Remove", + "save_button": "Save changes", + "saving": "Saving…", + "preview_title": "Preview", + "saved_toast": "Branding updated", + "error_toast": "Could not update branding", + "upload_error_toast": "Could not upload image" + } +} diff --git a/modules/branding/branding/module.py b/modules/branding/branding/module.py new file mode 100644 index 00000000..c9c9d3a0 --- /dev/null +++ b/modules/branding/branding/module.py @@ -0,0 +1,77 @@ +"""Branding module definition. + +Lets an administrator customise the app name, logo, favicon and primary colour. +Values persist in the shared settings store (no branding table) and reach every +page through a registered Inertia shared-props provider. +""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from branding import constants + + +class BrandingModule(ModuleBase): + meta = ModuleMeta( + name="Branding", + route_prefix="/api/branding", + view_prefix="/branding", + depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE], + ) + + def register_settings(self, app: FastAPI) -> None: + from settings.registration import register_module_settings + + from branding.services import BrandingServices + from branding.settings import BrandingSettings + + register_module_settings( + app, + constants.PACKAGE, + BrandingSettings, + lambda s: BrandingServices(settings=s), + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + "Branding", + [constants.PERM_VIEW, constants.PERM_MANAGE], + ) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Branding", + url="/branding", + icon="palette", + order=115, + section=MenuSection.SIDEBAR, + group="Administration", + roles=["admin"], + ) + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from branding.endpoints.api import router as api + from branding.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + async def on_startup(self, app: FastAPI) -> None: + from simple_module_hosting.shared_props import register_inertia_shared_provider + + from branding.shared_props import branding_shared_props + + register_inertia_shared_provider(app, branding_shared_props) + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {constants.PACKAGE: base} diff --git a/modules/branding/branding/pages/Manage.tsx b/modules/branding/branding/pages/Manage.tsx new file mode 100644 index 00000000..5de2ab62 --- /dev/null +++ b/modules/branding/branding/pages/Manage.tsx @@ -0,0 +1,261 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module-py/ui/components/ui/card'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { Label } from '@simple-module-py/ui/components/ui/label'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; +import type { SharedProps } from '@simple-module-py/ui/types'; +import { type ChangeEvent, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +const DEFAULT_SWATCH = '#10b981'; +type ImageKind = 'logo' | 'favicon'; + +async function readError(res: Response): Promise<string> { + try { + const body = await res.json(); + return typeof body?.detail === 'string' ? body.detail : res.statusText; + } catch { + return res.statusText; + } +} + +function ImageField({ + label, + help, + url, + onUpload, + onRemove, + disabled, +}: { + label: string; + help: string; + url: string | null; + onUpload: (file: File) => void; + onRemove: () => void; + disabled: boolean; +}) { + const { t } = useT(); + const inputRef = useRef<HTMLInputElement>(null); + + return ( + <div className="space-y-2"> + <Label>{label}</Label> + <div className="flex items-center gap-4"> + <div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-lg border bg-muted"> + {url ? ( + <img src={url} alt={label} className="h-full w-full object-contain" /> + ) : ( + <span className="text-xs text-muted-foreground">—</span> + )} + </div> + <div className="flex gap-2"> + <input + ref={inputRef} + type="file" + accept="image/*" + className="hidden" + onChange={(e: ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (file) onUpload(file); + e.target.value = ''; + }} + /> + <Button + type="button" + variant="outline" + size="sm" + disabled={disabled} + onClick={() => inputRef.current?.click()} + > + {url ? t(keys.branding.manage.replace_button) : t(keys.branding.manage.upload_button)} + </Button> + {url && ( + <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={onRemove}> + {t(keys.branding.manage.remove_button)} + </Button> + )} + </div> + </div> + <p className="text-xs text-muted-foreground">{help}</p> + </div> + ); +} + +function Manage() { + const { t } = useT(); + const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const canManage = auth?.permissions?.includes('branding.manage'); + + const [appName, setAppName] = useState(branding?.appName ?? ''); + const [color, setColor] = useState(branding?.primaryColor ?? ''); + const [busy, setBusy] = useState(false); + + async function run(work: () => Promise<Response>, errorMsg: string) { + setBusy(true); + try { + const res = await work(); + if (!res.ok) throw new Error(await readError(res)); + toast.success(t(keys.branding.manage.saved_toast)); + router.reload(); + } catch (err) { + toast.error(`${errorMsg}: ${(err as Error).message}`); + } finally { + setBusy(false); + } + } + + const saveText = () => + run( + () => + fetch('/api/branding/', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_name: appName, primary_color: color }), + }), + t(keys.branding.manage.error_toast), + ); + + const uploadImage = (kind: ImageKind, file: File) => { + const form = new FormData(); + form.append('file', file); + return run( + () => fetch(`/api/branding/${kind}`, { method: 'POST', body: form }), + t(keys.branding.manage.upload_error_toast), + ); + }; + + const removeImage = (kind: ImageKind) => + run( + () => fetch(`/api/branding/${kind}`, { method: 'DELETE' }), + t(keys.branding.manage.error_toast), + ); + + return ( + <> + <Head title={t(keys.branding.manage.title)} /> + <PageShell + title={t(keys.branding.manage.title)} + description={t(keys.branding.manage.description)} + > + <div className="grid gap-6 lg:grid-cols-3"> + <Card className="lg:col-span-2"> + <CardHeader> + <CardTitle>{t(keys.branding.manage.title)}</CardTitle> + <CardDescription>{t(keys.branding.manage.description)}</CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + <div className="space-y-2"> + <Label htmlFor="app_name">{t(keys.branding.manage.app_name_label)}</Label> + <Input + id="app_name" + value={appName} + maxLength={60} + disabled={!canManage || busy} + onChange={(e) => setAppName(e.target.value)} + /> + <p className="text-xs text-muted-foreground"> + {t(keys.branding.manage.app_name_help)} + </p> + </div> + + <div className="space-y-2"> + <Label htmlFor="primary_color">{t(keys.branding.manage.primary_color_label)}</Label> + <div className="flex items-center gap-3"> + <input + type="color" + aria-label={t(keys.branding.manage.primary_color_label)} + value={color || DEFAULT_SWATCH} + disabled={!canManage || busy} + onChange={(e) => setColor(e.target.value)} + className="h-9 w-12 cursor-pointer rounded border bg-transparent" + /> + <Input + id="primary_color" + value={color} + placeholder={DEFAULT_SWATCH} + disabled={!canManage || busy} + onChange={(e) => setColor(e.target.value)} + className="max-w-40 font-mono" + /> + {color && ( + <Button + type="button" + variant="ghost" + size="sm" + disabled={busy} + onClick={() => setColor('')} + > + {t(keys.branding.manage.remove_button)} + </Button> + )} + </div> + <p className="text-xs text-muted-foreground"> + {t(keys.branding.manage.primary_color_help)} + </p> + </div> + + <ImageField + label={t(keys.branding.manage.logo_label)} + help={t(keys.branding.manage.logo_help)} + url={branding?.logoUrl ?? null} + onUpload={(file) => uploadImage('logo', file)} + onRemove={() => removeImage('logo')} + disabled={!canManage || busy} + /> + <ImageField + label={t(keys.branding.manage.favicon_label)} + help={t(keys.branding.manage.favicon_help)} + url={branding?.faviconUrl ?? null} + onUpload={(file) => uploadImage('favicon', file)} + onRemove={() => removeImage('favicon')} + disabled={!canManage || busy} + /> + + <Button type="button" disabled={!canManage || busy} onClick={saveText}> + {busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.save_button)} + </Button> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>{t(keys.branding.manage.preview_title)}</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center gap-3 rounded-lg border p-4"> + <div + className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg text-white" + style={{ backgroundColor: color || DEFAULT_SWATCH }} + > + {branding?.logoUrl ? ( + <img + src={branding.logoUrl} + alt={appName} + className="h-full w-full object-contain" + /> + ) : ( + <span className="font-bold">{(appName.trim()[0] ?? 'S').toUpperCase()}</span> + )} + </div> + <span className="font-semibold">{appName || 'SimpleModule'}</span> + </div> + </CardContent> + </Card> + </div> + </PageShell> + </> + ); +} + +Manage.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>; + +export default Manage; diff --git a/modules/branding/branding/py.typed b/modules/branding/branding/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/branding/branding/service.py b/modules/branding/branding/service.py new file mode 100644 index 00000000..82733feb --- /dev/null +++ b/modules/branding/branding/service.py @@ -0,0 +1,61 @@ +"""Branding service — reads current branding and applies admin changes. + +There is no branding DB table: values live in the shared settings store. Writes +go through ``settings.reload.apply_changes_and_reload`` which validates against +``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding`` +and publishes ``SettingsReloaded``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from branding.constants import PACKAGE +from branding.contracts.schemas import BrandingOut +from branding.shared_props import file_url + +if TYPE_CHECKING: + from fastapi import FastAPI + from sqlalchemy.ext.asyncio import AsyncSession + + +class BrandingService: + """Read/update the application's branding.""" + + def __init__(self, app: FastAPI, db: AsyncSession) -> None: + self.app = app + self.db = db + + def current(self) -> BrandingOut: + settings = self.app.state.branding.settings + return BrandingOut( + app_name=settings.app_name, + primary_color=settings.primary_color, + logo_url=file_url(settings.logo_file_id), + favicon_url=file_url(settings.favicon_file_id), + ) + + async def apply(self, changes: dict[str, Any]) -> BrandingOut: + """Persist and hot-swap the given field changes, then return current.""" + # Plugin→plugin imports (settings is a declared dependency); kept local + # so import order during discovery stays tolerant. + from settings.reload import apply_changes_and_reload + from settings.service import SettingService + from settings.store import SettingsStore + + store = SettingsStore(SettingService(self.db)) + bus = self.app.state.sm.event_bus + await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes) + return self.current() + + async def set_logo(self, file_id: str) -> BrandingOut: + return await self.apply({"logo_file_id": file_id}) + + async def set_favicon(self, file_id: str) -> BrandingOut: + return await self.apply({"favicon_file_id": file_id}) + + async def clear_logo(self) -> BrandingOut: + return await self.apply({"logo_file_id": ""}) + + async def clear_favicon(self) -> BrandingOut: + return await self.apply({"favicon_file_id": ""}) diff --git a/modules/branding/branding/services.py b/modules/branding/branding/services.py new file mode 100644 index 00000000..91917947 --- /dev/null +++ b/modules/branding/branding/services.py @@ -0,0 +1,20 @@ +"""Module-scoped state container. + +Stored as ``app.state.branding`` by +:meth:`BrandingModule.register_settings` (via ``register_module_settings``). +``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when +an admin saves changes. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from branding.settings import BrandingSettings + + +@dataclass +class BrandingServices: + """Branding module singletons.""" + + settings: BrandingSettings diff --git a/modules/branding/branding/settings.py b/modules/branding/branding/settings.py new file mode 100644 index 00000000..0d85d87b --- /dev/null +++ b/modules/branding/branding/settings.py @@ -0,0 +1,47 @@ +"""Branding module settings — DB-backed via ``register_module_settings``. + +The four values an administrator can customise (app name, logo, favicon, +primary colour) are stored in the shared settings store at SYSTEM scope, +hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save +through ``settings.reload.apply_changes_and_reload``. + +Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the +frontend derives a download URL from the id. +""" + +from __future__ import annotations + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN + +DEFAULT_APP_NAME = "SimpleModule" + + +class BrandingSettings(BaseSettings): + """Customisable application identity.""" + + model_config = SettingsConfigDict(extra="ignore") + + app_name: str = DEFAULT_APP_NAME + primary_color: str = "" # "" = use the theme default; otherwise "#rrggbb" + logo_file_id: str = "" # file_storage UUID, "" = no custom logo + favicon_file_id: str = "" # file_storage UUID, "" = no custom favicon + + @field_validator("app_name") + @classmethod + def _non_empty_name(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("app_name must not be blank") + if len(cleaned) > MAX_APP_NAME_LEN: + raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters") + return cleaned + + @field_validator("primary_color") + @classmethod + def _valid_hex(cls, value: str) -> str: + if value and not HEX_COLOR_RE.match(value): + raise ValueError("primary_color must be a #rrggbb hex string or empty") + return value.lower() diff --git a/modules/branding/branding/shared_props.py b/modules/branding/branding/shared_props.py new file mode 100644 index 00000000..9b648847 --- /dev/null +++ b/modules/branding/branding/shared_props.py @@ -0,0 +1,44 @@ +"""Branding shared-props provider. + +Registered on ``app.state.inertia_shared_providers`` so every Inertia page +(authenticated *and* guest) receives a ``branding`` block in its shared props. +The frontend uses it for the app name, logo, favicon and primary colour. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from branding.constants import FILE_DOWNLOAD_URL + +if TYPE_CHECKING: + from starlette.requests import Request + + from branding.settings import BrandingSettings + + +def file_url(file_id: str) -> str | None: + """Build the file_storage download URL for a stored file id (or None).""" + return FILE_DOWNLOAD_URL.format(file_id=file_id) if file_id else None + + +def branding_payload(settings: BrandingSettings) -> dict: + """The camelCase branding block shared with the frontend.""" + return { + "appName": settings.app_name, + "primaryColor": settings.primary_color or None, + "logoUrl": file_url(settings.logo_file_id), + "faviconUrl": file_url(settings.favicon_file_id), + } + + +def branding_shared_props(request: Request) -> dict: + """Provider: emit ``{"branding": {...}}`` from the live module settings. + + Defensive — returns ``{}`` if the branding state isn't mounted yet, so a + half-booted app never errors a page render. + """ + services = getattr(request.app.state, "branding", None) + if services is None: + return {} + return {"branding": branding_payload(services.settings)} diff --git a/modules/branding/package.json b/modules/branding/package.json new file mode 100644 index 00000000..f6b6973c --- /dev/null +++ b/modules/branding/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module-py/branding", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Branding module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module-py/ui": "*" + }, + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/branding/pyproject.toml b/modules/branding/pyproject.toml new file mode 100644 index 00000000..4f3f955b --- /dev/null +++ b/modules/branding/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "simple_module_branding" +version = "0.1.0" +description = "Customisable application branding (name, logo, favicon, primary colour) for simple_module apps" +readme = "README.md" +license = "MIT" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "branding", "theming", "white-label"] +requires-python = ">=3.12" +dependencies = [ + "simple_module_core", + "simple_module_db", + "simple_module_hosting", + "simple_module_settings", + "simple_module_file_storage", +] + +[project.urls] +Repository = "https://github.com/antosubash/simple_module_python" + +[project.entry-points.simple_module] +branding = "branding.module:BrandingModule" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["branding"] + +# Ship the module-root package.json inside the wheel so the host can +# discover JS deps via importlib.resources after a pip install. +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "branding/package.json" + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +simple_module_hosting = { workspace = true } +simple_module_settings = { workspace = true } +simple_module_file_storage = { workspace = true } diff --git a/modules/branding/tests/test_branding.py b/modules/branding/tests/test_branding.py new file mode 100644 index 00000000..ef84ffcd --- /dev/null +++ b/modules/branding/tests/test_branding.py @@ -0,0 +1,168 @@ +"""Tests for the Branding module.""" + +from __future__ import annotations + +import uuid +from types import SimpleNamespace + +import httpx +import pytest +from branding.settings import BrandingSettings +from branding.shared_props import branding_payload, branding_shared_props + +# ── Unit: settings validation ────────────────────────────────────────── + + +def test_settings_defaults() -> None: + s = BrandingSettings() + assert s.app_name == "SimpleModule" + assert s.primary_color == "" + assert s.logo_file_id == "" + assert s.favicon_file_id == "" + + +def test_settings_app_name_trimmed_and_required() -> None: + assert BrandingSettings(app_name=" Acme ").app_name == "Acme" + with pytest.raises(ValueError): + BrandingSettings(app_name=" ") + with pytest.raises(ValueError): + BrandingSettings(app_name="x" * 61) + + +def test_settings_primary_color_validation() -> None: + assert BrandingSettings(primary_color="#1A7DD1").primary_color == "#1a7dd1" + assert BrandingSettings(primary_color="").primary_color == "" + with pytest.raises(ValueError): + BrandingSettings(primary_color="red") + with pytest.raises(ValueError): + BrandingSettings(primary_color="#fff") + + +# ── Unit: shared-props payload + provider ────────────────────────────── + + +def test_branding_payload_unset() -> None: + payload = branding_payload(BrandingSettings()) + assert payload == { + "appName": "SimpleModule", + "primaryColor": None, + "logoUrl": None, + "faviconUrl": None, + } + + +def test_branding_payload_set() -> None: + s = BrandingSettings( + app_name="Acme", + primary_color="#ff0000", + logo_file_id="abc-123", + favicon_file_id="def-456", + ) + payload = branding_payload(s) + assert payload["appName"] == "Acme" + assert payload["primaryColor"] == "#ff0000" + assert payload["logoUrl"] == "/api/file-storage/files/abc-123/download" + assert payload["faviconUrl"] == "/api/file-storage/files/def-456/download" + + +def test_provider_returns_empty_when_state_absent() -> None: + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + assert branding_shared_props(request) == {} # type: ignore[arg-type] + + +def test_provider_emits_branding_block() -> None: + state = SimpleNamespace(branding=SimpleNamespace(settings=BrandingSettings(app_name="Acme"))) + request = SimpleNamespace(app=SimpleNamespace(state=state)) + out = branding_shared_props(request) # type: ignore[arg-type] + assert out["branding"]["appName"] == "Acme" + + +# ── Integration: API + persistence + hot-swap ────────────────────────── + + +async def test_get_branding_returns_defaults(authenticated_client: httpx.AsyncClient) -> None: + resp = await authenticated_client.get("/api/branding/") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["app_name"] == "SimpleModule" + assert body["logo_url"] is None + + +async def test_update_persists_and_hot_swaps(app, authenticated_client: httpx.AsyncClient) -> None: + resp = await authenticated_client.put( + "/api/branding/", + json={"app_name": "Acme Corp", "primary_color": "#1A7DD1"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["app_name"] == "Acme Corp" + assert body["primary_color"] == "#1a7dd1" + + # Hot-swapped on app.state so the shared-props provider sees it immediately. + settings = app.state.branding.settings + assert settings.app_name == "Acme Corp" + assert branding_payload(settings)["appName"] == "Acme Corp" + + # Persisted: a fresh GET reflects the change. + again = await authenticated_client.get("/api/branding/") + assert again.json()["app_name"] == "Acme Corp" + + +async def test_update_rejects_bad_hex(authenticated_client: httpx.AsyncClient) -> None: + resp = await authenticated_client.put("/api/branding/", json={"primary_color": "nope"}) + assert resp.status_code == 422 + + +async def test_update_rejects_blank_app_name(authenticated_client: httpx.AsyncClient) -> None: + # A whitespace-only name must be a clean 422, not a 500 from BrandingSettings. + resp = await authenticated_client.put("/api/branding/", json={"app_name": " "}) + assert resp.status_code == 422 + + +async def test_logo_upload_rejects_non_image(authenticated_client: httpx.AsyncClient) -> None: + resp = await authenticated_client.post( + "/api/branding/logo", + files={"file": ("notes.txt", b"hello", "text/plain")}, + ) + assert resp.status_code == 415 + + +async def test_logo_upload_sets_logo_url( + app, authenticated_client: httpx.AsyncClient, monkeypatch +) -> None: + fake_id = uuid.uuid4() + + async def fake_upload(self, upload): + return SimpleNamespace(id=fake_id) + + monkeypatch.setattr("file_storage.service.FileStorageService.upload", fake_upload, raising=True) + + resp = await authenticated_client.post( + "/api/branding/logo", + files={"file": ("logo.png", b"\x89PNG\r\n", "image/png")}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["logo_url"] == f"/api/file-storage/files/{fake_id}/download" + assert app.state.branding.settings.logo_file_id == str(fake_id) + + # Clearing removes it. + cleared = await authenticated_client.delete("/api/branding/logo") + assert cleared.status_code == 200 + assert cleared.json()["logo_url"] is None + + +async def test_manage_view_requires_auth(client: httpx.AsyncClient) -> None: + resp = await client.get("/branding", follow_redirects=False) + assert resp.status_code in (302, 401, 403) + + +def test_page_constant_matches_view_literal() -> None: + # Guards against the inlined render literal drifting from the constant + # (the literal is required inline for SM003/SM004 static AST pairing). + import inspect + + from branding import constants + from branding.endpoints import views + + assert constants._PAGE_MANAGE == "Branding/Manage" + assert f'"{constants._PAGE_MANAGE}"' in inspect.getsource(views.manage) diff --git a/modules/branding/tsconfig.json b/modules/branding/tsconfig.json new file mode 100644 index 00000000..fcf70685 --- /dev/null +++ b/modules/branding/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@simple-module-py/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./branding/*"], + "@simple-module-py/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["branding/**/*.ts", "branding/**/*.tsx"] +} diff --git a/package-lock.json b/package-lock.json index ea56becf..434f876f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,19 @@ "react-dom": "^19.0.0" } }, + "modules/branding": { + "name": "@simple-module-py/branding", + "version": "0.1.0", + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "@simple-module-py/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/dashboard": { "name": "@simple-module-py/dashboard", "version": "0.1.0", @@ -4501,6 +4514,10 @@ "resolved": "modules/background_tasks", "link": true }, + "node_modules/@simple-module-py/branding": { + "resolved": "modules/branding", + "link": true + }, "node_modules/@simple-module-py/dashboard": { "resolved": "modules/dashboard", "link": true diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index d2415120..148f2683 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -76,6 +76,25 @@ export default { 'background_tasks.table.worker': '', 'background_tasks.toasts.retried': '', 'background_tasks.toasts.retry_failed': '', + 'branding.manage.app_name_help': '', + 'branding.manage.app_name_label': '', + 'branding.manage.description': '', + 'branding.manage.error_toast': '', + 'branding.manage.favicon_help': '', + 'branding.manage.favicon_label': '', + 'branding.manage.logo_help': '', + 'branding.manage.logo_label': '', + 'branding.manage.preview_title': '', + 'branding.manage.primary_color_help': '', + 'branding.manage.primary_color_label': '', + 'branding.manage.remove_button': '', + 'branding.manage.replace_button': '', + 'branding.manage.save_button': '', + 'branding.manage.saved_toast': '', + 'branding.manage.saving': '', + 'branding.manage.title': '', + 'branding.manage.upload_button': '', + 'branding.manage.upload_error_toast': '', 'dashboard.home.description': '', 'dashboard.home.description_body': '', 'dashboard.home.stats.active_users': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index f2de105a..26bddb50 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -109,6 +109,29 @@ export const keys = { retry_failed: 'background_tasks.toasts.retry_failed', }, }, + branding: { + manage: { + app_name_help: 'branding.manage.app_name_help', + app_name_label: 'branding.manage.app_name_label', + description: 'branding.manage.description', + error_toast: 'branding.manage.error_toast', + favicon_help: 'branding.manage.favicon_help', + favicon_label: 'branding.manage.favicon_label', + logo_help: 'branding.manage.logo_help', + logo_label: 'branding.manage.logo_label', + preview_title: 'branding.manage.preview_title', + primary_color_help: 'branding.manage.primary_color_help', + primary_color_label: 'branding.manage.primary_color_label', + remove_button: 'branding.manage.remove_button', + replace_button: 'branding.manage.replace_button', + save_button: 'branding.manage.save_button', + saved_toast: 'branding.manage.saved_toast', + saving: 'branding.manage.saving', + title: 'branding.manage.title', + upload_button: 'branding.manage.upload_button', + upload_error_toast: 'branding.manage.upload_error_toast', + }, + }, dashboard: { home: { description: 'dashboard.home.description', diff --git a/packages/ui/src/components/BrandingHead.test.tsx b/packages/ui/src/components/BrandingHead.test.tsx new file mode 100644 index 00000000..c9a6bc7b --- /dev/null +++ b/packages/ui/src/components/BrandingHead.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, render } from '@testing-library/react'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ branding: undefined as unknown })); + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { branding: state.branding } }), + Head: ({ children }: { children?: React.ReactNode }) => <>{children}</>, +})); + +import { BrandingHead } from './BrandingHead'; + +beforeEach(() => { + state.branding = undefined; + document.documentElement.style.removeProperty('--primary'); + document.documentElement.style.removeProperty('--sidebar-primary'); +}); + +afterEach(() => cleanup()); + +describe('BrandingHead', () => { + test('writes the primary colour as a CSS variable on :root', () => { + state.branding = { appName: 'Acme', primaryColor: '#ff0000', logoUrl: null, faviconUrl: null }; + render(<BrandingHead />); + expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#ff0000'); + expect(document.documentElement.style.getPropertyValue('--sidebar-primary')).toBe('#ff0000'); + }); + + test('renders a favicon link when a faviconUrl is set', () => { + state.branding = { + appName: 'Acme', + primaryColor: null, + logoUrl: null, + faviconUrl: '/api/file-storage/files/fav/download', + }; + render(<BrandingHead />); + // React 19 hoists <link> into <head>, so query the whole document. + const link = document.querySelector('link[rel="icon"]'); + expect(link).not.toBeNull(); + expect(link?.getAttribute('href')).toBe('/api/file-storage/files/fav/download'); + }); + + test('does not set the colour variable when none is configured', () => { + state.branding = { appName: 'Acme', primaryColor: null, logoUrl: null, faviconUrl: null }; + render(<BrandingHead />); + expect(document.documentElement.style.getPropertyValue('--primary')).toBe(''); + }); +}); diff --git a/packages/ui/src/components/BrandingHead.tsx b/packages/ui/src/components/BrandingHead.tsx new file mode 100644 index 00000000..008c9b00 --- /dev/null +++ b/packages/ui/src/components/BrandingHead.tsx @@ -0,0 +1,45 @@ +import { Head, usePage } from '@inertiajs/react'; +import { useEffect } from 'react'; +import type { SharedProps } from '../types'; + +const COLOR_VARS = ['--primary', '--sidebar-primary'] as const; + +/** + * Applies branding that lives in the document head / root, on every page: + * + * - the favicon `<link>` when a custom favicon is set, and + * - the primary brand colour, written as inline CSS variables on `:root` + * (inline wins over the stylesheet's `:root`/`.dark` rules; `--color-primary` + * already resolves to `var(--primary)`, so Tailwind `primary` utilities pick + * it up site-wide). + * + * Reads the `branding` shared prop, so it stays reactive across navigation. + */ +export function BrandingHead(): React.ReactElement | null { + const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const primaryColor = branding?.primaryColor ?? null; + const faviconUrl = branding?.faviconUrl ?? null; + + useEffect(() => { + const root = document.documentElement; + for (const v of COLOR_VARS) { + if (primaryColor) { + root.style.setProperty(v, primaryColor); + } else { + root.style.removeProperty(v); + } + } + return () => { + for (const v of COLOR_VARS) root.style.removeProperty(v); + }; + }, [primaryColor]); + + if (!faviconUrl) { + return null; + } + return ( + <Head> + <link rel="icon" href={faviconUrl} /> + </Head> + ); +} diff --git a/packages/ui/src/components/BrandingMark.test.tsx b/packages/ui/src/components/BrandingMark.test.tsx new file mode 100644 index 00000000..c340ca44 --- /dev/null +++ b/packages/ui/src/components/BrandingMark.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { BrandingMark } from './BrandingMark'; + +describe('BrandingMark', () => { + test('renders the app name as the wordmark', () => { + render(<BrandingMark appName="Acme Corp" accentColor="bg-blue-500" />); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + test('renders the custom logo when a logoUrl is provided', () => { + render( + <BrandingMark + appName="Acme" + logoUrl="/api/file-storage/files/abc/download" + accentColor="bg-blue-500" + />, + ); + const img = screen.getByRole('img', { name: 'Acme' }); + expect(img).toHaveAttribute('src', '/api/file-storage/files/abc/download'); + }); + + test('falls back to the app initial badge when there is no logo', () => { + render(<BrandingMark appName="Zephyr" accentColor="bg-blue-500" />); + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByText('Z')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/BrandingMark.tsx b/packages/ui/src/components/BrandingMark.tsx new file mode 100644 index 00000000..7f144eeb --- /dev/null +++ b/packages/ui/src/components/BrandingMark.tsx @@ -0,0 +1,51 @@ +import type React from 'react'; + +interface BrandingMarkProps { + /** Application name — shown as the wordmark and used for the fallback initial. */ + appName: string; + /** Custom logo URL. When absent, a generated initial badge is shown. */ + logoUrl?: string | null; + /** Tailwind classes for the fallback badge background (e.g. a gradient). */ + accentColor: string; + /** Visual size — `sm` for the mobile bar, `md` for the desktop sidebar. */ + size?: 'sm' | 'md'; + /** Classes for the wordmark text (colour/spacing supplied by the layout). */ + labelClassName?: string; +} + +/** + * App logo + wordmark. Renders the uploaded logo when set, otherwise a badge + * with the app's initial. Pure/presentational so it can be unit-tested without + * Inertia context. + */ +export function BrandingMark({ + appName, + logoUrl, + accentColor, + size = 'md', + labelClassName, +}: BrandingMarkProps): React.ReactElement { + const box = size === 'sm' ? 'w-7 h-7 rounded-md' : 'w-8 h-8 rounded-lg'; + const labelSize = size === 'sm' ? 'text-base' : 'text-lg'; + const initial = appName.trim().charAt(0).toUpperCase() || 'S'; + + return ( + <> + {logoUrl ? ( + <img src={logoUrl} alt={appName} className={`${box} object-contain bg-white/5 shadow-sm`} /> + ) : ( + <div className={`${box} ${accentColor} flex items-center justify-center shadow-sm`}> + <span className="text-white font-bold text-xs font-[var(--font-display)]">{initial}</span> + </div> + )} + <span + className={ + labelClassName ?? + `${labelSize} font-semibold text-white font-[var(--font-display)] tracking-tight` + } + > + {appName} + </span> + </> + ); +} diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx index 1a8fcf7a..96bdf181 100644 --- a/packages/ui/src/layouts/AuthCardShell.tsx +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -1,4 +1,5 @@ import type React from 'react'; +import { BrandingHead } from '../components/BrandingHead'; /** * Full-viewport centered shell for unauthenticated flows @@ -10,6 +11,7 @@ import type React from 'react'; export function AuthCardShell({ children }: { children: React.ReactNode }) { return ( <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-secondary/40 p-4"> + <BrandingHead /> <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden"> <div className="absolute -top-[10%] -right-[10%] h-[600px] w-[600px] rounded-full bg-primary-600 opacity-15 blur-[100px]" /> <div className="absolute -bottom-[10%] -left-[10%] h-[500px] w-[500px] rounded-full bg-primary-800 opacity-15 blur-[100px]" /> diff --git a/packages/ui/src/layouts/PublicLayout.tsx b/packages/ui/src/layouts/PublicLayout.tsx index 2244166c..b7075521 100644 --- a/packages/ui/src/layouts/PublicLayout.tsx +++ b/packages/ui/src/layouts/PublicLayout.tsx @@ -3,28 +3,40 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Menu, X } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingHead } from '../components/BrandingHead'; import { LocaleSwitcher } from '../components/LocaleSwitcher'; import type { SharedProps } from '../types'; export function PublicLayout({ children }: { children: React.ReactNode }) { - const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; const [menuOpen, setMenuOpen] = useState(false); + const appName = branding?.appName ?? 'simple_module'; + const logoUrl = branding?.logoUrl ?? null; + const brandInitial = appName.trim().charAt(0).toUpperCase() || 'S'; return ( <div className="min-h-screen flex flex-col bg-background text-foreground"> + <BrandingHead /> <nav className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl backdrop-saturate-150"> <div className="mx-auto max-w-6xl px-4 py-3 sm:px-8 sm:py-3.5"> <div className="flex items-center justify-between"> <Link href="/" className="group flex items-center gap-2.5"> - <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-800 shadow-md shadow-primary-600/30 transition-transform group-hover:scale-105"> - <span className="font-bold text-white text-sm font-[var(--font-display)]">S</span> - </div> - <div className="flex flex-col leading-tight"> - <span className="text-[15px] font-bold tracking-tight font-[var(--font-display)]"> - simple_module - </span> - <span className="font-mono text-[10px] text-muted-foreground">python · v0.1</span> - </div> + {logoUrl ? ( + <img + src={logoUrl} + alt={appName} + className="h-8 w-8 rounded-lg object-contain shadow-md transition-transform group-hover:scale-105" + /> + ) : ( + <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-800 shadow-md shadow-primary-600/30 transition-transform group-hover:scale-105"> + <span className="font-bold text-white text-sm font-[var(--font-display)]"> + {brandInitial} + </span> + </div> + )} + <span className="text-[15px] font-bold tracking-tight font-[var(--font-display)]"> + {appName} + </span> </Link> <div className="hidden items-center gap-4 sm:flex"> diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index b957a0c7..e2afe57a 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -18,6 +18,8 @@ import { import { ChevronsUpDown } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingHead } from '../components/BrandingHead'; +import { BrandingMark } from '../components/BrandingMark'; import { NavIcon } from '../components/NavIcon'; import type { MenuItem, SharedProps } from '../types'; @@ -64,8 +66,10 @@ export function SidebarLayout({ footerNavSlot, }: SidebarLayoutProps) { const page = usePage<{ props: SharedProps }>(); - const { auth, menus } = page.props as unknown as SharedProps; + const { auth, menus, branding } = page.props as unknown as SharedProps; const currentUrl = page.url; + const appName = branding?.appName ?? theme.mobileTitleLabel; + const logoUrl = branding?.logoUrl ?? null; const [sidebarOpen, setSidebarOpen] = useState(false); const closeSidebar = () => setSidebarOpen(false); @@ -73,6 +77,7 @@ export function SidebarLayout({ return ( <TooltipProvider> + <BrandingHead /> <div className="min-h-screen bg-background"> {/* Mobile top bar */} <div @@ -101,14 +106,12 @@ export function SidebarLayout({ </svg> </Button> <Link href="/dashboard/" className="flex items-center gap-2"> - <div - className={`w-7 h-7 rounded-md ${theme.accentColor} flex items-center justify-center shadow-sm`} - > - <span className="text-white font-bold text-xs font-[var(--font-display)]">SM</span> - </div> - <span className="text-base font-semibold text-white font-[var(--font-display)]"> - {theme.mobileTitleLabel} - </span> + <BrandingMark + appName={appName} + logoUrl={logoUrl} + accentColor={theme.accentColor} + size="sm" + /> </Link> </div> @@ -129,14 +132,12 @@ export function SidebarLayout({ {/* Logo */} <div className="h-14 lg:h-16 flex items-center justify-between px-4 lg:px-5 border-b border-white/[0.06]"> <Link href="/dashboard/" className="flex items-center gap-2.5 group"> - <div - className={`w-8 h-8 rounded-lg ${theme.accentColor} flex items-center justify-center shadow-lg shadow-primary-500/15 transition-transform duration-200 group-hover:scale-105`} - > - <span className="text-white font-bold text-sm font-[var(--font-display)]">SM</span> - </div> - <span className="text-lg font-semibold text-white font-[var(--font-display)] tracking-tight"> - SimpleModule - </span> + <BrandingMark + appName={appName} + logoUrl={logoUrl} + accentColor={theme.accentColor} + size="md" + /> </Link> <Button variant="ghost" diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index f2eb292d..2557b805 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -6,6 +6,13 @@ export interface MenuItem { group?: string; } +export interface BrandingShared { + appName: string; + primaryColor: string | null; + logoUrl: string | null; + faviconUrl: string | null; +} + export interface SharedProps { auth: { user: { name: string; email: string; roles: string[] } | null; @@ -18,4 +25,7 @@ export interface SharedProps { navbar: MenuItem[]; userDropdown: MenuItem[]; }; + // Injected by the branding module's shared-props provider (optional: the + // module may not be installed). + branding?: BrandingShared; } diff --git a/pyproject.toml b/pyproject.toml index edfede6e..57dca1d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ extra-paths = [ "modules/feature_flags", "modules/keycloak", "modules/audit_log", + "modules/branding", "host", "scripts", ] @@ -109,7 +110,7 @@ invalid-assignment = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "modules/branding/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/tests/integration/test_branding_flow.py b/tests/integration/test_branding_flow.py new file mode 100644 index 00000000..8764d697 --- /dev/null +++ b/tests/integration/test_branding_flow.py @@ -0,0 +1,42 @@ +"""End-to-end integration test for the branding flow. + +Exercises the full path against a real ``create_app()``: PUT /api/branding +persists + hot-swaps the settings, the branding shared-props provider reads the +live value, ``InertiaLayoutDataMiddleware`` merges it, and an Inertia page +request carries the ``branding`` block in its shared props. +""" + +from __future__ import annotations + +import httpx + + +async def test_branding_appears_in_inertia_shared_props( + app, + authenticated_client: httpx.AsyncClient, +) -> None: + # Default branding is present on every page. + resp = await authenticated_client.get( + "/dashboard/", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + assert resp.json()["props"]["branding"]["appName"] == "SimpleModule" + + # Change it through the admin API. + put = await authenticated_client.put( + "/api/branding/", + json={"app_name": "Acme End2End", "primary_color": "#abcdef"}, + ) + assert put.status_code == 200, put.text + + # The next page load reflects the new branding in its shared props. + resp = await authenticated_client.get( + "/dashboard/", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + branding = resp.json()["props"]["branding"] + assert branding["appName"] == "Acme End2End" + assert branding["primaryColor"] == "#abcdef" + assert branding["logoUrl"] is None