From 5eaf681c90ec54f79f95bc997406df69645dcabc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:22:35 +0200 Subject: [PATCH 01/21] docs: issue-batch-426-437 design Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- .../2026-10-11-issue-batch-426-437-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md diff --git a/docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md b/docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md new file mode 100644 index 00000000..a5cb5d8f --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md @@ -0,0 +1,192 @@ +# Issue batch #426–#437 — design + +**Date:** 2026-10-11 · **Status:** approved · **Branch:** `feat/issue-batch-426-437` · **One combined PR** + +Twelve issues filed on 2026-10-11 from building Kuri on 0.0.35, fixed together. +#317 stays out (needs the external design deck). + +## Scope + +| # | Area | One-line fix | +|---|---|---| +| 426 | i18n | `ui` catalog ships inside `simple_module_hosting`, registered unconditionally | +| 427 | ui | Layout "home" links come from a `homeUrl` shared prop, not `/dashboard/` | +| 428 | ui | Default footer caption drops the framework's licence | +| 429 | scaffold | Template `app.tsx` calls `initTheme()` and wires the `title` callback | +| 430 | settings | Picker hides zero-field modules (unless they have `manage_url`) and opens on the first shown | +| 431 | scaffold | Generated module test uses `effective_route_paths` | +| 432 | file_storage | `s3_signature_version` setting, default `s3v4`, passed as botocore `Config` | +| 433 | settings | `import-from-env` reads `.env` under `os.environ` | +| 434 | core/hosting | CSP providers evaluated per response, so origins can follow DB settings | +| 435 | users/dashboard | "Module has views" comes from the routes actually mounted, so a root-mounted module counts | +| 436 | auth | `UserContext.permissions`, round-tripped through the session and merged by the resolver | +| 437 | users | Sign-in error gets `role="alert"` | + +## Design + +### #426 — ship the `ui` catalog + +`_i18n_build.py` registers `ui` only when `/packages/ui/locales` +exists, which is true only inside this monorepo. `hosting` already ships its own +catalog as package data (`simple_module_hosting/locales/`). + +**Move** `packages/ui/locales/{en,es}.json` to +`framework/hosting/simple_module_hosting/ui_locales/` (a sibling directory; the +flat `locales/` is taken by the `hosting` namespace) and always register `ui` +from there. One source of truth, shipped in the wheel by the existing +`packages = ["simple_module_hosting"]`, no copy to keep in sync and no +force-include that breaks sdist builds. Apps that want different wording already +have the #415 host override layer. Update the three code comments that name the +old path (`Makefile`, `simple_module_core/__main__.py`, `diagnostics/_i18n.py`) +plus the i18n diagnostics if they scan that path, and the docs +(`framework-conventions.md`). + +Rejected: copy + byte-identical sync test (two sources), hatch `force-include` +from `../../packages/ui/locales` (breaks building from the sdist). + +### #427 — `homeUrl` shared prop + +The users module already owns the landing URL (`login_redirect_url`, rewritten +at startup by `apply_login_redirect_fallback` when Dashboard is absent) and +already registers an Inertia shared-props provider. It adds +`homeUrl: ` there. `packages/ui` gets a `useHomeUrl()` hook +returning `props.homeUrl ?? '/'`, used by `AdminLayout`, `SidebarLayout` (logo) +and `PublicLayout`. `SharedProps` gains `homeUrl?: string`. + +Fallback is `/`, not `/dashboard/`: `/` is a host route in every app, so the +link can never 404 when the users module is absent. + +### #428 — no licence in the default footer + +Default caption becomes `© {year} {appName}`. `BRAND_LICENSE` is removed (its +only consumers are `BrandingFooter` and `BrandingPreview`); a host wanting a +licence line sets `footer_text`. Tests that expect `MIT` are updated. + +Rejected: a `license` branding setting — a free-text `footer_text` already covers it. + +### #429 — scaffold `app.tsx` + +The template gains what the in-repo `host/client_app/app.tsx` does for theme and +title: `title: (t) => formatTitle(t)`, `setTitleAppName(initialPage.props.branding?.appName)` +and `initTheme()` before the first render. `ErrorBoundary`/`OfflineBanner` parity +is out of scope. `test_cli_new_regressions.py` asserts the three calls. + +### #430 — settings picker + +Client-side in `ModulesEdit.tsx`: `visible = modules.filter(m => m.fields.length > 0 || m.manage_url)`; +search filters `visible`; initial selection is `visible[0]`. The JSON API is +unchanged. A module with no fields but a `manage_url` stays, because its card is +the way to its own settings page. + +### #431 — scaffold module test + +Template imports `effective_route_paths` from `simple_module_test` and asserts +on its result. `test_scaffolding_module.py` asserts the rendered test uses it. + +### #432 — SigV4 + +`FileStorageSettings.s3_signature_version: str = "s3v4"` (group S3, admin-editable +like the other S3 fields). `_build` in `backends/s3.py` passes +`config=botocore.config.Config(signature_version=...)`, imported lazily because +botocore only exists with the `s3` extra. Test: presigned URL contains +`X-Amz-Algorithm=AWS4-HMAC-SHA256` with a custom endpoint. + +### #433 — `.env` in `import-from-env` + +`import_from_env_impl(app, store, environ=None)` reads from +`{**parse_dotenv(find_env_file()), **os.environ}` by default — the real +environment wins, as with pydantic-settings — using the framework's existing +`simple_module_core.dotenv` helpers (no new dependency). No mutation of +`os.environ`. When nothing was imported and a `.env` exists, the command prints a +hint naming the expected `SM__` form. The `.env.example` colour +lines get a "quote values containing `#`" note. + +### #434 — CSP sources that follow settings + +`CspSourceRegistry.add_provider(fn)` where `fn(app) -> Iterable[tuple[str, str]]` +returns `(directive, source)` pairs, registered from the existing +`register_csp_sources` hook (no signature change). `SecurityHeadersMiddleware` +keeps the static policy string and, when providers exist, per response: + +1. calls each provider (sync, must only read in-memory state such as + `app.state..settings`); a provider that raises, or returns a pair that + fails the same directive/source validation `add()` applies, is logged once + and contributes nothing (fail closed — the static policy still applies); +2. looks the resulting `frozenset` of pairs up in a small dict cache of finished + policy strings and builds it with `extend_policy` on a miss. + +No version key or TTL is needed: the cache is keyed on the *value* providers +return, so a runtime settings edit (already hydrated per process by the settings +module and the `settings.values` invalidation channel) changes the key on the +next response. With no providers the middleware is byte-for-byte today's path. +Cache bounded (e.g. 32 entries, clear on overflow). + +Rejected: recompute on `SettingsReloaded` + invalidation subscriptions (more +wiring, a missed event leaves a stale policy); passing `app` to the hook (still +runs before DB hydration, so it fixes nothing). + +### #435 — root-mounted modules + +`view_prefix=""` is both the default ("no views") and "mounted at `/`", so the +issue's `is not None` would match every API-only module. Instead +`wire_module_routes` records each module whose view router actually received +routes in `app.state.sm.view_prefixes: dict[str, str]` (module name → prefix, +discovery order). `apply_login_redirect_fallback` and `dashboard/stats.py` use +that map: the first non-users module in it wins, and `""` yields `/`. The admin +URL in stats stays as it is (an admin prefix is never empty). + +Rejected: a `register_routes`-override check (an API-only module that overrides it +would be taken as root-mounted); `view_prefix: str | None` (breaking change for +every module). + +### #436 — `UserContext.permissions` + +`permissions: list[str] = field(default_factory=list)` appended after `tenant_id` +(positional order preserved), written by `to_session_dict` and read with +`payload.get("permissions") or []` so old cookies stay valid. Add +`has_permission(p)`. `resolve_principal_permissions` merges +`getattr(user, "permissions", None) or ()` after the role step, **dropping `*`** +— the same guarantee #398 gives grant sources, so a token or IdP claim can never +carry the wildcard. Duck-typed, so SM009 holds. `from_user` leaves it empty +(direct user grants keep coming from the permissions module's grant source). +Documented: these permissions are frozen in the session like `roles`; anything +revocable belongs in a grant source. + +### #437 — announce the sign-in error + +`role="alert"` on the `LoginForm` error paragraph, plus a vitest asserting +`getByRole('alert')` after a failed submit. + +## Decisions made (override at approval) + +1. #426 moves the catalog instead of copying it; `packages/ui/locales` is deleted. +2. #427 prop name `homeUrl`, owned by users, client fallback `/`. +3. #428 drops the licence entirely rather than adding a setting. +4. #432 is a setting (as the issue suggests), not a hard-coded value. +5. #434 is per-response providers with a value-keyed cache, not event-driven recompute. +6. #435 uses the mounted-routes map instead of a `ModuleMeta` change. +7. #436 filters `*` out of `UserContext.permissions`. +8. #429 adds theme + title only, not ErrorBoundary/OfflineBanner. + +## Testing + +- Python: `_i18n_build` registers `ui` with no `packages/ui` under the root; + users shared props expose `homeUrl`; S3 presign is SigV4; `import_from_env_impl` + reads a tmp `.env` and env wins over it; CSP provider adds/changes/fails closed + and the static path is unchanged; root-mounted fake module wins the login + fallback and gets `/` in dashboard stats while an API-only module does not; + `UserContext` round-trip with and without `permissions`, resolver merges them + and drops `*`; scaffold template content checks (#429, #431). +- JS: `useHomeUrl` in the three layouts, footer caption, settings picker + hide/initial selection, LoginForm alert. +- QA in the browser: `/admin/users/` back link, footer caption, `/admin/settings/` + picker, failed sign-in. + +## Risks + +- #426: the generated i18n keys/resources read the registry, so moving the + catalog must keep `keys.generated.ts` identical — checked by regenerating. +- #434: the per-response provider call is on the hot path; providers must stay + attribute reads. The no-provider path is unchanged, so only apps using it pay. +- #435: the test harness in `simple_module_test` also calls `wire_module_routes`, + so `app.state.sm` must exist there too. From 170ac9ad84cf241cacf6eedc815c08b4d8c74497 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:22:59 +0200 Subject: [PATCH 02/21] docs: issue-batch-426-437 implementation plan Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- .../plans/2026-10-11-issue-batch-426-437.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-11-issue-batch-426-437.md diff --git a/docs/superpowers/plans/2026-10-11-issue-batch-426-437.md b/docs/superpowers/plans/2026-10-11-issue-batch-426-437.md new file mode 100644 index 00000000..c46f7db9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-issue-batch-426-437.md @@ -0,0 +1,28 @@ +# Issue batch #426–#437 — implementation plan + +Design: `docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md` (approved). + +Every task: write the failing test first, implement, run the touched suites +(`uv run pytest ` / `npx vitest run `), keep files under 300 lines. +Tasks touch disjoint files and run in parallel; implementers do **not** commit — +the lead commits each task by path once its tests pass. + +| Task | Issues | Files (primary) | Hard? | +|---|---|---|---| +| T1 | #426 | `framework/hosting/simple_module_hosting/_i18n_build.py`, `ui_locales/` (moved from `packages/ui/locales/`), comments in `Makefile`, `simple_module_core/__main__.py`, `diagnostics/_i18n.py`, `docs/framework-conventions.md`, `framework/hosting/tests/` | no | +| T2 | #427, #428 | `packages/ui/src/{types.ts,lib/home-url.ts,lib/brand.ts,layouts/{AdminLayout,SidebarLayout,PublicLayout}.tsx,components/BrandingFooter*.tsx}`, `modules/branding/branding/components/BrandingPreview.tsx`, users shared-props provider + test | no | +| T3 | #429, #431 | `framework/cli/simple_module_cli/templates/host/client_app/app.tsx`, `templates/module/tests/test___PACKAGE__.py.tpl`, `framework/cli/tests/` | no | +| T4 | #430, #433 | `modules/settings/settings/pages/ModulesEdit.tsx`, `modules/settings/tests-js/ModulesEdit.test.tsx`, `modules/settings/settings/cli.py`, `modules/settings/tests/test_cli_import.py`, `.env.example` | no | +| T5 | #432 | `modules/file_storage/file_storage/{settings.py,backends/s3.py}`, `modules/file_storage/tests/test_s3_backend.py`, file_storage locales if settings labels are translated | no | +| T6 | #434 | `framework/core/simple_module_core/csp.py`, `framework/hosting/simple_module_hosting/{middleware.py,_phase_helpers.py}`, CSP tests, `docs/framework/lifecycle.md`, `docs/module-authoring.md` § External asset origins | **yes** | +| T7 | #435 | `framework/hosting/simple_module_hosting/_module_routes.py` (+ wherever `app.state.sm` is created), `modules/users/users/startup.py`, `modules/dashboard/dashboard/stats.py`, their tests | no | +| T8 | #436 | `modules/auth/auth/contracts/schemas.py`, `framework/hosting/simple_module_hosting/permissions.py`, `modules/auth/tests/test_user_context.py`, `framework/hosting/tests/test_grant_sources.py` (or new), `docs/framework/permissions.md` | **yes** | +| T9 | #437 | `modules/users/users/auth_local/components/LoginForm.tsx` + new vitest | no | + +Done-check per task: the issue's repro is covered by a test that failed before +and passes after; touched suites green; `uv run ruff check`/`ruff format --check` +on touched Python, `npx biome check` on touched TS. + +After all tasks: `make lint`, `make test`, `make doctor`, regenerate i18n keys +(boot the host in development or the repo's gen script) and confirm +`packages/i18n/src/keys.generated.ts` only changes where expected. From 4b114afcb3d8ae0061baba526a0549ff3f549d70 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:24:51 +0200 Subject: [PATCH 03/21] fix(users): announce the sign-in error to screen readers (#437) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- modules/users/tests-js/LoginForm.test.tsx | 31 +++++++++++++++++++ .../users/auth_local/components/LoginForm.tsx | 6 +++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 modules/users/tests-js/LoginForm.test.tsx diff --git a/modules/users/tests-js/LoginForm.test.tsx b/modules/users/tests-js/LoginForm.test.tsx new file mode 100644 index 00000000..3e4520a2 --- /dev/null +++ b/modules/users/tests-js/LoginForm.test.tsx @@ -0,0 +1,31 @@ +import '@testing-library/jest-dom/vitest'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { LoginForm } from '../users/auth_local/components/LoginForm'; + +const baseProps = { + email: '', + onEmailChange: vi.fn(), + password: '', + onPasswordChange: vi.fn(), + remember: false, + onRememberChange: vi.fn(), + rememberDays: 30, + loading: false, + onSubmit: vi.fn(), + allowSignup: false, + oauthProviders: [], +}; + +describe('LoginForm', () => { + test('announces a sign-in error as an alert', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials'); + }); + + test('renders no alert without an error', () => { + render(); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/modules/users/users/auth_local/components/LoginForm.tsx b/modules/users/users/auth_local/components/LoginForm.tsx index a9147107..8a47ef41 100644 --- a/modules/users/users/auth_local/components/LoginForm.tsx +++ b/modules/users/users/auth_local/components/LoginForm.tsx @@ -105,7 +105,11 @@ export function LoginForm({ - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} - + = {}; +vi.mock('@inertiajs/react', () => ({ usePage: () => ({ props }) })); + +import { useHomeUrl } from './home-url'; + +describe('useHomeUrl', () => { + beforeEach(() => { + props = {}; + }); + + it('returns the homeUrl shared prop', () => { + props = { homeUrl: '/home/' }; + expect(renderHook(() => useHomeUrl()).result.current).toBe('/home/'); + }); + + it('falls back to / when absent', () => { + expect(renderHook(() => useHomeUrl()).result.current).toBe('/'); + }); +}); diff --git a/packages/ui/src/lib/home-url.ts b/packages/ui/src/lib/home-url.ts new file mode 100644 index 00000000..d4eafbb7 --- /dev/null +++ b/packages/ui/src/lib/home-url.ts @@ -0,0 +1,11 @@ +import { usePage } from '@inertiajs/react'; +import type { SharedProps } from '../types'; + +/** + * The app's home URL from the `homeUrl` shared prop (the users module's + * `login_redirect_url`), or `/` when no module supplies one. + */ +export function useHomeUrl(): string { + const { homeUrl } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + return homeUrl || '/'; +} diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 96901e97..5088bbe4 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -35,7 +35,7 @@ export interface BrandingShared { banner: { message: string; severity: string } | null; /** * Caption line set by an administrator, or `null` when unset — in which case - * the framework's own `© {year} · {BRAND_LICENSE}` is shown. + * the framework's own `© {year} {appName}` is shown. */ footerText: string | null; /** @@ -62,6 +62,10 @@ export interface SharedProps { // auth provider is installed (e.g. a Keycloak-only deployment), which reads // the same as "closed" — the host isn't the one taking signups. signup?: { allowed: boolean }; + // Where the app's "home" is (the users module's `login_redirect_url`). Layouts + // link their logo / back-to-app / dashboard entry here instead of hardcoding + // a module's URL. Absent without the users module; callers fall back to `/`. + homeUrl?: string; // Also from the users module. `active` is true only for the shared demo // account's own session — an operator signed in normally on a demo instance // sees nothing. `readOnly` mirrors the `demo_read_only` setting that makes From aefca2c4cb2ebff3499f9865d70d4662d9b65652 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:31:26 +0200 Subject: [PATCH 13/21] fix(ui): default footer caption no longer claims the MIT licence (#428) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- .../branding/branding/components/BrandingPreview.tsx | 4 ++-- modules/branding/branding/components/FooterField.tsx | 2 +- packages/ui/src/components/BrandingFooter.test.tsx | 10 ++++++---- packages/ui/src/components/BrandingFooter.tsx | 6 +++--- packages/ui/src/components/BrandingMark.tsx | 2 +- packages/ui/src/lib/brand.ts | 3 --- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/modules/branding/branding/components/BrandingPreview.tsx b/modules/branding/branding/components/BrandingPreview.tsx index c7ccee01..6ab04616 100644 --- a/modules/branding/branding/components/BrandingPreview.tsx +++ b/modules/branding/branding/components/BrandingPreview.tsx @@ -1,4 +1,4 @@ -import { BRAND_FOOTER_LINKS, BRAND_LICENSE } from '@simple-module-py/ui/lib/brand'; +import { BRAND_FOOTER_LINKS } from '@simple-module-py/ui/lib/brand'; import { deriveBrandRamp } from '@simple-module-py/ui/lib/color'; import type { FooterLink } from './dirty'; @@ -76,7 +76,7 @@ function FooterStrip({ brand }: { brand: PreviewBrand }) { return (
- {brand.footerText.trim() || `© ${PREVIEW_YEAR} ${brand.appName} · ${BRAND_LICENSE}`} + {brand.footerText.trim() || `© ${PREVIEW_YEAR} ${brand.appName}`} {previewFooterLinks(brand) diff --git a/modules/branding/branding/components/FooterField.tsx b/modules/branding/branding/components/FooterField.tsx index cc8994d6..a787937a 100644 --- a/modules/branding/branding/components/FooterField.tsx +++ b/modules/branding/branding/components/FooterField.tsx @@ -17,7 +17,7 @@ export const MAX_FOOTER_HREF = 500; export const MAX_FOOTER_TEXT = 200; interface FooterFieldProps { - /** The caption line. Blank keeps the framework's own `© {year} · MIT`. */ + /** The caption line. Blank keeps the framework's own `© {year} {appName}`. */ text: string; onTextChange: (next: string) => void; links: FooterLink[]; diff --git a/packages/ui/src/components/BrandingFooter.test.tsx b/packages/ui/src/components/BrandingFooter.test.tsx index dff2e8bd..ea020d56 100644 --- a/packages/ui/src/components/BrandingFooter.test.tsx +++ b/packages/ui/src/components/BrandingFooter.test.tsx @@ -11,10 +11,11 @@ describe('BrandingFooter', () => { expect(screen.getByRole('link', { name: 'Changelog' })).toBeInTheDocument(); }); - test('shows the current year and licence in the caption', () => { + test('shows the current year and app name in the caption, not a licence', () => { render(); const year = new Date().getFullYear(); - expect(screen.getByText(new RegExp(`${year}.*MIT`))).toBeInTheDocument(); + expect(screen.getByText(`© ${year} Acme`)).toBeInTheDocument(); + expect(screen.queryByText(/MIT/)).not.toBeInTheDocument(); }); test('renders host-configured links instead of the framework ones', () => { @@ -83,9 +84,10 @@ describe('BrandingFooter', () => { ['null', null], ['undefined', undefined], ['blank', ' '], - ])('falls back to the licence caption when footerText is %s', (_name, footerText) => { + ])('falls back to the default caption when footerText is %s', (_name, footerText) => { render(); const year = new Date().getFullYear(); - expect(screen.getByText(new RegExp(`${year}.*MIT`))).toBeInTheDocument(); + expect(screen.getByText(`© ${year} Acme`)).toBeInTheDocument(); + expect(screen.queryByText(/MIT/)).not.toBeInTheDocument(); }); }); diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx index 6a5ffde3..5ab35586 100644 --- a/packages/ui/src/components/BrandingFooter.tsx +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -1,4 +1,4 @@ -import { BRAND_ACCENT, BRAND_FOOTER_LINKS, BRAND_LICENSE, type BrandLink } from '../lib/brand'; +import { BRAND_ACCENT, BRAND_FOOTER_LINKS, type BrandLink } from '../lib/brand'; import { BrandingMark } from './BrandingMark'; /** Stable for the lifetime of the bundle — the year only matters at page load. */ @@ -23,7 +23,7 @@ interface BrandingFooterProps { links?: BrandLink[] | null; /** * Caption shown under the app name. Omitted, `null` or blank falls back to - * the framework's own `© {year} · {BRAND_LICENSE}`, so a host that + * the framework's own `© {year} {appName}`, so a host that * configures nothing is unchanged — and one that does stops publishing the * framework's licence as its own. */ @@ -45,7 +45,7 @@ export function BrandingFooter({ // Trimmed for the same reason the link list is length-checked: whitespace is // what an admin leaves behind when clearing the field, and a blank caption // reads as a broken footer rather than a deliberate one. - const caption = footerText?.trim() || `© ${FOOTER_YEAR} · ${BRAND_LICENSE}`; + const caption = footerText?.trim() || `© ${FOOTER_YEAR} ${appName}`; const container = variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; diff --git a/packages/ui/src/components/BrandingMark.tsx b/packages/ui/src/components/BrandingMark.tsx index 838b2511..96820403 100644 --- a/packages/ui/src/components/BrandingMark.tsx +++ b/packages/ui/src/components/BrandingMark.tsx @@ -13,7 +13,7 @@ interface BrandingMarkProps { labelClassName?: string; /** Override for the badge shadow (defaults to `shadow-sm`; e.g. a coloured glow). */ badgeClassName?: string; - /** Optional muted sub-caption stacked under the wordmark (e.g. `python`, `© 2026 · MIT`). */ + /** Optional muted sub-caption stacked under the wordmark (e.g. `python`, `© 2026 Acme`). */ caption?: string; /** Classes for the caption text. Defaults to a muted mono line. */ captionClassName?: string; diff --git a/packages/ui/src/lib/brand.ts b/packages/ui/src/lib/brand.ts index 0cd32229..95676372 100644 --- a/packages/ui/src/lib/brand.ts +++ b/packages/ui/src/lib/brand.ts @@ -6,9 +6,6 @@ */ export const BRAND_REPO_URL = 'https://github.com/antosubash/simple_module_python'; -/** Licence shown in the footer caption. */ -export const BRAND_LICENSE = 'MIT'; - /** Short technology tag shown beneath the wordmark on auth screens. */ export const BRAND_TECH = 'python'; From 7bf688403d9c389cecc54da27a24a77c33d7be89 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:33:09 +0200 Subject: [PATCH 14/21] fix(users,dashboard): count root-mounted modules as having views (#435) view_prefix="" is both the default (no views) and "mounted at /", so the truthiness check skipped root-mounted modules and is-not-None would have matched every API-only one. wire_module_routes now records each module whose view router actually received routes (app.state.sm.view_prefixes), and the login fallback and dashboard stats read that map, turning "" into /. Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- framework/core/simple_module_core/services.py | 3 ++ .../simple_module_hosting/_module_routes.py | 8 +++++ .../simple_module_hosting/app_builder.py | 1 + framework/hosting/tests/test_view_prefixes.py | 32 +++++++++++++++++ modules/dashboard/dashboard/stats.py | 3 +- modules/dashboard/tests/test_dashboard.py | 21 ++++++++++++ modules/users/tests/test_views.py | 34 +++++++++++++++++++ modules/users/users/startup.py | 8 ++--- 8 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 framework/hosting/tests/test_view_prefixes.py diff --git a/framework/core/simple_module_core/services.py b/framework/core/simple_module_core/services.py index b939a1af..5a1cd0a7 100644 --- a/framework/core/simple_module_core/services.py +++ b/framework/core/simple_module_core/services.py @@ -104,3 +104,6 @@ class Services: #: How the host scopes requests (#418), recorded where ``create_app`` #: decides on ``TenantMiddleware``. Defaulted for apps built outside it. tenancy: TenancyMode = TenancyMode.SINGLE + #: Module name -> ``view_prefix`` for modules whose view router received + #: routes, in discovery order. ``""`` here means mounted at ``/`` (#435). + view_prefixes: dict[str, str] = field(default_factory=dict) diff --git a/framework/hosting/simple_module_hosting/_module_routes.py b/framework/hosting/simple_module_hosting/_module_routes.py index 6dcf02bb..087c7ada 100644 --- a/framework/hosting/simple_module_hosting/_module_routes.py +++ b/framework/hosting/simple_module_hosting/_module_routes.py @@ -23,6 +23,14 @@ def wire_module_routes(app: FastAPI, module) -> None: view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"]) module.register_routes(api_router, view_router) _clone_bare_prefix_route(view_router, module.meta.view_prefix) + if view_router.routes: + # ``view_prefix=""`` means both "no views" and "mounted at /", so only + # a router that actually received routes says which one it is (#435). + # Dict order is discovery order. Lives on app.state until ``Services`` + # exists, then ``create_app`` hands it over as ``sm.view_prefixes``. + if not hasattr(app.state, "view_prefixes"): + app.state.view_prefixes = {} + app.state.view_prefixes[module.meta.name] = module.meta.view_prefix app.include_router(api_router) app.include_router(view_router) diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 3aae5cff..10ce4f22 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -265,6 +265,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: invalidation=invalidation_bus, diagnostics=diagnostics_state, tenancy=mode_for(settings), + view_prefixes=getattr(app.state, "view_prefixes", {}), ) return app diff --git a/framework/hosting/tests/test_view_prefixes.py b/framework/hosting/tests/test_view_prefixes.py new file mode 100644 index 00000000..147d5298 --- /dev/null +++ b/framework/hosting/tests/test_view_prefixes.py @@ -0,0 +1,32 @@ +"""wire_module_routes records which modules actually mount view routes (#435).""" + +from __future__ import annotations + +from fastapi import APIRouter, FastAPI +from simple_module_core import ModuleBase, ModuleMeta +from simple_module_hosting._module_routes import wire_module_routes + + +def _module(name: str, view_prefix: str, *, views: bool, api: bool = False) -> ModuleBase: + class _M(ModuleBase): + meta = ModuleMeta(name=name, view_prefix=view_prefix, route_prefix=f"/api/{name}") + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + if views: + view_router.add_api_route("/", lambda: "ok", methods=["GET"]) + if api: + api_router.add_api_route("/", lambda: "ok", methods=["GET"]) + + return _M() + + +def test_records_only_modules_with_view_routes_in_order(): + app = FastAPI() + for m in ( + _module("Api", "", views=False, api=True), + _module("Root", "", views=True), + _module("Blog", "/blog", views=True), + _module("Empty", "/empty", views=False), + ): + wire_module_routes(app, m) + assert list(app.state.view_prefixes.items()) == [("Root", ""), ("Blog", "/blog")] diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index 8667098f..ccf9f493 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -127,6 +127,7 @@ def _get_module_info(app: FastAPI, health_checks: list[dict[str, str]]) -> list[ # caller's permissions here: this whole payload is process-wide cached for # 30s, so anything user-specific would leak across sessions. The page gates # each link against the per-user `menus` prop instead. + prefixes = app.state.sm.view_prefixes return [ { "name": m.meta.name, @@ -135,7 +136,7 @@ def _get_module_info(app: FastAPI, health_checks: list[dict[str, str]]) -> list[ # in a mono face and read as the thing you would `uv add`. "package": get_module_package_name(m), "status": "loaded", - "url": f"{m.meta.view_prefix}/" if m.meta.view_prefix else "", + "url": f"{prefixes[m.meta.name]}/" if m.meta.name in prefixes else "", # A partly-administrative module (users) mounts its management # screens outside its own view_prefix, so the tile cannot find # them by prefix alone — ship the second mount point too. diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 5007a9e7..c7db9108 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -150,6 +150,27 @@ async def test_view_less_modules_get_an_empty_url( for mod in resp.json()["system_info"]["modules"]: assert mod["url"] == "" or mod["url"].startswith("/"), mod + async def test_root_mounted_module_gets_slash_url( + self, app, authenticated_client: httpx.AsyncClient + ): + """#435: view_prefix "" with real view routes is `/`; without, still empty.""" + sm = app.state.sm + name = sm.modules[0].meta.name + saved = dict(sm.view_prefixes) + try: + sm.view_prefixes[name] = "" + resp = await authenticated_client.get(_STATS_URL) + urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} + assert urls[name] == "/" + sm.view_prefixes.pop(name) + invalidate_stats_cache() + resp = await authenticated_client.get(_STATS_URL) + urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} + assert urls[name] == "" + finally: + sm.view_prefixes.clear() + sm.view_prefixes.update(saved) + async def test_every_module_entry_reports_health(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get(_STATS_URL) for mod in resp.json()["system_info"]["modules"]: diff --git a/modules/users/tests/test_views.py b/modules/users/tests/test_views.py index 1e2cd78b..e6595293 100644 --- a/modules/users/tests/test_views.py +++ b/modules/users/tests/test_views.py @@ -295,3 +295,37 @@ async def test_login_redirect_fallback_without_dashboard(users_app): assert url.endswith("/"), "must have trailing slash" finally: object.__setattr__(sm, "modules", original) + + +def _fallback_url(app, prefixes: dict[str, str]) -> str: + """Run the login fallback against a Dashboard-less module set (#435).""" + from users.startup import apply_login_redirect_fallback + + sm = app.state.sm + saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes) + settings = app.state.users.settings + saved_url = settings.login_redirect_url + object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard")) + sm.view_prefixes.clear() + sm.view_prefixes.update(prefixes) + settings.login_redirect_url = "/dashboard/" + try: + apply_login_redirect_fallback(app, settings, "Users", "/dashboard/") + return settings.login_redirect_url + finally: + object.__setattr__(sm, "modules", saved_mods) + sm.view_prefixes.clear() + sm.view_prefixes.update(saved_prefixes) + settings.login_redirect_url = saved_url + + +@pytest.mark.anyio +async def test_login_redirect_fallback_prefers_root_mounted_module(users_app): + """#435: a module that serves views at ``/`` (view_prefix == "") wins.""" + assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/" + + +@pytest.mark.anyio +async def test_login_redirect_fallback_skips_api_only_module(users_app): + """An API-only module also has view_prefix == "" but is not in the map.""" + assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/" diff --git a/modules/users/users/startup.py b/modules/users/users/startup.py index 21d019cd..c7d77adb 100644 --- a/modules/users/users/startup.py +++ b/modules/users/users/startup.py @@ -149,11 +149,7 @@ def apply_login_redirect_fallback( if any(m.meta.name == _DASHBOARD_MODULE for m in app.state.sm.modules): return first_view = next( - ( - m.meta.view_prefix - for m in app.state.sm.modules - if m.meta.view_prefix and m.meta.name != module_name - ), + (prefix for name, prefix in app.state.sm.view_prefixes.items() if name != module_name), None, ) - settings.login_redirect_url = f"{first_view}/" if first_view else _FALLBACK_REDIRECT + settings.login_redirect_url = _FALLBACK_REDIRECT if first_view is None else f"{first_view}/" From a03c7b36a5f04bbccf280e23a08d8b0a04fefa21 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:42:52 +0200 Subject: [PATCH 15/21] chore: satisfy lint (format, RUF071 on an intended character-wise prefix, 300-line cap) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- docs/framework/lifecycle.md | 4 +- .../simple_module_hosting/_route_guard.py | 4 +- framework/hosting/tests/test_csp_providers.py | 7 +++- .../tests/test_login_redirect_root_mount.py | 39 +++++++++++++++++++ modules/users/tests/test_views.py | 34 ---------------- 5 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 modules/users/tests/test_login_redirect_root_mount.py diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index 7fdc1ff6..7e7b7ca0 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -185,9 +185,7 @@ called on every response with the app and returns `(directive, source)` pairs: ```python def register_csp_sources(self, registry) -> None: - registry.add_provider( - lambda app: [("img-src", app.state.maps.settings.tile_origin)] - ) + registry.add_provider(lambda app: [("img-src", app.state.maps.settings.tile_origin)]) ``` Providers must be synchronous and read only in-memory state diff --git a/framework/hosting/simple_module_hosting/_route_guard.py b/framework/hosting/simple_module_hosting/_route_guard.py index 4d1ffe58..07023a16 100644 --- a/framework/hosting/simple_module_hosting/_route_guard.py +++ b/framework/hosting/simple_module_hosting/_route_guard.py @@ -53,7 +53,9 @@ def _common_prefix(paths: list[str]) -> str: """The literal leading part every path shares — never past a ``{param}``.""" if not paths: return "" - return os.path.commonprefix(paths).split("{", 1)[0] + # Character-wise on purpose: the cut happens at the first "{param}", which + # path-component comparison (commonpath) cannot express. + return os.path.commonprefix(paths).split("{", 1)[0] # noqa: RUF071 class _PrefixGuard: diff --git a/framework/hosting/tests/test_csp_providers.py b/framework/hosting/tests/test_csp_providers.py index 16e2080a..f2033243 100644 --- a/framework/hosting/tests/test_csp_providers.py +++ b/framework/hosting/tests/test_csp_providers.py @@ -137,7 +137,12 @@ async def test_static_sources_kept_alongside_provider_sources(self) -> None: @pytest.mark.parametrize( "source", - ["https://exämple.com", "https://x.example\x00", "https://x.example\x7f", "https://x\u2028y"], + [ + "https://exämple.com", + "https://x.example\x00", + "https://x.example\x7f", + "https://x\u2028y", + ], ) async def test_unencodable_source_never_breaks_the_response(self, source) -> None: # An admin-edited setting is the usual input: a value the header diff --git a/modules/users/tests/test_login_redirect_root_mount.py b/modules/users/tests/test_login_redirect_root_mount.py new file mode 100644 index 00000000..9cbefa8d --- /dev/null +++ b/modules/users/tests/test_login_redirect_root_mount.py @@ -0,0 +1,39 @@ +"""Login fallback with a module mounted at the root (#435).""" + +from __future__ import annotations + +import pytest + + +def _fallback_url(app, prefixes: dict[str, str]) -> str: + """Run the login fallback against a Dashboard-less module set (#435).""" + from users.startup import apply_login_redirect_fallback + + sm = app.state.sm + saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes) + settings = app.state.users.settings + saved_url = settings.login_redirect_url + object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard")) + sm.view_prefixes.clear() + sm.view_prefixes.update(prefixes) + settings.login_redirect_url = "/dashboard/" + try: + apply_login_redirect_fallback(app, settings, "Users", "/dashboard/") + return settings.login_redirect_url + finally: + object.__setattr__(sm, "modules", saved_mods) + sm.view_prefixes.clear() + sm.view_prefixes.update(saved_prefixes) + settings.login_redirect_url = saved_url + + +@pytest.mark.anyio +async def test_login_redirect_fallback_prefers_root_mounted_module(users_app): + """#435: a module that serves views at ``/`` (view_prefix == "") wins.""" + assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/" + + +@pytest.mark.anyio +async def test_login_redirect_fallback_skips_api_only_module(users_app): + """An API-only module also has view_prefix == "" but is not in the map.""" + assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/" diff --git a/modules/users/tests/test_views.py b/modules/users/tests/test_views.py index e6595293..1e2cd78b 100644 --- a/modules/users/tests/test_views.py +++ b/modules/users/tests/test_views.py @@ -295,37 +295,3 @@ async def test_login_redirect_fallback_without_dashboard(users_app): assert url.endswith("/"), "must have trailing slash" finally: object.__setattr__(sm, "modules", original) - - -def _fallback_url(app, prefixes: dict[str, str]) -> str: - """Run the login fallback against a Dashboard-less module set (#435).""" - from users.startup import apply_login_redirect_fallback - - sm = app.state.sm - saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes) - settings = app.state.users.settings - saved_url = settings.login_redirect_url - object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard")) - sm.view_prefixes.clear() - sm.view_prefixes.update(prefixes) - settings.login_redirect_url = "/dashboard/" - try: - apply_login_redirect_fallback(app, settings, "Users", "/dashboard/") - return settings.login_redirect_url - finally: - object.__setattr__(sm, "modules", saved_mods) - sm.view_prefixes.clear() - sm.view_prefixes.update(saved_prefixes) - settings.login_redirect_url = saved_url - - -@pytest.mark.anyio -async def test_login_redirect_fallback_prefers_root_mounted_module(users_app): - """#435: a module that serves views at ``/`` (view_prefix == "") wins.""" - assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/" - - -@pytest.mark.anyio -async def test_login_redirect_fallback_skips_api_only_module(users_app): - """An API-only module also has view_prefix == "" but is not in the map.""" - assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/" From 48749a98781b9fc20aca880b52d1ed6b50ad81ac Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:52:11 +0200 Subject: [PATCH 16/21] refactor(optimize): wire_module_routes reports views directly; share S3 extra message; monkeypatch in dashboard test Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- .../simple_module_hosting/_module_routes.py | 16 ++++++------ .../simple_module_hosting/app_builder.py | 6 +++-- framework/hosting/tests/test_view_prefixes.py | 25 ++++++++++++------- modules/dashboard/tests/test_dashboard.py | 25 ++++++++----------- .../file_storage/file_storage/backends/s3.py | 14 +++++------ 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/framework/hosting/simple_module_hosting/_module_routes.py b/framework/hosting/simple_module_hosting/_module_routes.py index 087c7ada..037d221c 100644 --- a/framework/hosting/simple_module_hosting/_module_routes.py +++ b/framework/hosting/simple_module_hosting/_module_routes.py @@ -12,9 +12,13 @@ __all__ = ["wire_module_routes"] -def wire_module_routes(app: FastAPI, module) -> None: +def wire_module_routes(app: FastAPI, module) -> bool: """Attach a module's API + view (+ admin view) routers to ``app``. + Returns whether the module mounted any view routes. ``view_prefix=""`` means + both "no views" and "mounted at /", so only a router that actually received + routes says which one it is (#435). + The single canonical implementation so ``create_app`` and the test harness in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains a new router type. @@ -23,14 +27,7 @@ def wire_module_routes(app: FastAPI, module) -> None: view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"]) module.register_routes(api_router, view_router) _clone_bare_prefix_route(view_router, module.meta.view_prefix) - if view_router.routes: - # ``view_prefix=""`` means both "no views" and "mounted at /", so only - # a router that actually received routes says which one it is (#435). - # Dict order is discovery order. Lives on app.state until ``Services`` - # exists, then ``create_app`` hands it over as ``sm.view_prefixes``. - if not hasattr(app.state, "view_prefixes"): - app.state.view_prefixes = {} - app.state.view_prefixes[module.meta.name] = module.meta.view_prefix + has_views = bool(view_router.routes) app.include_router(api_router) app.include_router(view_router) @@ -46,6 +43,7 @@ def wire_module_routes(app: FastAPI, module) -> None: module.register_admin_routes(admin_router) _clone_bare_prefix_route(admin_router, admin_prefix) app.include_router(admin_router) + return has_views def _clone_bare_prefix_route(router: APIRouter, prefix: str) -> None: diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 10ce4f22..acf647b7 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -234,8 +234,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) # ── Phase 9: Routes, health, static files ────────────── + view_prefixes: dict[str, str] = {} # discovery order; only modules with view routes for mod in modules: - wire_module_routes(app, mod) + if wire_module_routes(app, mod): + view_prefixes[mod.meta.name] = mod.meta.view_prefix app.include_router(health_router) mount_setup_wizard(app, setup_registry) # /setup — what SetupMiddleware redirects to @@ -265,7 +267,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: invalidation=invalidation_bus, diagnostics=diagnostics_state, tenancy=mode_for(settings), - view_prefixes=getattr(app.state, "view_prefixes", {}), + view_prefixes=view_prefixes, ) return app diff --git a/framework/hosting/tests/test_view_prefixes.py b/framework/hosting/tests/test_view_prefixes.py index 147d5298..0c55fd01 100644 --- a/framework/hosting/tests/test_view_prefixes.py +++ b/framework/hosting/tests/test_view_prefixes.py @@ -20,13 +20,20 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None return _M() -def test_records_only_modules_with_view_routes_in_order(): +def test_returns_whether_module_mounted_view_routes(): app = FastAPI() - for m in ( - _module("Api", "", views=False, api=True), - _module("Root", "", views=True), - _module("Blog", "/blog", views=True), - _module("Empty", "/empty", views=False), - ): - wire_module_routes(app, m) - assert list(app.state.view_prefixes.items()) == [("Root", ""), ("Blog", "/blog")] + assert wire_module_routes(app, _module("Root", "", views=True)) is True + assert wire_module_routes(app, _module("Blog", "/blog", views=True)) is True + assert wire_module_routes(app, _module("Api", "", views=False, api=True)) is False + assert wire_module_routes(app, _module("Empty", "/empty", views=False)) is False + + +def test_services_view_prefixes_follow_discovery_order(app): + names = [m.meta.name for m in app.state.sm.modules] + recorded = list(app.state.sm.view_prefixes) + assert recorded == [n for n in names if n in recorded] + # Real module set: Users and Dashboard serve views, and each records its + # own prefix, not a placeholder. + by_name = {m.meta.name: m.meta.view_prefix for m in app.state.sm.modules} + assert {"Users", "Dashboard"} <= set(recorded) + assert all(app.state.sm.view_prefixes[n] == by_name[n] for n in recorded) diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index c7db9108..040efd9a 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -151,25 +151,20 @@ async def test_view_less_modules_get_an_empty_url( assert mod["url"] == "" or mod["url"].startswith("/"), mod async def test_root_mounted_module_gets_slash_url( - self, app, authenticated_client: httpx.AsyncClient + self, app, authenticated_client: httpx.AsyncClient, monkeypatch ): """#435: view_prefix "" with real view routes is `/`; without, still empty.""" sm = app.state.sm name = sm.modules[0].meta.name - saved = dict(sm.view_prefixes) - try: - sm.view_prefixes[name] = "" - resp = await authenticated_client.get(_STATS_URL) - urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} - assert urls[name] == "/" - sm.view_prefixes.pop(name) - invalidate_stats_cache() - resp = await authenticated_client.get(_STATS_URL) - urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} - assert urls[name] == "" - finally: - sm.view_prefixes.clear() - sm.view_prefixes.update(saved) + monkeypatch.setitem(sm.view_prefixes, name, "") + resp = await authenticated_client.get(_STATS_URL) + urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} + assert urls[name] == "/" + monkeypatch.delitem(sm.view_prefixes, name) + invalidate_stats_cache() + resp = await authenticated_client.get(_STATS_URL) + urls = {m["name"]: m["url"] for m in resp.json()["system_info"]["modules"]} + assert urls[name] == "" async def test_every_module_entry_reports_health(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.get(_STATS_URL) diff --git a/modules/file_storage/file_storage/backends/s3.py b/modules/file_storage/file_storage/backends/s3.py index 14e28d2e..95273c7d 100644 --- a/modules/file_storage/file_storage/backends/s3.py +++ b/modules/file_storage/file_storage/backends/s3.py @@ -23,6 +23,10 @@ if TYPE_CHECKING: pass +_MISSING_S3_EXTRA = ( + "S3 backend requires the 'aioboto3' package. Install with: uv add --optional s3 aioboto3" +) + class S3Backend: """S3-compatible object storage. @@ -40,10 +44,7 @@ def __init__(self, *, bucket: str, region: str, client_kwargs: dict[str, Any]) - try: import aioboto3 except ImportError as exc: - raise ConfigurationError( - "S3 backend requires the 'aioboto3' package. " - "Install with: uv add --optional s3 aioboto3" - ) from exc + raise ConfigurationError(_MISSING_S3_EXTRA) from exc self._aioboto3 = aioboto3 self._session = aioboto3.Session() self.bucket = bucket @@ -152,10 +153,7 @@ def _build(settings: FileStorageSettings) -> S3Backend: try: from botocore.config import Config except ImportError as exc: - raise ConfigurationError( - "S3 backend requires the 'aioboto3' package. " - "Install with: uv add --optional s3 aioboto3" - ) from exc + raise ConfigurationError(_MISSING_S3_EXTRA) from exc # Without an explicit version, botocore presigns SigV2 for custom endpoints. client_kwargs["config"] = Config(signature_version=settings.s3_signature_version) return S3Backend( From 7cb50b46bed4f90301c398b1d293d311be43a679 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:52:11 +0200 Subject: [PATCH 17/21] test(settings): isolate import-from-env tests from the developer's .env Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- modules/settings/tests/test_cli_import.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/settings/tests/test_cli_import.py b/modules/settings/tests/test_cli_import.py index 15e2208e..17c650f0 100644 --- a/modules/settings/tests/test_cli_import.py +++ b/modules/settings/tests/test_cli_import.py @@ -13,6 +13,13 @@ _FIXTURE_ENV = ("SM_AUTH_PROVIDER", "SM_BG_TASKS_BROADCAST_INVALIDATIONS") +@pytest.fixture(autouse=True) +def _isolate_project_root(monkeypatch, tmp_path) -> None: + # import_from_env reads ``.env`` via find_env_file(); pin it to an empty dir + # so no test picks up the developer's real repo ``.env``. + monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path)) + + def _drop_fixture_env(monkeypatch) -> None: for name in _FIXTURE_ENV: monkeypatch.delenv(name, raising=False) From 8aaad82e38ca834d0d856c97e2ffcf097759bfb4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 18:55:55 +0200 Subject: [PATCH 18/21] fix: address code review findings (round 1, pass 1) - doctor's locale checks read the ui catalog from its new home (#426) - host supplies a homeUrl default (Dashboard, else /) for installs without the users module, so Keycloak installs keep their home link (#427) - document that CSP sources are ASCII (punycode for IDNs) (#434) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- docs/module-authoring.md | 4 ++- framework/core/simple_module_core/__main__.py | 15 ++++++-- .../simple_module_hosting/_inertia_shared.py | 13 +++++++ .../simple_module_hosting/middleware.py | 3 ++ .../hosting/tests/test_home_url_default.py | 35 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 framework/hosting/tests/test_home_url_default.py diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 1191dfd4..38b92303 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -581,7 +581,9 @@ def register_csp_sources(self, registry): Providers must be synchronous and read in-memory state only (`app.state..settings`); a raising provider or an invalid pair is logged -once and dropped, leaving the static policy in force. See +once and dropped, leaving the static policy in force. Sources must be +printable ASCII, as CSP's own grammar requires, so an internationalised domain +is written in its punycode form (`https://xn--exmple-cua.com`). See [docs/framework/lifecycle.md](framework/lifecycle.md#register_csp_sourcesregistry). ## First-run setup steps diff --git a/framework/core/simple_module_core/__main__.py b/framework/core/simple_module_core/__main__.py index 6f28b929..98a1cec5 100644 --- a/framework/core/simple_module_core/__main__.py +++ b/framework/core/simple_module_core/__main__.py @@ -101,6 +101,15 @@ def _hosting_locales() -> Path | None: return locales if locales.is_dir() else None +def _hosting_ui_locales() -> Path | None: + """The shared ``ui`` catalog, which ships inside ``simple_module_hosting`` (#426).""" + hosting = _hosting_locales() + if hosting is None: + return None + ui = hosting.parent / "ui_locales" + return ui if ui.is_dir() else None + + def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]: """Return ``[(reporter, namespace, path), ...]`` for hosting + host + ui locale dirs.""" # Anchor on the same project root the `.env` was loaded from @@ -115,9 +124,9 @@ def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]: host_locales = root / "host" / "locales" if host_locales.is_dir(): out.append(("host", "host", host_locales)) - ui_locales = root / "packages" / "ui" / "locales" - if ui_locales.is_dir(): - out.append(("packages/ui", "ui", ui_locales)) + ui_locales = _hosting_ui_locales() + if ui_locales is not None: + out.append(("simple_module_hosting/ui", "ui", ui_locales)) return out diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index e698d797..30244609 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -144,3 +144,16 @@ async def merge_shared_prop_providers(app: Any, request: Request, shared: dict) ) continue shared[key] = value + + +def default_home_url(app: Any) -> str: + """Where the layouts' "home" links point when no module publishes ``homeUrl``. + + The users module publishes its configured landing URL and wins; this covers + installs without it (Keycloak). Dashboard is the conventional landing page, + named by string from ``sm.view_prefixes`` so nothing is imported (SM009); + ``/`` is a host route in every app, so the link can never 404. + """ + sm = getattr(app.state, "sm", None) + prefix = getattr(sm, "view_prefixes", {}).get("Dashboard") + return f"{prefix}/" if prefix else "/" diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index b1d59ab6..7de536b6 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -21,6 +21,7 @@ from simple_module_hosting._inertia_shared import ( build_i18n_block, build_menu_translator, + default_home_url, merge_shared_prop_providers, ) from simple_module_hosting._observability import ( @@ -260,6 +261,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # Merge module-registered shared-prop providers (e.g. branding) — read off # app.state without importing the plugin (SM009), defensively. await merge_shared_prop_providers(scope["app"], request, shared) + # After the providers, so a module's own value (users) wins. + shared.setdefault("homeUrl", default_home_url(scope["app"])) request.state.inertia_shared = shared diff --git a/framework/hosting/tests/test_home_url_default.py b/framework/hosting/tests/test_home_url_default.py new file mode 100644 index 00000000..eec14c9e --- /dev/null +++ b/framework/hosting/tests/test_home_url_default.py @@ -0,0 +1,35 @@ +"""The host supplies a ``homeUrl`` default when no module does (#427). + +The users module publishes its configured landing URL; an install without it +(Keycloak) must still point the layouts' home links at the Dashboard rather +than falling back to ``/``. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from simple_module_hosting._inertia_shared import default_home_url + + +def _app(prefixes: dict[str, str]) -> SimpleNamespace: + return SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(view_prefixes=prefixes))) + + +def test_dashboard_installed_is_home() -> None: + assert default_home_url(_app({"Users": "/users", "Dashboard": "/dashboard"})) == "/dashboard/" + + +def test_without_dashboard_home_is_root() -> None: + assert default_home_url(_app({"Admin": "/admin/things"})) == "/" + + +def test_no_services_yet_home_is_root() -> None: + assert default_home_url(SimpleNamespace(state=SimpleNamespace())) == "/" + + +async def test_page_payload_carries_home_url(authenticated_client) -> None: + # In the full app the users module's own value wins; either way the key is + # always present, so the client fallback is a last resort only. + resp = await authenticated_client.get("/admin/", headers={"X-Inertia": "true"}) + assert resp.json()["props"]["homeUrl"].endswith("/") From 4c04b14879c29fdfe751a4c37ded9d9b700c36da Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 19:08:09 +0200 Subject: [PATCH 19/21] fix: address code review findings (round 1, pass 2) - error, landing, accept-invite and reset-password pages follow homeUrl too (#427) - reject an unknown s3_signature_version at config time (#432) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- host/client_app/pages/Error.tsx | 4 +++- host/client_app/pages/Landing.tsx | 4 +++- modules/file_storage/file_storage/settings.py | 14 +++++++++++++- modules/file_storage/tests/test_s3_backend.py | 10 ++++++++++ modules/users/users/pages/AcceptInvite.tsx | 6 +++--- modules/users/users/pages/ResetPassword.tsx | 5 +++-- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx index d2f12ccd..19b631d0 100644 --- a/host/client_app/pages/Error.tsx +++ b/host/client_app/pages/Error.tsx @@ -4,6 +4,7 @@ import { CopyableId } from '@simple-module-py/ui/components/CopyableId'; import { ErrorScreen } from '@simple-module-py/ui/components/ErrorScreen'; import { InterpolatedText } from '@simple-module-py/ui/components/InterpolatedText'; import { Button } from '@simple-module-py/ui/components/ui/button'; +import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; import type { SharedProps } from '@simple-module-py/ui/types'; import type { ReactNode } from 'react'; import { ErrorShell, useInAdminShell } from '../components/ErrorShell'; @@ -121,6 +122,7 @@ function ErrorPage({ }: Props) { const { t } = useT(); const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const appHome = useHomeUrl(); const copy = useStatusCopy(status, Boolean(maintenance)); const inline = useInAdminShell(); @@ -150,7 +152,7 @@ function ErrorPage({ const showSignIn = Boolean(login_url); // "Home" is wherever this visitor's app starts. Sending a signed-in user to // the marketing page makes them navigate back in through the nav. - const homeHref = auth?.isAuthenticated ? '/dashboard/' : '/'; + const homeHref = auth?.isAuthenticated ? appHome : '/'; return ( <> diff --git a/host/client_app/pages/Landing.tsx b/host/client_app/pages/Landing.tsx index 5288d22a..791f8f68 100644 --- a/host/client_app/pages/Landing.tsx +++ b/host/client_app/pages/Landing.tsx @@ -4,6 +4,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { PublicLayout } from '@simple-module-py/ui/layouts/PublicLayout'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; +import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; import type { SharedProps } from '@simple-module-py/ui/types'; import { Database, LayoutTemplate, Package, Route, ShieldCheck, Stethoscope } from 'lucide-react'; import { CopyCommand } from '../components/CopyCommand'; @@ -38,12 +39,13 @@ const QUICKSTART: { comment: string; command: string; ok?: string }[] = [ function Landing() { const { t } = useT(); const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const appHome = useHomeUrl(); // The admin UI is the only thing behind a session here, so the one CTA that // leads anywhere private is auth-aware: a signed-in visitor is offered the // dashboard, everyone else the sign-in page. Never /auth/* — that is the // JSON API prefix and has no page (see host/tests/test_public_auth_links). - const adminHref = auth?.isAuthenticated ? '/dashboard/' : LOGIN_PATH; + const adminHref = auth?.isAuthenticated ? appHome : LOGIN_PATH; const features = [ { diff --git a/modules/file_storage/file_storage/settings.py b/modules/file_storage/file_storage/settings.py index 2c224e13..ad3dedc9 100644 --- a/modules/file_storage/file_storage/settings.py +++ b/modules/file_storage/file_storage/settings.py @@ -13,12 +13,15 @@ from pathlib import Path -from pydantic import Field, model_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import SettingsConfigDict from simple_module_core.settings_base import DbBackedSettings from file_storage import constants +# "s3" is SigV2, kept only for legacy S3-compatible stores that lack SigV4. +_SIGNATURE_VERSIONS = frozenset({"s3v4", "s3"}) + class FileStorageSettings(DbBackedSettings): """Configuration for the file_storage module. @@ -78,6 +81,15 @@ class FileStorageSettings(DbBackedSettings): description="Total storage the browse screen measures usage against. None = unmeasured.", ) + @field_validator("s3_signature_version") + @classmethod + def _known_signature_version(cls, value: str) -> str: + # botocore only fails on an unknown version when the first client is + # built, so an admin-UI typo would otherwise surface as failed uploads. + if value not in _SIGNATURE_VERSIONS: + raise ValueError(f"s3_signature_version must be one of {sorted(_SIGNATURE_VERSIONS)}") + return value + @model_validator(mode="after") def _validate_backend_config(self) -> FileStorageSettings: """Enforce per-backend required fields. diff --git a/modules/file_storage/tests/test_s3_backend.py b/modules/file_storage/tests/test_s3_backend.py index cfd77564..50cc3397 100644 --- a/modules/file_storage/tests/test_s3_backend.py +++ b/modules/file_storage/tests/test_s3_backend.py @@ -109,3 +109,13 @@ async def test_s3_signature_version_is_configurable(s3_settings): backend = build_backend(s3_settings) url = await backend.presigned_get_url("any/key.txt", ttl_seconds=300) assert "AWSAccessKeyId=" in url + + +@pytest.mark.parametrize("value", ["s3v5", "", "v4"]) +def test_unknown_signature_version_is_rejected_at_config_time(value) -> None: + # A typo saved in the admin UI must fail validation, not every upload. + from file_storage.settings import FileStorageSettings + from pydantic import ValidationError + + with pytest.raises(ValidationError): + FileStorageSettings(s3_signature_version=value) diff --git a/modules/users/users/pages/AcceptInvite.tsx b/modules/users/users/pages/AcceptInvite.tsx index 7325b2ff..36a71250 100644 --- a/modules/users/users/pages/AcceptInvite.tsx +++ b/modules/users/users/pages/AcceptInvite.tsx @@ -5,6 +5,7 @@ import { Input } from '@simple-module-py/ui/components/ui/input'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { BRAND_DEFAULT_APP_NAME } from '@simple-module-py/ui/lib/brand'; +import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; import { TimerOff } from 'lucide-react'; import { useState } from 'react'; import { AuthField } from '../auth_local/components/AuthField'; @@ -13,8 +14,6 @@ import { AuthStateCard } from '../auth_local/components/AuthStateCard'; import { InviteSummary } from '../auth_local/components/InviteSummary'; import { PasswordFields } from '../auth_local/components/PasswordFields'; -const DASHBOARD_PATH = '/dashboard/'; - interface InvitePreview { email: string; roles: string[]; @@ -33,6 +32,7 @@ interface Props { } function AcceptInvite() { + const homeUrl = useHomeUrl(); const { token, invite, branding } = usePage<{ props: Props }>().props as unknown as Props; const { t } = useT(); @@ -59,7 +59,7 @@ function AcceptInvite() { }) .then(async (res) => { if (res.status === 204 || res.status === 200) { - router.visit(DASHBOARD_PATH); + router.visit(homeUrl); } else { const data = await res.json().catch(() => ({})); const detail = typeof data?.detail === 'string' ? data.detail : ''; diff --git a/modules/users/users/pages/ResetPassword.tsx b/modules/users/users/pages/ResetPassword.tsx index 00dde9a9..2bdd691f 100644 --- a/modules/users/users/pages/ResetPassword.tsx +++ b/modules/users/users/pages/ResetPassword.tsx @@ -3,13 +3,13 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; +import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; import { CheckCircle2, TimerOff } from 'lucide-react'; import { useState } from 'react'; import { AuthStateCard } from '../auth_local/components/AuthStateCard'; import { PasswordFields } from '../auth_local/components/PasswordFields'; const FORGOT_PATH = '/users/forgot-password'; -const DASHBOARD_PATH = '/dashboard/'; interface Props { token: string; @@ -21,6 +21,7 @@ interface Props { } function ResetPassword() { + const homeUrl = useHomeUrl(); const { token, email, @@ -79,7 +80,7 @@ function ResetPassword() { return; } if (await signIn()) { - router.visit(DASHBOARD_PATH); + router.visit(homeUrl); return; } // The password *was* saved — the reset returned 200. Reporting a From 1a9426b687c91856d932e14b75741a440ef89c09 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 19:18:14 +0200 Subject: [PATCH 20/21] fix: address code review findings (round 1, pass 3) - useHomeUrl accepts same-origin paths only, else / (#427) - default footer caption has no trailing space for a blank app name (#428) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- packages/ui/src/components/BrandingFooter.test.tsx | 6 ++++++ packages/ui/src/components/BrandingFooter.tsx | 2 +- packages/ui/src/lib/home-url.test.tsx | 11 +++++++++++ packages/ui/src/lib/home-url.ts | 3 ++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/BrandingFooter.test.tsx b/packages/ui/src/components/BrandingFooter.test.tsx index ea020d56..3688daf1 100644 --- a/packages/ui/src/components/BrandingFooter.test.tsx +++ b/packages/ui/src/components/BrandingFooter.test.tsx @@ -90,4 +90,10 @@ describe('BrandingFooter', () => { expect(screen.getByText(`© ${year} Acme`)).toBeInTheDocument(); expect(screen.queryByText(/MIT/)).not.toBeInTheDocument(); }); + + it('drops the trailing space when the app name is blank', () => { + const year = new Date().getFullYear(); + render(); + expect(screen.getByText(`© ${year}`)).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx index 5ab35586..d70d37b3 100644 --- a/packages/ui/src/components/BrandingFooter.tsx +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -45,7 +45,7 @@ export function BrandingFooter({ // Trimmed for the same reason the link list is length-checked: whitespace is // what an admin leaves behind when clearing the field, and a blank caption // reads as a broken footer rather than a deliberate one. - const caption = footerText?.trim() || `© ${FOOTER_YEAR} ${appName}`; + const caption = footerText?.trim() || `© ${FOOTER_YEAR} ${appName}`.trim(); const container = variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; diff --git a/packages/ui/src/lib/home-url.test.tsx b/packages/ui/src/lib/home-url.test.tsx index b3ed246b..86b2373c 100644 --- a/packages/ui/src/lib/home-url.test.tsx +++ b/packages/ui/src/lib/home-url.test.tsx @@ -19,4 +19,15 @@ describe('useHomeUrl', () => { it('falls back to / when absent', () => { expect(renderHook(() => useHomeUrl()).result.current).toBe('/'); }); + + it.each([ + 'https://evil.example/', + '//evil.example/', + '/\\evil.example/', + 'javascript:alert(1)', + 'dashboard/', + ])('falls back to / for a value that is not a same-origin path: %s', (homeUrl) => { + props = { homeUrl }; + expect(renderHook(() => useHomeUrl()).result.current).toBe('/'); + }); }); diff --git a/packages/ui/src/lib/home-url.ts b/packages/ui/src/lib/home-url.ts index d4eafbb7..8cd3895a 100644 --- a/packages/ui/src/lib/home-url.ts +++ b/packages/ui/src/lib/home-url.ts @@ -7,5 +7,6 @@ import type { SharedProps } from '../types'; */ export function useHomeUrl(): string { const { homeUrl } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; - return homeUrl || '/'; + // Same-origin paths only: the value is admin-editable and now feeds hrefs. + return homeUrl && /^\/(?![/\\])/.test(homeUrl) ? homeUrl : '/'; } From 568d2147323e4a56f19f0482a2f74718b0a99150 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 11 Oct 2026 19:24:58 +0200 Subject: [PATCH 21/21] test(e2e): shell home link, footer caption and sign-in alert (#426, #427, #428, #437) Claude-Session: https://claude.ai/code/session_01NJyEUshNM4K1e66dRgbyj4 --- tests/e2e/test_shell_home_and_signin.py | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/e2e/test_shell_home_and_signin.py diff --git a/tests/e2e/test_shell_home_and_signin.py b/tests/e2e/test_shell_home_and_signin.py new file mode 100644 index 00000000..d72b3484 --- /dev/null +++ b/tests/e2e/test_shell_home_and_signin.py @@ -0,0 +1,62 @@ +"""E2E coverage for the app shell's home links, footer and sign-in error. + +* the admin shell renders the shared ``ui`` catalog, never raw ``ui.*`` keys + (#426), and its way back to the app follows the ``homeUrl`` shared prop + rather than a hardcoded ``/dashboard/`` (#427); +* the default footer caption names the app, not the framework's licence (#428); +* a failed sign-in is announced to assistive technology (#437). +""" + +from __future__ import annotations + +import re + +import pytest +from playwright.sync_api import Page, expect + +pytestmark = pytest.mark.e2e + +_RAW_UI_KEY = re.compile(r"\bui\.[a-z_]+\.[a-z_]+") + + +def _login(page: Page, username: str, password: str) -> None: + page.goto("/users/login") + page.locator("#email").fill(username) + page.locator("#password").fill(password) + page.get_by_role("button", name="Sign in").click() + page.wait_for_url("**/dashboard/**", timeout=15_000) + + +def test_admin_shell_is_translated_and_links_home( + page: Page, e2e_username: str, e2e_password: str +) -> None: + _login(page, e2e_username, e2e_password) + page.goto("/admin/users/") + back = page.get_by_role("link", name="Back to app") + expect(back).to_be_visible() + assert not _RAW_UI_KEY.search(page.locator("body").inner_text()) + + home = page.evaluate( + "() => JSON.parse(document.getElementById('app').dataset.page).props.homeUrl" + ) + assert home.startswith("/") + expect(back).to_have_attribute("href", home) + back.click() + page.wait_for_url(f"**{home}**") + + +def test_default_footer_names_the_app_not_a_licence(page: Page) -> None: + # The public landing page renders BrandingFooter; the sign-in page has no footer. + page.goto("/") + footer = page.locator("footer").last + expect(footer).to_contain_text("©") + assert "MIT" not in footer.inner_text() + + +def test_failed_sign_in_is_announced(page: Page, e2e_username: str) -> None: + # One deliberate failure: the limiter allows five per window. + page.goto("/users/login") + page.locator("#email").fill(e2e_username) + page.locator("#password").fill("definitely-not-the-password") + page.get_by_role("button", name="Sign in").click() + expect(page.get_by_role("alert")).to_be_visible()