diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e65cfcd0..6d493204 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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" }, diff --git a/docs/modules/branding.md b/docs/modules/branding.md new file mode 100644 index 00000000..ac93ab74 --- /dev/null +++ b/docs/modules/branding.md @@ -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//download", + "faviconUrl": "/api/file-storage/files//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). diff --git a/docs/modules/dashboard.md b/docs/modules/dashboard.md index 3fe04b25..09924d2c 100644 --- a/docs/modules/dashboard.md +++ b/docs/modules/dashboard.md @@ -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 @@ -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 @@ -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 diff --git a/docs/modules/index.md b/docs/modules/index.md index 1d67d9df..a7345be4 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -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 | |---|---|---| @@ -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). | diff --git a/docs/modules/users.md b/docs/modules/users.md index c50976b5..016ecc35 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -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` | @@ -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 @@ -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 @@ -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` | | @@ -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 @@ -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 @@ -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 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 14b43b6b..c81fcafc 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -10,6 +10,7 @@ Before serving traffic: - [ ] `SM_SECRET_KEY` is a strong random value (not the default). - [ ] `SM_DATABASE_URL` points at Postgres (`postgresql+asyncpg://`), not SQLite. - [ ] `alembic upgrade head` run against the production DB. +- [ ] Frontend built (`npm run build` → `static/dist/`) and bundled into the image — outside `development`/`testing`, the app renders assets from the Vite manifest, not a dev server (see [Static assets](#static-assets)). - [ ] App boot in a non-development `SM_ENVIRONMENT` starts clean. Module discovery runs strict (missing/invalid `meta` and entry-point failures raise), and the migration check (SM010) aborts boot — so a successful start covers those. The full page/locale/auth-provider diagnostic suite only runs in development, so do a clean dev boot before shipping (see [diagnostic codes](/reference/diagnostic-codes)). - [ ] Admin bootstrap complete — an admin user exists and can log in. - [ ] Reverse proxy forwards `X-Forwarded-Proto`/`X-Forwarded-For`; configured with `--proxy-headers`. @@ -141,6 +142,13 @@ location /static/ { Vite emits filenames with content hashes, so long cache TTLs are safe. +### Production rendering & cache headers + +Outside `development`/`testing` (i.e. any other `SM_ENVIRONMENT`), the app serves the built frontend instead of the Vite dev server: + +- **Manifest-based Inertia rendering.** In dev/testing the page loads `main.tsx` from the Vite dev server; otherwise the app reads the built Vite manifest and emits content-hashed `