Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default defineConfig({
{ text: "settings", link: "/modules/settings" },
{ text: "feature_flags", link: "/modules/feature_flags" },
{ text: "file_storage", link: "/modules/file_storage" },
{ text: "branding", link: "/modules/branding" },
{ text: "background_tasks", link: "/modules/background_tasks" },
{ text: "audit_log", link: "/modules/audit_log" },
{ text: "dashboard", link: "/modules/dashboard" },
Expand Down
110 changes: 110 additions & 0 deletions docs/modules/branding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# branding

Lets an administrator customise the application's identity — **app name**, **logo**, **favicon**, and **primary colour** — from an admin page, with no code change or redeploy. Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page (authenticated *and* guest) through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, and apply the brand colour everywhere.

## ModuleMeta

| Field | Value |
|---|---|
| `name` | `Branding` |
| `route_prefix` | `/api/branding` |
| `view_prefix` | `/branding` |
| `depends_on` | `["Settings", "FileStorage"]` |

It depends on `settings` for storage and `file_storage` for the uploaded logo/favicon bytes.

## Routes

### API

All JSON endpoints — including the read — require `branding.manage` (they back the admin editor).

| Method + path | Body / response | Permission |
|---|---|---|
| `GET /api/branding/` | → `BrandingOut` | `branding.manage` |
| `PUT /api/branding/` | `BrandingUpdate` → `BrandingOut` | `branding.manage` |
| `POST /api/branding/logo` | `multipart` (field `file`) → `BrandingOut` | `branding.manage` |
| `POST /api/branding/favicon` | `multipart` (field `file`) → `BrandingOut` | `branding.manage` |
| `DELETE /api/branding/logo` | → `BrandingOut` (logo cleared) | `branding.manage` |
| `DELETE /api/branding/favicon` | → `BrandingOut` (favicon cleared) | `branding.manage` |

`PUT` only touches the text fields (`app_name`, `primary_color`); the logo and favicon are set/cleared through their dedicated upload/delete routes. Uploads are validated **before** the bytes are handed to `file_storage` — an unsupported MIME type returns `415`, an oversized image returns `413` (see [Image guard-rails](#image-guard-rails)).

### View

| Method + path | Inertia component | Permission |
|---|---|---|
| `GET /branding/` | `Branding/Manage` | `branding.view` |

The page reads the current values from the shared `branding` prop, so the view endpoint passes no page props of its own.

## Public contracts

```python
from branding.contracts import BrandingOut, BrandingUpdate
```

| Class | Purpose |
|---|---|
| `BrandingOut` | Current branding with logo/favicon resolved to download URLs: `app_name`, `primary_color`, `logo_url`, `favicon_url`. |
| `BrandingUpdate` | Editable text fields only: `app_name` (≤ 60 chars, non-blank), `primary_color` (`#rrggbb` or empty). |

## Models

**None.** Branding owns no tables. The four values are stored in the shared settings store at **SYSTEM** scope, hydrated into `app.state.branding.settings` at boot, and hot-swapped on save via the settings reload path.

## Settings

DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited from the dedicated **Branding** admin page (`/branding`) rather than the generic settings UI.

| Field | Default | Purpose |
|---|---|---|
| `app_name` | `"SimpleModule"` | Application name (trimmed; must be non-blank and ≤ 60 chars). |
| `primary_color` | `""` | Brand colour as a lowercase `#rrggbb` hex string; `""` ⇒ use the theme default. |
| `logo_file_id` | `""` | `file_storage` UUID of the uploaded logo; `""` ⇒ no custom logo. |
| `favicon_file_id` | `""` | `file_storage` UUID of the uploaded favicon; `""` ⇒ no custom favicon. |

### Image guard-rails

Enforced in the API before the upload reaches `file_storage`:

- **Max size:** 2 MB (`413` otherwise).
- **Allowed types:** `image/png`, `image/jpeg`, `image/svg+xml`, `image/webp`, `image/gif`, `image/x-icon` / `image/vnd.microsoft.icon` (`415` otherwise).

## How branding reaches the frontend

On startup the module registers a shared-props provider (`register_inertia_shared_provider`). On every Inertia render — guest pages included — it emits a `branding` block built from the live module settings:

```json
{
"branding": {
"appName": "Acme Corp",
"primaryColor": "#1d4ed8",
"logoUrl": "/api/file-storage/files/<id>/download",
"faviconUrl": "/api/file-storage/files/<id>/download"
}
}
```

`primaryColor` is `null` when unset; `logoUrl` / `faviconUrl` are `null` when no file is configured (otherwise a `file_storage` download URL derived from the stored id). The provider is defensive — it returns `{}` if branding state isn't mounted yet, so a half-booted app never errors a render. Because changes go through the settings store, a save hot-reloads `app.state.branding.settings`; the next render reflects the new values without a restart.

## Permissions

| Code | Granted to | Purpose |
|---|---|---|
| `branding.view` | `admin` | open the Branding admin page (`/branding`) |
| `branding.manage` | `admin` | read + write branding via the API (edit name/colour, upload/clear logo + favicon) |

## Menu

| Label | URL | Icon | Section | Group | Order | Roles |
|---|---|---|---|---|---|---|
| `Branding` | `/branding` | `palette` | `SIDEBAR` | `Administration` | `115` | `["admin"]` |

## Inertia pages

- `Branding/Manage.tsx` — the admin editor: app-name + colour form, logo and favicon upload/clear, and a live preview.

## Locales

`branding/locales/en.json` — namespace `branding`, top-level key `manage` (the admin page strings).
5 changes: 5 additions & 0 deletions docs/modules/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ It's intentionally simple — a place for new installs to land that proves the p
| Method + path | Inertia component | Permission |
|---|---|---|
| `GET /dashboard/` | `Dashboard/Home` | authenticated user (any role) |
| `GET /dashboard/doctor` | `Dashboard/Doctor` | authenticated user (any role) |

`/dashboard/doctor` is a browser mirror of `make doctor` — it shows the same module list, static checks, dev-server and environment info from the stats payload. The route itself only requires login; its sidebar link is admin-only (see [Menu](#menu)).

### API

Expand Down Expand Up @@ -49,6 +52,7 @@ The result is cached process-wide for 30 seconds. If you mutate something that s
| Label | URL | Icon | Section | Order |
|---|---|---|---|---|
| `Dashboard` | `/dashboard/` | `home` | `SIDEBAR` | `10` |
| `Doctor` | `/dashboard/doctor` | `stethoscope` | `ADMIN_SIDEBAR` | `90` |

## Permissions

Expand All @@ -57,6 +61,7 @@ _(none registered)_ — the page is gated by authentication only, via the `users
## Inertia pages

- `Dashboard/Home.tsx` — single page rendering the stats card, system info card, and welcome card. Kept simple on purpose so it's a useful starting template for a custom landing page.
- `Dashboard/Doctor.tsx` — the `make doctor` mirror (module list, static checks, dev-server + environment info).

## Locales

Expand Down
3 changes: 2 additions & 1 deletion docs/modules/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Bundled modules

simple_module_python ships with ten first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.
simple_module_python ships with eleven first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.

| Module | Depends on | What it provides |
|---|---|---|
Expand All @@ -11,6 +11,7 @@ simple_module_python ships with ten first-party modules. Each is a regular Pytho
| [`settings`](/modules/settings) | — | DB-backed key/value store with system / tenant / user precedence; per-module pydantic settings registration; hot reload; `smpy settings` CLI. |
| [`feature_flags`](/modules/feature_flags) | — | Runtime feature toggles with system + tenant overrides. |
| [`file_storage`](/modules/file_storage) | `settings` | Pluggable file storage (filesystem, S3-compatible) with upload validation, presigned URLs, browse/download/delete UI. |
| [`branding`](/modules/branding) | `settings`, `file_storage` | Admin-configurable app identity — app name, logo, favicon and primary colour — pushed to every page via Inertia shared props. |
| [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. |
| [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. |
| [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |
Expand Down
69 changes: 51 additions & 18 deletions docs/modules/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ The module is built on [`fastapi-users`](https://fastapi-users.github.io/) for p
| `POST /api/users/auth/request-verify-token` | `RequestVerifyToken` | rate-limited |
| `POST /api/users/auth/verify` | `VerifyRequest` | |
| `POST /api/users/auth/accept-invite` | `AcceptInviteRequest` | sets password + signs the user in |
| `POST /api/users/auth/token` | `TokenRequest` (email + password) | bearer login for mobile / API clients → `{access_token, refresh_token, token_type, expires_in}`; `401` for external/SSO users |
| `POST /api/users/auth/token/refresh` | `RefreshRequest` (refresh_token) | rotates a refresh token into a new pair (old one revoked) |
| `DELETE /api/users/auth/token` | `RefreshRequest` (refresh_token) | revokes a refresh token (idempotent) |
| `GET /api/users/auth/{provider}/login` | — | OAuth: redirect to the IdP (`provider` ∈ configured set) |
| `GET /api/users/auth/{provider}/callback` | `?code=&state=` | OAuth: find-or-create user, set cookie, 303 to `login_redirect_url` |

Expand All @@ -47,12 +50,17 @@ The module is built on [`fastapi-users`](https://fastapi-users.github.io/) for p
| Method + path | Body / response |
|---|---|
| `GET /api/users/admin` | `?q=&status=&role=&verified=&sort=&order=&page=&per_page=` → `list[UserListItem]` |
| `POST /api/users/admin/invite` | `UserInvite` → `UserListItem` |
| `POST /api/users/admin/{user_id}/disable` | → `UserListItem` |
| `POST /api/users/admin/{user_id}/enable` | → `UserListItem` |
| `POST /api/users/admin/{user_id}/roles` | `RoleAssignment` |
| `POST /api/users/admin/{user_id}/mark-verified` | → `UserListItem` |
| `POST /api/users/admin/{user_id}/reset-password-link` | → `PasswordResetLink` |
| `POST /api/users/admin` | `UserAdminCreate` → `UserListItem` (201) — active+verified user with an admin-set password |
| `POST /api/users/admin/invite` | `UserInvite` → `UserListItem` (201) |
| `PATCH /api/users/admin/{user_id}` | `UserDetailsUpdate` → `UserListItem` — edit email + full name |
| `DELETE /api/users/admin/{user_id}` | → `204` (hard delete; an admin cannot delete their own account → `400`) |
| `PATCH /api/users/admin/{user_id}/disable` | → `UserListItem` |
| `PATCH /api/users/admin/{user_id}/enable` | → `UserListItem` |
| `PUT /api/users/admin/{user_id}/roles` | `RoleAssignment` → `UserListItem` |
| `PATCH /api/users/admin/{user_id}/verify` | → `UserListItem` (mark verified; idempotent) |
| `POST /api/users/admin/{user_id}/reset-password-link` | → `PasswordResetLink` (`409` for external/SSO users) |

`POST /api/users/admin` creates an **active + verified** user directly — no invite email, no verification flow; the admin sets the password. It returns `409` if the email is already taken and `400` for an invalid password. The matching admin UI (Create form, Edit details card, and a delete "danger zone") lives under `/users/admin` — see [View routes](#view-routes) and [Inertia pages](#inertia-pages).

### View routes

Expand All @@ -75,27 +83,32 @@ Admin (`users.manage`):

- `GET /users/admin` → `Users/Users/Index`
- `GET /users/admin/invite` → `Users/Users/Invite`
- `GET /users/admin/{user_id}/edit` → `Users/Users/Edit`
- `GET /users/admin/create` → `Users/Users/Create`
- `GET /users/admin/{user_id}` → `Users/Users/Edit`

## Public contracts

```python
from users.contracts import (
from users.contracts.schemas import (
UserRead, UserCreate, UserUpdate, UserInvite,
UserAdminCreate, UserDetailsUpdate,
UserListItem, RoleListItem, RoleAssignment,
AcceptInviteRequest, PasswordResetLink, SelfProfileUpdate,
)
from users.contracts.events import (
UserRegistered, UserInvited, UserDisabled, RoleAssigned,
UserRegistered, UserInvited, UserCreated, UserDeleted,
UserDisabled, RoleAssigned,
)
```

| Class | Purpose |
|---|---|
| `UserRead` | `id`, `email`, `is_active`, `is_superuser`, `is_verified`, `full_name`, `tenant_id`, `disabled_at`, `last_login_at`. |
| `UserListItem` | Admin list row with `roles`. |
| `UserRead` | `id`, `email`, `is_active`, `is_superuser`, `is_verified`, `is_external`, `full_name`, `tenant_id`, `disabled_at`, `last_login_at`. |
| `UserAdminCreate` | Admin create-user input: `email`, `password`, `full_name`, `role_names`. |
| `UserDetailsUpdate` | Admin edit input: `email`, `full_name`. |
| `UserListItem` | Admin list row; adds `is_external`, `created_at`, `roles`. |
| `RoleListItem` | `id`, `name`, `description`, `user_count`. |
| `UserRegistered`, `UserInvited`, `UserDisabled`, `RoleAssigned` | Events — see [Events](#events). |
| `UserRegistered`, `UserInvited`, `UserCreated`, `UserDeleted`, `UserDisabled`, `RoleAssigned` | Events — see [Events](#events). |

## Models

Expand All @@ -105,8 +118,9 @@ from users.contracts.events import (
|---|---|---|
| `id` | `UUID` | PK |
| `email` | `str` | unique, indexed; functional index on `lower(email)` |
| `hashed_password` | `str` | |
| `hashed_password` | `str \| None` | **nullable** — external (SSO) users have no local password (see [External / SSO users](#external-sso-users)) |
| `is_active` / `is_superuser` / `is_verified` | `bool` | |
| `is_external` | `bool` | `True` for users provisioned via an external IdP; default `False` (`server_default false`) |
| `full_name` | `str \| None` | |
| `tenant_id` | `str \| None` | indexed; only set when multi-tenant |
| `disabled_at` | `datetime \| None` | |
Expand Down Expand Up @@ -191,8 +205,10 @@ Everything else is DB-backed (initial values are pydantic defaults; edit at `/se
|---|---|---|
| `UserRegistered` | `user_id`, `email` | on signup |
| `UserInvited` | `user_id`, `email`, `invited_by` | on admin invite |
| `UserCreated` | `user_id`, `email`, `created_by` | on admin create (`POST /api/users/admin`) |
| `UserDeleted` | `user_id` | on admin delete |
| `UserDisabled` | `user_id` | on admin disable |
| `RoleAssigned` | `user_id`, `role_name` | once per role on `POST /admin/{user_id}/roles` |
| `RoleAssigned` | `user_id`, `role_name` | once per role on `PUT /admin/{user_id}/roles` |

## CLI

Expand Down Expand Up @@ -242,7 +258,22 @@ Built-in provider keys (the `{provider}` URL segment):
| Microsoft (Entra ID) | `microsoft` | `oauth_microsoft_tenant`: `"common"` (any account), `"organizations"` (work/school), or a tenant GUID |
| Generic OIDC | `oidc` | any provider exposing a discovery URL (Keycloak, Authentik, Auth0, Zitadel, …); discovery failure logs + disables rather than breaking boot |

A single dispatcher pair (`/api/users/auth/{provider}/login` + `/callback`) serves every provider; the client is resolved per request from the cache. The `/callback` returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through `UserManager.oauth_callback` (`associate_by_email=True`, `is_verified_by_default=True`); state CSRF uses the signed session cookie. Linked accounts are stored in `users_oauth_account`.
A single dispatcher pair (`/api/users/auth/{provider}/login` + `/callback`) serves every provider; the client is resolved per request from the cache. The `/callback` returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through `UserManager.oauth_callback` (`associate_by_email=True`, `is_verified_by_default=True`); state CSRF uses the signed session cookie. Linked accounts are stored in `users_oauth_account`. A **newly provisioned** OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged.

## External / SSO users

Users created through an external IdP (Google, GitHub, Microsoft/Entra ID, or generic OIDC) are provisioned with `is_external=True` and **no local password** (`hashed_password` is `NULL`). The OAuth callback's find-or-create only nulls the password + marks external for accounts it *creates* — an existing password account that gets linked by email keeps its password and stays non-external. External users sign in **only** through their IdP; roles are still assigned locally like any other user.

Every password-credential path guards against external users, server-side:

| Action | Behaviour for an external user |
|---|---|
| `POST /api/users/auth/login` (session) | `401` — treated like a missing user (a dummy bcrypt verify still runs, so timing doesn't leak that the account is SSO-only) |
| `POST /api/users/auth/token` (bearer) | `401` — same guard as session login |
| `POST /api/users/auth/forgot-password` | `200` but a **silent no-op** — preserves anti-enumeration |
| `POST /api/users/admin/{user_id}/reset-password-link` | `409` — raises `ExternalUserNoPasswordError`; there is no password to reset |

In the admin UI, the user list and edit page show an **"External · SSO"** badge, and the password-reset action is hidden with an explanation. Disable / enable, role assignment, and delete work the same as for any other user.

## UsersAuthProvider

Expand All @@ -267,10 +298,12 @@ Auth flow:
- `Users/Login.tsx`, `Users/Register.tsx`, `Users/ForgotPassword.tsx`, `Users/ResetPassword.tsx`, `Users/VerifyEmail.tsx`, `Users/AcceptInvite.tsx`, `Users/Profile.tsx`.

Admin:
- `Users/Users/Index.tsx`, `Users/Users/Invite.tsx`, `Users/Users/Edit.tsx`.
- `Users/Users/Index.tsx` (list + Create button), `Users/Users/Invite.tsx`, `Users/Users/Create.tsx`, `Users/Users/Edit.tsx`.

Components:
- `Users/components/IndexFilters.tsx`, `Users/components/RolesTab.tsx`.
Components (under `pages/Users/components/`):
- `AccountStatusCard.tsx` — status block, including the **External · SSO** badge and the hidden-password-reset explanation for external users.
- `DetailsCard.tsx` — edit email + full name.
- `DangerZone.tsx` — delete-user action with confirmation.

## Notes

Expand Down
Loading
Loading