simple-module-branding 0.0.24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
43
+ # Auto-generated frontend module manifest (regenerated by the host at boot
44
+ # or via `make gen-pages`).
45
+ host/client_app/modules.manifest.json
46
+ host/client_app/modules.generated.ts
47
+ host/client_app/modules.generated.css
48
+
49
+ # Worktrees
50
+ .worktrees/
51
+
52
+ # Performance profiles
53
+ .memray/
54
+ .benchmarks/
55
+
56
+ # OS
57
+ .DS_Store
58
+ Thumbs.db
59
+
60
+ .playwright-cli/*
61
+ .playwright-mcp/*
62
+ host/client_app/.playwright-cli/*
63
+ .superpowers/
64
+ .qa/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_branding
3
+ Version: 0.0.24
4
+ Summary: Customisable application branding (name, logo, favicon, primary colour) for simple_module apps
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: branding,simple-module,theming,white-label
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: simple-module-core==0.0.24
11
+ Requires-Dist: simple-module-db==0.0.24
12
+ Requires-Dist: simple-module-file-storage==0.0.24
13
+ Requires-Dist: simple-module-hosting==0.0.24
14
+ Requires-Dist: simple-module-settings==0.0.24
15
+ Description-Content-Type: text/markdown
16
+
17
+ # simple_module_branding
18
+
19
+ Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.
20
+
21
+ An administrator can set the **application name**, **logo**, **favicon** and
22
+ **primary brand colour** from the admin UI (`/branding`), and those values are
23
+ applied everywhere the framework would otherwise show the default identity —
24
+ the sidebar/header logo and name, the browser tab title, the favicon, and the
25
+ primary accent colour.
26
+
27
+ ## Screenshots
28
+
29
+ The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
30
+ (custom logo + name + primary colour) across the sidebar and dashboard:
31
+
32
+ | Admin page | Branding applied | Across the app |
33
+ |---|---|---|
34
+ | ![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) |
35
+
36
+ ## Install
37
+
38
+ The module ships with the default app. To add it to a custom host, declare it
39
+ as a dependency and let entry-point discovery pick it up:
40
+
41
+ ```toml
42
+ # host/pyproject.toml
43
+ dependencies = ["simple_module_branding"]
44
+
45
+ [tool.uv.sources]
46
+ simple_module_branding = { workspace = true }
47
+ ```
48
+
49
+ Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
50
+ modules to be installed too.
51
+
52
+ ## Usage
53
+
54
+ 1. Sign in as an admin and open **Branding** in the sidebar (or visit
55
+ `/branding`).
56
+ 2. Set the application name, pick a primary colour, and upload a logo and/or
57
+ favicon. Changes apply immediately across the app.
58
+
59
+ Programmatically, the current branding is available on every page through the
60
+ `branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`,
61
+ `faviconUrl`).
62
+
63
+ ## How it works
64
+
65
+ - **Storage.** The four values are persisted via the `settings` module's store
66
+ (SYSTEM scope) — there is no branding database table. They hydrate into
67
+ `app.state.branding.settings` at boot and hot-swap on save.
68
+ - **Images.** Logo and favicon uploads are stored through the `file_storage`
69
+ module (referenced by UUID) and served from its download endpoint.
70
+ - **Delivery.** A registered Inertia shared-props provider injects a `branding`
71
+ block into every page's shared props (authenticated *and* guest), which the
72
+ frontend reads for the name, logo, favicon and colour.
73
+
74
+ ## Permissions
75
+
76
+ - `branding.view` — view the branding admin page.
77
+ - `branding.manage` — change branding (name, colour, logo, favicon).
78
+
79
+ ## Dependencies
80
+
81
+ Depends on the `Settings` and `FileStorage` modules.
82
+
83
+ ## Notes
84
+
85
+ - Branding is currently SYSTEM-scoped (one identity per deployment). The
86
+ settings store already supports tenant/user scope, leaving room for
87
+ per-tenant branding later.
88
+ - The primary colour overrides the `--primary` / `--sidebar-primary` CSS
89
+ variables from a single hex; the full OKLCH colour scale is not regenerated.
90
+
91
+ License: MIT
@@ -0,0 +1,75 @@
1
+ # simple_module_branding
2
+
3
+ Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.
4
+
5
+ An administrator can set the **application name**, **logo**, **favicon** and
6
+ **primary brand colour** from the admin UI (`/branding`), and those values are
7
+ applied everywhere the framework would otherwise show the default identity —
8
+ the sidebar/header logo and name, the browser tab title, the favicon, and the
9
+ primary accent colour.
10
+
11
+ ## Screenshots
12
+
13
+ The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
14
+ (custom logo + name + primary colour) across the sidebar and dashboard:
15
+
16
+ | Admin page | Branding applied | Across the app |
17
+ |---|---|---|
18
+ | ![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) |
19
+
20
+ ## Install
21
+
22
+ The module ships with the default app. To add it to a custom host, declare it
23
+ as a dependency and let entry-point discovery pick it up:
24
+
25
+ ```toml
26
+ # host/pyproject.toml
27
+ dependencies = ["simple_module_branding"]
28
+
29
+ [tool.uv.sources]
30
+ simple_module_branding = { workspace = true }
31
+ ```
32
+
33
+ Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
34
+ modules to be installed too.
35
+
36
+ ## Usage
37
+
38
+ 1. Sign in as an admin and open **Branding** in the sidebar (or visit
39
+ `/branding`).
40
+ 2. Set the application name, pick a primary colour, and upload a logo and/or
41
+ favicon. Changes apply immediately across the app.
42
+
43
+ Programmatically, the current branding is available on every page through the
44
+ `branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`,
45
+ `faviconUrl`).
46
+
47
+ ## How it works
48
+
49
+ - **Storage.** The four values are persisted via the `settings` module's store
50
+ (SYSTEM scope) — there is no branding database table. They hydrate into
51
+ `app.state.branding.settings` at boot and hot-swap on save.
52
+ - **Images.** Logo and favicon uploads are stored through the `file_storage`
53
+ module (referenced by UUID) and served from its download endpoint.
54
+ - **Delivery.** A registered Inertia shared-props provider injects a `branding`
55
+ block into every page's shared props (authenticated *and* guest), which the
56
+ frontend reads for the name, logo, favicon and colour.
57
+
58
+ ## Permissions
59
+
60
+ - `branding.view` — view the branding admin page.
61
+ - `branding.manage` — change branding (name, colour, logo, favicon).
62
+
63
+ ## Dependencies
64
+
65
+ Depends on the `Settings` and `FileStorage` modules.
66
+
67
+ ## Notes
68
+
69
+ - Branding is currently SYSTEM-scoped (one identity per deployment). The
70
+ settings store already supports tenant/user scope, leaving room for
71
+ per-tenant branding later.
72
+ - The primary colour overrides the `--primary` / `--sidebar-primary` CSS
73
+ variables from a single hex; the full OKLCH colour scale is not regenerated.
74
+
75
+ License: MIT
@@ -0,0 +1 @@
1
+ """Branding module."""
@@ -0,0 +1,65 @@
1
+ """Branding module constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Final
7
+
8
+ from file_storage.constants import PATH_FILE_DOWNLOAD, ROUTE_PREFIX_API
9
+
10
+ PACKAGE: Final = "branding"
11
+
12
+ # Modules this one depends on (kept as constants so the depends_on list doesn't
13
+ # carry bare string literals — see scripts/check_hardcoded_strings.py).
14
+ _MODULE_SETTINGS: Final = "Settings"
15
+ _MODULE_FILE_STORAGE: Final = "FileStorage"
16
+
17
+ # Inertia page identifier. The view endpoint renders this as a literal (so the
18
+ # SM003/SM004 static-AST diagnostics can pair it with pages/Manage.tsx); a test
19
+ # asserts the literal matches this constant.
20
+ _PAGE_MANAGE: Final = "Branding/Manage"
21
+
22
+ PERM_VIEW: Final = "branding.view"
23
+ PERM_MANAGE: Final = "branding.manage"
24
+
25
+ # A #rrggbb hex colour (single source of truth for both the settings and the
26
+ # update-DTO validators).
27
+ HEX_COLOR_RE: Final = re.compile(r"^#[0-9a-fA-F]{6}$")
28
+ MAX_APP_NAME_LEN: Final = 60
29
+
30
+ # Image upload guard-rails (enforced before handing the file to file_storage).
31
+ MAX_IMAGE_BYTES: Final = 2 * 1024 * 1024 # 2 MB
32
+ ALLOWED_IMAGE_TYPES: Final = frozenset(
33
+ {
34
+ "image/png",
35
+ "image/jpeg",
36
+ "image/svg+xml",
37
+ "image/webp",
38
+ "image/gif",
39
+ "image/x-icon",
40
+ "image/vnd.microsoft.icon",
41
+ }
42
+ )
43
+
44
+ # file_storage download URL, derived from file_storage's own route constants
45
+ # (branding depends on FileStorage) so it tracks any route change. The
46
+ # ``{file_id}`` placeholder is filled per stored-file id.
47
+ FILE_DOWNLOAD_URL: Final = ROUTE_PREFIX_API + PATH_FILE_DOWNLOAD
48
+
49
+
50
+ def clean_app_name(value: str) -> str:
51
+ """Normalise + validate an app name (shared by the settings + update DTO).
52
+
53
+ The name is surfaced in HTML titles and—critically—email ``Subject``
54
+ headers, so control characters (notably CR/LF) must be rejected: an
55
+ embedded newline would otherwise pass a bare ``strip()`` and then raise
56
+ when set as a header, breaking every transactional email.
57
+ """
58
+ cleaned = value.strip()
59
+ if not cleaned:
60
+ raise ValueError("app_name must not be blank")
61
+ if len(cleaned) > MAX_APP_NAME_LEN:
62
+ raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters")
63
+ if any(ord(ch) < 0x20 for ch in cleaned):
64
+ raise ValueError("app_name must not contain control characters")
65
+ return cleaned
@@ -0,0 +1,8 @@
1
+ """Branding contracts — public interface for other modules."""
2
+
3
+ from branding.contracts.schemas import BrandingOut, BrandingUpdate
4
+
5
+ __all__ = [
6
+ "BrandingOut",
7
+ "BrandingUpdate",
8
+ ]
@@ -0,0 +1,43 @@
1
+ """SQLModel DTOs for the Branding module — the public surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import field_validator
6
+ from sqlmodel import Field, SQLModel
7
+
8
+ from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN, clean_app_name
9
+
10
+
11
+ class BrandingOut(SQLModel):
12
+ """Current branding, with logo/favicon resolved to download URLs."""
13
+
14
+ app_name: str
15
+ primary_color: str = ""
16
+ logo_url: str | None = None
17
+ favicon_url: str | None = None
18
+
19
+
20
+ class BrandingUpdate(SQLModel):
21
+ """Editable text fields. Logo/favicon are set via dedicated upload routes."""
22
+
23
+ app_name: str | None = Field(default=None, max_length=MAX_APP_NAME_LEN)
24
+ primary_color: str | None = Field(default=None)
25
+
26
+ @field_validator("app_name")
27
+ @classmethod
28
+ def _non_empty_name(cls, value: str | None) -> str | None:
29
+ # Validate here so bad input surfaces as a 422 rather than a 500 when
30
+ # BrandingSettings re-validates (blank, too long, or control chars —
31
+ # the last would otherwise break email Subject headers downstream).
32
+ if value is None:
33
+ return None
34
+ return clean_app_name(value)
35
+
36
+ @field_validator("primary_color")
37
+ @classmethod
38
+ def _valid_hex(cls, value: str | None) -> str | None:
39
+ if value is None:
40
+ return None
41
+ if value != "" and not HEX_COLOR_RE.match(value):
42
+ raise ValueError("primary_color must be a #rrggbb hex string or empty")
43
+ return value.lower()
@@ -0,0 +1,21 @@
1
+ """FastAPI dependencies for the Branding module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from simple_module_db.deps import get_db
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from branding.service import BrandingService
12
+
13
+
14
+ async def get_branding_service(
15
+ request: Request,
16
+ db: AsyncSession = Depends(get_db),
17
+ ) -> BrandingService:
18
+ return BrandingService(request.app, db)
19
+
20
+
21
+ BrandingServiceDep = Annotated[BrandingService, Depends(get_branding_service)]
@@ -0,0 +1,74 @@
1
+ """REST API endpoints for Branding (JSON). All writes require branding.manage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile
6
+ from file_storage.deps import get_file_storage_service
7
+ from file_storage.service import FileStorageService
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from branding import constants
11
+ from branding.contracts.schemas import BrandingOut, BrandingUpdate
12
+ from branding.deps import BrandingServiceDep
13
+
14
+ router = APIRouter()
15
+
16
+ _MANAGE = Depends(RequiresPermission(constants.PERM_MANAGE))
17
+
18
+
19
+ def _validate_image(file: UploadFile) -> None:
20
+ if file.content_type not in constants.ALLOWED_IMAGE_TYPES:
21
+ raise HTTPException(
22
+ status_code=415,
23
+ detail=f"Unsupported image type {file.content_type!r}.",
24
+ )
25
+ if file.size is not None and file.size > constants.MAX_IMAGE_BYTES:
26
+ raise HTTPException(
27
+ status_code=413,
28
+ detail=f"Image exceeds {constants.MAX_IMAGE_BYTES} bytes.",
29
+ )
30
+
31
+
32
+ @router.get("/", response_model=BrandingOut, dependencies=[_MANAGE])
33
+ async def get_branding(service: BrandingServiceDep) -> BrandingOut:
34
+ return service.current()
35
+
36
+
37
+ @router.put("/", response_model=BrandingOut, dependencies=[_MANAGE])
38
+ async def update_branding(data: BrandingUpdate, service: BrandingServiceDep) -> BrandingOut:
39
+ changes = {k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None}
40
+ if not changes:
41
+ return service.current()
42
+ return await service.apply(changes)
43
+
44
+
45
+ @router.post("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
46
+ async def upload_logo(
47
+ service: BrandingServiceDep,
48
+ file: UploadFile,
49
+ storage: FileStorageService = Depends(get_file_storage_service),
50
+ ) -> BrandingOut:
51
+ _validate_image(file)
52
+ stored = await storage.upload(file)
53
+ return await service.set_logo(str(stored.id))
54
+
55
+
56
+ @router.post("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
57
+ async def upload_favicon(
58
+ service: BrandingServiceDep,
59
+ file: UploadFile,
60
+ storage: FileStorageService = Depends(get_file_storage_service),
61
+ ) -> BrandingOut:
62
+ _validate_image(file)
63
+ stored = await storage.upload(file)
64
+ return await service.set_favicon(str(stored.id))
65
+
66
+
67
+ @router.delete("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
68
+ async def clear_logo(service: BrandingServiceDep) -> BrandingOut:
69
+ return await service.clear_logo()
70
+
71
+
72
+ @router.delete("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
73
+ async def clear_favicon(service: BrandingServiceDep) -> BrandingOut:
74
+ return await service.clear_favicon()
@@ -0,0 +1,29 @@
1
+ """Inertia view endpoints for Branding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from inertia import InertiaResponse
7
+ from simple_module_hosting.inertia_deps import InertiaDep
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from branding import constants
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get(
16
+ "/",
17
+ response_model=None,
18
+ dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))],
19
+ )
20
+ async def manage(inertia: InertiaDep) -> InertiaResponse:
21
+ # Current branding is delivered through the shared ``branding`` prop
22
+ # (the branding shared-props provider), so no page props are needed.
23
+ # The page name is inlined as a literal (rather than constants._PAGE_MANAGE)
24
+ # so the SM003/SM004 diagnostics — which do static AST analysis and can't
25
+ # resolve attribute access — pair this call with pages/Manage.tsx. A unit
26
+ # test asserts the literal matches constants._PAGE_MANAGE.
27
+ return await inertia.render(
28
+ "Branding/Manage",
29
+ )
@@ -0,0 +1,23 @@
1
+ {
2
+ "manage": {
3
+ "title": "Branding",
4
+ "description": "Customise your application's name, logo, favicon and primary colour.",
5
+ "app_name_label": "Application name",
6
+ "app_name_help": "Shown in the sidebar, the browser tab and on sign-in.",
7
+ "primary_color_label": "Primary colour",
8
+ "primary_color_help": "Accent colour used across buttons and highlights. Leave blank for the default.",
9
+ "logo_label": "Logo",
10
+ "logo_help": "Square PNG or SVG works best. Replaces the default badge in the sidebar.",
11
+ "favicon_label": "Favicon",
12
+ "favicon_help": "Small icon shown in the browser tab. PNG, SVG or ICO.",
13
+ "upload_button": "Upload",
14
+ "replace_button": "Replace",
15
+ "remove_button": "Remove",
16
+ "save_button": "Save changes",
17
+ "saving": "Saving…",
18
+ "preview_title": "Preview",
19
+ "saved_toast": "Branding updated",
20
+ "error_toast": "Could not update branding",
21
+ "upload_error_toast": "Could not upload image"
22
+ }
23
+ }
@@ -0,0 +1,77 @@
1
+ """Branding module definition.
2
+
3
+ Lets an administrator customise the app name, logo, favicon and primary colour.
4
+ Values persist in the shared settings store (no branding table) and reach every
5
+ page through a registered Inertia shared-props provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.resources
11
+ from pathlib import Path
12
+
13
+ from fastapi import APIRouter, FastAPI
14
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
15
+ from simple_module_core.module import ModuleBase, ModuleMeta
16
+ from simple_module_core.permissions import PermissionRegistry
17
+
18
+ from branding import constants
19
+
20
+
21
+ class BrandingModule(ModuleBase):
22
+ meta = ModuleMeta(
23
+ name="Branding",
24
+ route_prefix="/api/branding",
25
+ view_prefix="/branding",
26
+ depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE],
27
+ )
28
+
29
+ def register_settings(self, app: FastAPI) -> None:
30
+ from settings.registration import register_module_settings
31
+
32
+ from branding.services import BrandingServices
33
+ from branding.settings import BrandingSettings
34
+
35
+ register_module_settings(
36
+ app,
37
+ constants.PACKAGE,
38
+ BrandingSettings,
39
+ lambda s: BrandingServices(settings=s),
40
+ )
41
+
42
+ def register_permissions(self, registry: PermissionRegistry) -> None:
43
+ registry.add_group(
44
+ "Branding",
45
+ [constants.PERM_VIEW, constants.PERM_MANAGE],
46
+ )
47
+
48
+ def register_menu_items(self, registry: MenuRegistry) -> None:
49
+ registry.add(
50
+ MenuItem(
51
+ label="Branding",
52
+ url="/branding",
53
+ icon="palette",
54
+ order=115,
55
+ section=MenuSection.SIDEBAR,
56
+ group="Administration",
57
+ roles=["admin"],
58
+ )
59
+ )
60
+
61
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
62
+ from branding.endpoints.api import router as api
63
+ from branding.endpoints.views import router as views
64
+
65
+ api_router.include_router(api)
66
+ view_router.include_router(views)
67
+
68
+ async def on_startup(self, app: FastAPI) -> None:
69
+ from simple_module_hosting.shared_props import register_inertia_shared_provider
70
+
71
+ from branding.shared_props import branding_shared_props
72
+
73
+ register_inertia_shared_provider(app, branding_shared_props)
74
+
75
+ def locale_dirs(self) -> dict[str, Path]:
76
+ base = Path(str(importlib.resources.files(__package__) / "locales"))
77
+ return {constants.PACKAGE: base}
@@ -0,0 +1,261 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
+ import { Button } from '@simple-module-py/ui/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@simple-module-py/ui/components/ui/card';
12
+ import { Input } from '@simple-module-py/ui/components/ui/input';
13
+ import { Label } from '@simple-module-py/ui/components/ui/label';
14
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
15
+ import type { SharedProps } from '@simple-module-py/ui/types';
16
+ import { type ChangeEvent, useRef, useState } from 'react';
17
+ import { toast } from 'sonner';
18
+
19
+ const DEFAULT_SWATCH = '#10b981';
20
+ type ImageKind = 'logo' | 'favicon';
21
+
22
+ async function readError(res: Response): Promise<string> {
23
+ try {
24
+ const body = await res.json();
25
+ return typeof body?.detail === 'string' ? body.detail : res.statusText;
26
+ } catch {
27
+ return res.statusText;
28
+ }
29
+ }
30
+
31
+ function ImageField({
32
+ label,
33
+ help,
34
+ url,
35
+ onUpload,
36
+ onRemove,
37
+ disabled,
38
+ }: {
39
+ label: string;
40
+ help: string;
41
+ url: string | null;
42
+ onUpload: (file: File) => void;
43
+ onRemove: () => void;
44
+ disabled: boolean;
45
+ }) {
46
+ const { t } = useT();
47
+ const inputRef = useRef<HTMLInputElement>(null);
48
+
49
+ return (
50
+ <div className="space-y-2">
51
+ <Label>{label}</Label>
52
+ <div className="flex items-center gap-4">
53
+ <div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-lg border bg-muted">
54
+ {url ? (
55
+ <img src={url} alt={label} className="h-full w-full object-contain" />
56
+ ) : (
57
+ <span className="text-xs text-muted-foreground">—</span>
58
+ )}
59
+ </div>
60
+ <div className="flex gap-2">
61
+ <input
62
+ ref={inputRef}
63
+ type="file"
64
+ accept="image/*"
65
+ className="hidden"
66
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
67
+ const file = e.target.files?.[0];
68
+ if (file) onUpload(file);
69
+ e.target.value = '';
70
+ }}
71
+ />
72
+ <Button
73
+ type="button"
74
+ variant="outline"
75
+ size="sm"
76
+ disabled={disabled}
77
+ onClick={() => inputRef.current?.click()}
78
+ >
79
+ {url ? t(keys.branding.manage.replace_button) : t(keys.branding.manage.upload_button)}
80
+ </Button>
81
+ {url && (
82
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={onRemove}>
83
+ {t(keys.branding.manage.remove_button)}
84
+ </Button>
85
+ )}
86
+ </div>
87
+ </div>
88
+ <p className="text-xs text-muted-foreground">{help}</p>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ function Manage() {
94
+ const { t } = useT();
95
+ const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
96
+ const canManage = auth?.permissions?.includes('branding.manage');
97
+
98
+ const [appName, setAppName] = useState(branding?.appName ?? '');
99
+ const [color, setColor] = useState(branding?.primaryColor ?? '');
100
+ const [busy, setBusy] = useState(false);
101
+
102
+ async function run(work: () => Promise<Response>, errorMsg: string) {
103
+ setBusy(true);
104
+ try {
105
+ const res = await work();
106
+ if (!res.ok) throw new Error(await readError(res));
107
+ toast.success(t(keys.branding.manage.saved_toast));
108
+ router.reload();
109
+ } catch (err) {
110
+ toast.error(`${errorMsg}: ${(err as Error).message}`);
111
+ } finally {
112
+ setBusy(false);
113
+ }
114
+ }
115
+
116
+ const saveText = () =>
117
+ run(
118
+ () =>
119
+ fetch('/api/branding/', {
120
+ method: 'PUT',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ app_name: appName, primary_color: color }),
123
+ }),
124
+ t(keys.branding.manage.error_toast),
125
+ );
126
+
127
+ const uploadImage = (kind: ImageKind, file: File) => {
128
+ const form = new FormData();
129
+ form.append('file', file);
130
+ return run(
131
+ () => fetch(`/api/branding/${kind}`, { method: 'POST', body: form }),
132
+ t(keys.branding.manage.upload_error_toast),
133
+ );
134
+ };
135
+
136
+ const removeImage = (kind: ImageKind) =>
137
+ run(
138
+ () => fetch(`/api/branding/${kind}`, { method: 'DELETE' }),
139
+ t(keys.branding.manage.error_toast),
140
+ );
141
+
142
+ return (
143
+ <>
144
+ <Head title={t(keys.branding.manage.title)} />
145
+ <PageShell
146
+ title={t(keys.branding.manage.title)}
147
+ description={t(keys.branding.manage.description)}
148
+ >
149
+ <div className="grid gap-6 lg:grid-cols-3">
150
+ <Card className="lg:col-span-2">
151
+ <CardHeader>
152
+ <CardTitle>{t(keys.branding.manage.title)}</CardTitle>
153
+ <CardDescription>{t(keys.branding.manage.description)}</CardDescription>
154
+ </CardHeader>
155
+ <CardContent className="space-y-6">
156
+ <div className="space-y-2">
157
+ <Label htmlFor="app_name">{t(keys.branding.manage.app_name_label)}</Label>
158
+ <Input
159
+ id="app_name"
160
+ value={appName}
161
+ maxLength={60}
162
+ disabled={!canManage || busy}
163
+ onChange={(e) => setAppName(e.target.value)}
164
+ />
165
+ <p className="text-xs text-muted-foreground">
166
+ {t(keys.branding.manage.app_name_help)}
167
+ </p>
168
+ </div>
169
+
170
+ <div className="space-y-2">
171
+ <Label htmlFor="primary_color">{t(keys.branding.manage.primary_color_label)}</Label>
172
+ <div className="flex items-center gap-3">
173
+ <input
174
+ type="color"
175
+ aria-label={t(keys.branding.manage.primary_color_label)}
176
+ value={color || DEFAULT_SWATCH}
177
+ disabled={!canManage || busy}
178
+ onChange={(e) => setColor(e.target.value)}
179
+ className="h-9 w-12 cursor-pointer rounded border bg-transparent"
180
+ />
181
+ <Input
182
+ id="primary_color"
183
+ value={color}
184
+ placeholder={DEFAULT_SWATCH}
185
+ disabled={!canManage || busy}
186
+ onChange={(e) => setColor(e.target.value)}
187
+ className="max-w-40 font-mono"
188
+ />
189
+ {color && (
190
+ <Button
191
+ type="button"
192
+ variant="ghost"
193
+ size="sm"
194
+ disabled={busy}
195
+ onClick={() => setColor('')}
196
+ >
197
+ {t(keys.branding.manage.remove_button)}
198
+ </Button>
199
+ )}
200
+ </div>
201
+ <p className="text-xs text-muted-foreground">
202
+ {t(keys.branding.manage.primary_color_help)}
203
+ </p>
204
+ </div>
205
+
206
+ <ImageField
207
+ label={t(keys.branding.manage.logo_label)}
208
+ help={t(keys.branding.manage.logo_help)}
209
+ url={branding?.logoUrl ?? null}
210
+ onUpload={(file) => uploadImage('logo', file)}
211
+ onRemove={() => removeImage('logo')}
212
+ disabled={!canManage || busy}
213
+ />
214
+ <ImageField
215
+ label={t(keys.branding.manage.favicon_label)}
216
+ help={t(keys.branding.manage.favicon_help)}
217
+ url={branding?.faviconUrl ?? null}
218
+ onUpload={(file) => uploadImage('favicon', file)}
219
+ onRemove={() => removeImage('favicon')}
220
+ disabled={!canManage || busy}
221
+ />
222
+
223
+ <Button type="button" disabled={!canManage || busy} onClick={saveText}>
224
+ {busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.save_button)}
225
+ </Button>
226
+ </CardContent>
227
+ </Card>
228
+
229
+ <Card>
230
+ <CardHeader>
231
+ <CardTitle>{t(keys.branding.manage.preview_title)}</CardTitle>
232
+ </CardHeader>
233
+ <CardContent>
234
+ <div className="flex items-center gap-3 rounded-lg border p-4">
235
+ <div
236
+ className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg text-white"
237
+ style={{ backgroundColor: color || DEFAULT_SWATCH }}
238
+ >
239
+ {branding?.logoUrl ? (
240
+ <img
241
+ src={branding.logoUrl}
242
+ alt={appName}
243
+ className="h-full w-full object-contain"
244
+ />
245
+ ) : (
246
+ <span className="font-bold">{(appName.trim()[0] ?? 'S').toUpperCase()}</span>
247
+ )}
248
+ </div>
249
+ <span className="font-semibold">{appName || 'SimpleModule'}</span>
250
+ </div>
251
+ </CardContent>
252
+ </Card>
253
+ </div>
254
+ </PageShell>
255
+ </>
256
+ );
257
+ }
258
+
259
+ Manage.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
260
+
261
+ export default Manage;
File without changes
@@ -0,0 +1,61 @@
1
+ """Branding service — reads current branding and applies admin changes.
2
+
3
+ There is no branding DB table: values live in the shared settings store. Writes
4
+ go through ``settings.reload.apply_changes_and_reload`` which validates against
5
+ ``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding``
6
+ and publishes ``SettingsReloaded``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from branding.constants import PACKAGE
14
+ from branding.contracts.schemas import BrandingOut
15
+ from branding.shared_props import file_url
16
+
17
+ if TYPE_CHECKING:
18
+ from fastapi import FastAPI
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+
22
+ class BrandingService:
23
+ """Read/update the application's branding."""
24
+
25
+ def __init__(self, app: FastAPI, db: AsyncSession) -> None:
26
+ self.app = app
27
+ self.db = db
28
+
29
+ def current(self) -> BrandingOut:
30
+ settings = self.app.state.branding.settings
31
+ return BrandingOut(
32
+ app_name=settings.app_name,
33
+ primary_color=settings.primary_color,
34
+ logo_url=file_url(settings.logo_file_id),
35
+ favicon_url=file_url(settings.favicon_file_id),
36
+ )
37
+
38
+ async def apply(self, changes: dict[str, Any]) -> BrandingOut:
39
+ """Persist and hot-swap the given field changes, then return current."""
40
+ # Plugin→plugin imports (settings is a declared dependency); kept local
41
+ # so import order during discovery stays tolerant.
42
+ from settings.reload import apply_changes_and_reload
43
+ from settings.service import SettingService
44
+ from settings.store import SettingsStore
45
+
46
+ store = SettingsStore(SettingService(self.db))
47
+ bus = self.app.state.sm.event_bus
48
+ await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes)
49
+ return self.current()
50
+
51
+ async def set_logo(self, file_id: str) -> BrandingOut:
52
+ return await self.apply({"logo_file_id": file_id})
53
+
54
+ async def set_favicon(self, file_id: str) -> BrandingOut:
55
+ return await self.apply({"favicon_file_id": file_id})
56
+
57
+ async def clear_logo(self) -> BrandingOut:
58
+ return await self.apply({"logo_file_id": ""})
59
+
60
+ async def clear_favicon(self) -> BrandingOut:
61
+ return await self.apply({"favicon_file_id": ""})
@@ -0,0 +1,20 @@
1
+ """Module-scoped state container.
2
+
3
+ Stored as ``app.state.branding`` by
4
+ :meth:`BrandingModule.register_settings` (via ``register_module_settings``).
5
+ ``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when
6
+ an admin saves changes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from branding.settings import BrandingSettings
14
+
15
+
16
+ @dataclass
17
+ class BrandingServices:
18
+ """Branding module singletons."""
19
+
20
+ settings: BrandingSettings
@@ -0,0 +1,42 @@
1
+ """Branding module settings — DB-backed via ``register_module_settings``.
2
+
3
+ The four values an administrator can customise (app name, logo, favicon,
4
+ primary colour) are stored in the shared settings store at SYSTEM scope,
5
+ hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save
6
+ through ``settings.reload.apply_changes_and_reload``.
7
+
8
+ Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the
9
+ frontend derives a download URL from the id.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pydantic import field_validator
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+ from branding.constants import HEX_COLOR_RE, clean_app_name
18
+
19
+ DEFAULT_APP_NAME = "SimpleModule"
20
+
21
+
22
+ class BrandingSettings(BaseSettings):
23
+ """Customisable application identity."""
24
+
25
+ model_config = SettingsConfigDict(extra="ignore")
26
+
27
+ app_name: str = DEFAULT_APP_NAME
28
+ primary_color: str = "" # "" = use the theme default; otherwise "#rrggbb"
29
+ logo_file_id: str = "" # file_storage UUID, "" = no custom logo
30
+ favicon_file_id: str = "" # file_storage UUID, "" = no custom favicon
31
+
32
+ @field_validator("app_name")
33
+ @classmethod
34
+ def _non_empty_name(cls, value: str) -> str:
35
+ return clean_app_name(value)
36
+
37
+ @field_validator("primary_color")
38
+ @classmethod
39
+ def _valid_hex(cls, value: str) -> str:
40
+ if value and not HEX_COLOR_RE.match(value):
41
+ raise ValueError("primary_color must be a #rrggbb hex string or empty")
42
+ return value.lower()
@@ -0,0 +1,44 @@
1
+ """Branding shared-props provider.
2
+
3
+ Registered on ``app.state.inertia_shared_providers`` so every Inertia page
4
+ (authenticated *and* guest) receives a ``branding`` block in its shared props.
5
+ The frontend uses it for the app name, logo, favicon and primary colour.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from branding.constants import FILE_DOWNLOAD_URL
13
+
14
+ if TYPE_CHECKING:
15
+ from starlette.requests import Request
16
+
17
+ from branding.settings import BrandingSettings
18
+
19
+
20
+ def file_url(file_id: str) -> str | None:
21
+ """Build the file_storage download URL for a stored file id (or None)."""
22
+ return FILE_DOWNLOAD_URL.format(file_id=file_id) if file_id else None
23
+
24
+
25
+ def branding_payload(settings: BrandingSettings) -> dict:
26
+ """The camelCase branding block shared with the frontend."""
27
+ return {
28
+ "appName": settings.app_name,
29
+ "primaryColor": settings.primary_color or None,
30
+ "logoUrl": file_url(settings.logo_file_id),
31
+ "faviconUrl": file_url(settings.favicon_file_id),
32
+ }
33
+
34
+
35
+ def branding_shared_props(request: Request) -> dict:
36
+ """Provider: emit ``{"branding": {...}}`` from the live module settings.
37
+
38
+ Defensive — returns ``{}`` if the branding state isn't mounted yet, so a
39
+ half-booted app never errors a page render.
40
+ """
41
+ services = getattr(request.app.state, "branding", None)
42
+ if services is None:
43
+ return {}
44
+ return {"branding": branding_payload(services.settings)}
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/branding",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the Branding module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "simple_module_branding"
3
+ version = "0.0.24"
4
+ description = "Customisable application branding (name, logo, favicon, primary colour) for simple_module apps"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
8
+ keywords = ["simple-module", "branding", "theming", "white-label"]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "simple_module_core==0.0.24",
12
+ "simple_module_db==0.0.24",
13
+ "simple_module_hosting==0.0.24",
14
+ "simple_module_settings==0.0.24",
15
+ "simple_module_file_storage==0.0.24",
16
+ ]
17
+
18
+ [project.urls]
19
+ Repository = "https://github.com/antosubash/simple_module_python"
20
+
21
+ [project.entry-points.simple_module]
22
+ branding = "branding.module:BrandingModule"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["branding"]
30
+
31
+ # Ship the module-root package.json inside the wheel so the host can
32
+ # discover JS deps via importlib.resources after a pip install.
33
+ [tool.hatch.build.targets.wheel.force-include]
34
+ "package.json" = "branding/package.json"
35
+
36
+ [tool.uv.sources]
37
+ simple_module_core = { workspace = true }
38
+ simple_module_db = { workspace = true }
39
+ simple_module_hosting = { workspace = true }
40
+ simple_module_settings = { workspace = true }
41
+ simple_module_file_storage = { workspace = true }
@@ -0,0 +1,203 @@
1
+ """Tests for the Branding module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from types import SimpleNamespace
7
+
8
+ import httpx
9
+ import pytest
10
+ from branding.settings import BrandingSettings
11
+ from branding.shared_props import branding_payload, branding_shared_props
12
+
13
+ # ── Unit: settings validation ──────────────────────────────────────────
14
+
15
+
16
+ def test_settings_defaults() -> None:
17
+ s = BrandingSettings()
18
+ assert s.app_name == "SimpleModule"
19
+ assert s.primary_color == ""
20
+ assert s.logo_file_id == ""
21
+ assert s.favicon_file_id == ""
22
+
23
+
24
+ def test_settings_app_name_trimmed_and_required() -> None:
25
+ assert BrandingSettings(app_name=" Acme ").app_name == "Acme"
26
+ with pytest.raises(ValueError):
27
+ BrandingSettings(app_name=" ")
28
+ with pytest.raises(ValueError):
29
+ BrandingSettings(app_name="x" * 61)
30
+
31
+
32
+ def test_settings_app_name_rejects_control_chars() -> None:
33
+ # A CR/LF in the name would break email Subject headers downstream — it must
34
+ # be rejected at the source rather than passing a bare strip().
35
+ for bad in ("Acme\nCorp", "Acme\rCorp", "Acme\tInc"):
36
+ with pytest.raises(ValueError):
37
+ BrandingSettings(app_name=bad)
38
+
39
+
40
+ def test_settings_primary_color_validation() -> None:
41
+ assert BrandingSettings(primary_color="#1A7DD1").primary_color == "#1a7dd1"
42
+ assert BrandingSettings(primary_color="").primary_color == ""
43
+ with pytest.raises(ValueError):
44
+ BrandingSettings(primary_color="red")
45
+ with pytest.raises(ValueError):
46
+ BrandingSettings(primary_color="#fff")
47
+
48
+
49
+ # ── Unit: shared-props payload + provider ──────────────────────────────
50
+
51
+
52
+ def test_branding_payload_unset() -> None:
53
+ payload = branding_payload(BrandingSettings())
54
+ assert payload == {
55
+ "appName": "SimpleModule",
56
+ "primaryColor": None,
57
+ "logoUrl": None,
58
+ "faviconUrl": None,
59
+ }
60
+
61
+
62
+ def test_branding_payload_set() -> None:
63
+ s = BrandingSettings(
64
+ app_name="Acme",
65
+ primary_color="#ff0000",
66
+ logo_file_id="abc-123",
67
+ favicon_file_id="def-456",
68
+ )
69
+ payload = branding_payload(s)
70
+ assert payload["appName"] == "Acme"
71
+ assert payload["primaryColor"] == "#ff0000"
72
+ assert payload["logoUrl"] == "/api/file-storage/files/abc-123/download"
73
+ assert payload["faviconUrl"] == "/api/file-storage/files/def-456/download"
74
+
75
+
76
+ def test_provider_returns_empty_when_state_absent() -> None:
77
+ request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
78
+ assert branding_shared_props(request) == {} # type: ignore[arg-type]
79
+
80
+
81
+ def test_provider_emits_branding_block() -> None:
82
+ state = SimpleNamespace(branding=SimpleNamespace(settings=BrandingSettings(app_name="Acme")))
83
+ request = SimpleNamespace(app=SimpleNamespace(state=state))
84
+ out = branding_shared_props(request) # type: ignore[arg-type]
85
+ assert out["branding"]["appName"] == "Acme"
86
+
87
+
88
+ # ── Integration: API + persistence + hot-swap ──────────────────────────
89
+
90
+
91
+ async def test_get_branding_returns_defaults(authenticated_client: httpx.AsyncClient) -> None:
92
+ resp = await authenticated_client.get("/api/branding/")
93
+ assert resp.status_code == 200, resp.text
94
+ body = resp.json()
95
+ assert body["app_name"] == "SimpleModule"
96
+ assert body["logo_url"] is None
97
+
98
+
99
+ async def test_update_persists_and_hot_swaps(app, authenticated_client: httpx.AsyncClient) -> None:
100
+ resp = await authenticated_client.put(
101
+ "/api/branding/",
102
+ json={"app_name": "Acme Corp", "primary_color": "#1A7DD1"},
103
+ )
104
+ assert resp.status_code == 200, resp.text
105
+ body = resp.json()
106
+ assert body["app_name"] == "Acme Corp"
107
+ assert body["primary_color"] == "#1a7dd1"
108
+
109
+ # Hot-swapped on app.state so the shared-props provider sees it immediately.
110
+ settings = app.state.branding.settings
111
+ assert settings.app_name == "Acme Corp"
112
+ assert branding_payload(settings)["appName"] == "Acme Corp"
113
+
114
+ # Persisted: a fresh GET reflects the change.
115
+ again = await authenticated_client.get("/api/branding/")
116
+ assert again.json()["app_name"] == "Acme Corp"
117
+
118
+
119
+ async def test_root_template_reflects_branding(
120
+ app, authenticated_client: httpx.AsyncClient
121
+ ) -> None:
122
+ """The pre-hydration HTML shell carries the branded title + theme-color."""
123
+ # Default name before any change.
124
+ default_page = await authenticated_client.get("/branding/", follow_redirects=False)
125
+ assert default_page.status_code == 200, default_page.text
126
+ assert "<title>SimpleModule</title>" in default_page.text
127
+
128
+ await authenticated_client.put(
129
+ "/api/branding/",
130
+ json={"app_name": "Acme Corp", "primary_color": "#1A7DD1"},
131
+ )
132
+ page = await authenticated_client.get("/branding/", follow_redirects=False)
133
+ assert "<title>Acme Corp</title>" in page.text
134
+ assert '<meta name="theme-color" content="#1a7dd1" />' in page.text
135
+
136
+
137
+ async def test_update_rejects_bad_hex(authenticated_client: httpx.AsyncClient) -> None:
138
+ resp = await authenticated_client.put("/api/branding/", json={"primary_color": "nope"})
139
+ assert resp.status_code == 422
140
+
141
+
142
+ async def test_update_rejects_blank_app_name(authenticated_client: httpx.AsyncClient) -> None:
143
+ # A whitespace-only name must be a clean 422, not a 500 from BrandingSettings.
144
+ resp = await authenticated_client.put("/api/branding/", json={"app_name": " "})
145
+ assert resp.status_code == 422
146
+
147
+
148
+ async def test_update_rejects_control_char_app_name(
149
+ authenticated_client: httpx.AsyncClient,
150
+ ) -> None:
151
+ # A newline must be a clean 422 — otherwise it would later break email
152
+ # Subject headers (it now flows into invite/verify/reset subjects).
153
+ resp = await authenticated_client.put("/api/branding/", json={"app_name": "Acme\nCorp"})
154
+ assert resp.status_code == 422
155
+
156
+
157
+ async def test_logo_upload_rejects_non_image(authenticated_client: httpx.AsyncClient) -> None:
158
+ resp = await authenticated_client.post(
159
+ "/api/branding/logo",
160
+ files={"file": ("notes.txt", b"hello", "text/plain")},
161
+ )
162
+ assert resp.status_code == 415
163
+
164
+
165
+ async def test_logo_upload_sets_logo_url(
166
+ app, authenticated_client: httpx.AsyncClient, monkeypatch
167
+ ) -> None:
168
+ fake_id = uuid.uuid4()
169
+
170
+ async def fake_upload(self, upload):
171
+ return SimpleNamespace(id=fake_id)
172
+
173
+ monkeypatch.setattr("file_storage.service.FileStorageService.upload", fake_upload, raising=True)
174
+
175
+ resp = await authenticated_client.post(
176
+ "/api/branding/logo",
177
+ files={"file": ("logo.png", b"\x89PNG\r\n", "image/png")},
178
+ )
179
+ assert resp.status_code == 200, resp.text
180
+ assert resp.json()["logo_url"] == f"/api/file-storage/files/{fake_id}/download"
181
+ assert app.state.branding.settings.logo_file_id == str(fake_id)
182
+
183
+ # Clearing removes it.
184
+ cleared = await authenticated_client.delete("/api/branding/logo")
185
+ assert cleared.status_code == 200
186
+ assert cleared.json()["logo_url"] is None
187
+
188
+
189
+ async def test_manage_view_requires_auth(client: httpx.AsyncClient) -> None:
190
+ resp = await client.get("/branding", follow_redirects=False)
191
+ assert resp.status_code in (302, 401, 403)
192
+
193
+
194
+ def test_page_constant_matches_view_literal() -> None:
195
+ # Guards against the inlined render literal drifting from the constant
196
+ # (the literal is required inline for SM003/SM004 static AST pairing).
197
+ import inspect
198
+
199
+ from branding import constants
200
+ from branding.endpoints import views
201
+
202
+ assert constants._PAGE_MANAGE == "Branding/Manage"
203
+ assert f'"{constants._PAGE_MANAGE}"' in inspect.getsource(views.manage)
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@simple-module-py/tsconfig/base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@/*": ["./branding/*"],
7
+ "@simple-module-py/ui/*": ["../../packages/ui/src/*"]
8
+ }
9
+ },
10
+ "include": ["branding/**/*.ts", "branding/**/*.tsx"]
11
+ }