feat(admin): branding - custom logo, colors, login text (LC-96) #158

Merged
nrupard merged 3 commits from feat/lc-96-branding into main 2026-05-20 16:00:40 +02:00
Owner

Summary

Implements LC-96: per-scope branding (logo + primary/accent colors + login heading/body), global for standalone and per-enclave for SaaS.

  • Schema chat/0033_branding.sql: branding(scope_kind, scope_id, logo_upload_id, primary_color, accent_color, login_heading, login_body, ...) with ('global', 0) seeded. Per-enclave rows fall back to global at resolution. logo_upload_id is a soft reference (not a FK) so the trimmed-migration test pools don't all need 0012_uploads; the orphan sweep exempts referenced logos via a subquery.
  • Colors via middleware (routes::branding::inject_branding_css): resolves scope from the request path and stamps <style>:root{--brand-primary;--brand-accent}</style> before </head> of every text/html 2xx response. No per-page struct threading, no Tailwind rebuild. assets/main.css carries the --brand-* defaults + .brand-bg/.brand-text/.brand-border utilities.
  • Logo: /branding/logo (public, login is pre-auth) + /enclave/{id}/branding/logo (membership-gated), both falling back to global. Uploaded through the existing file_uploads pipeline via a shared multipart parser.
  • Login page: heading (plain text) + body (new markdown::render_login_body, strips raw HTML + fenced code blocks so syntect never loads). Sign-in button uses brand-bg.
  • Admin UI: /admin/branding (standalone, admin-gated) for global; /enclave/{id}/branding (manage-gated) for per-enclave, linked from enclave settings. <input type="color"> pickers; both audit-log branding_set.

Test plan

  • just check (both feature builds, clippy -D warnings, fmt --check).
  • just test + just test-saas green. New routes_branding.rs (9 cases): resolve fallback, per-enclave precedence, path-scope parse, public-logo 404, middleware injection (global + enclave-scope), admin gating, form persistence, color-validation rejection. The 3 admin-form tests are #[cfg(feature = "standalone")].
  • Migration 0033 backfilled into the 14 hand-rolled-migration test files.
  • Manual: set a logo + colors at /admin/branding, confirm login page + in-app colors change; set a different per-enclave color and confirm it wins under /enclave/{id}/.

Out of scope (locked plan)

Favicon, custom CSS injection, i18n. In-app sidebar logo placement is a follow-up — login + the admin preview cover logo visibility for now, and colors already propagate app-wide through the middleware.

## Summary Implements LC-96: per-scope branding (logo + primary/accent colors + login heading/body), global for standalone and per-enclave for SaaS. - **Schema** `chat/0033_branding.sql`: `branding(scope_kind, scope_id, logo_upload_id, primary_color, accent_color, login_heading, login_body, ...)` with `('global', 0)` seeded. Per-enclave rows fall back to global at resolution. `logo_upload_id` is a soft reference (not a FK) so the trimmed-migration test pools don't all need `0012_uploads`; the orphan sweep exempts referenced logos via a subquery. - **Colors via middleware** (`routes::branding::inject_branding_css`): resolves scope from the request path and stamps `<style>:root{--brand-primary;--brand-accent}</style>` before `</head>` of every `text/html` 2xx response. No per-page struct threading, no Tailwind rebuild. `assets/main.css` carries the `--brand-*` defaults + `.brand-bg/.brand-text/.brand-border` utilities. - **Logo**: `/branding/logo` (public, login is pre-auth) + `/enclave/{id}/branding/logo` (membership-gated), both falling back to global. Uploaded through the existing `file_uploads` pipeline via a shared multipart parser. - **Login page**: heading (plain text) + body (new `markdown::render_login_body`, strips raw HTML + fenced code blocks so syntect never loads). Sign-in button uses `brand-bg`. - **Admin UI**: `/admin/branding` (standalone, admin-gated) for global; `/enclave/{id}/branding` (manage-gated) for per-enclave, linked from enclave settings. `<input type="color">` pickers; both audit-log `branding_set`. ## Test plan - [x] `just check` (both feature builds, clippy -D warnings, fmt --check). - [x] `just test` + `just test-saas` green. New `routes_branding.rs` (9 cases): resolve fallback, per-enclave precedence, path-scope parse, public-logo 404, middleware injection (global + enclave-scope), admin gating, form persistence, color-validation rejection. The 3 admin-form tests are `#[cfg(feature = "standalone")]`. - [x] Migration 0033 backfilled into the 14 hand-rolled-migration test files. - [ ] Manual: set a logo + colors at `/admin/branding`, confirm login page + in-app colors change; set a different per-enclave color and confirm it wins under `/enclave/{id}/`. ## Out of scope (locked plan) Favicon, custom CSS injection, i18n. In-app **sidebar logo placement** is a follow-up — login + the admin preview cover logo visibility for now, and colors already propagate app-wide through the middleware.
feat(branding): custom logo, colors, login text (LC-96)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m9s
27dfce2ef6
Per-scope branding for standalone (global) and SaaS (per-enclave).

Schema: chat/0033 adds a `branding(scope_kind, scope_id, ...)` table. Global lives at `('global', 0)` (seeded by the migration); per-enclave rows at `('enclave', N)` fall back to global at resolution time. `logo_upload_id` is a soft reference into `file_uploads` (not a FK, so test pools with trimmed migration lists don't all need `0012_uploads` upstream); the orphan sweep exempts whatever a branding row points at.

Colors propagate via a tower middleware (`routes::branding::inject_branding_css`) that resolves the scope from the request path and stamps `<style>:root{--brand-primary:...;--brand-accent:...}</style>` before `</head>` of every text/html response. No per-page struct threading and no Tailwind rebuild: `assets/main.css` defines the `--brand-*` defaults + `.brand-bg` / `.brand-text` / `.brand-border` utility classes that read the vars.

Logo: served from `/branding/logo` (public; the login page is pre-auth) and `/enclave/{id}/branding/logo` (membership-gated). Uploaded through the existing `file_uploads` pipeline via a shared multipart parser.

Login page: heading is plain text; body renders through a new `markdown::render_login_body` that strips raw HTML and fenced code blocks (so the login page never loads syntect). The sign-in button now uses `brand-bg`.

Admin UI: `/admin/branding` (standalone, admin-gated) manages global; `/enclave/{id}/branding` (manage-gated) manages per-enclave, linked from enclave settings. Both use `<input type="color">` pickers and audit-log `branding_set`.

Tests: 9-case `routes_branding.rs` (resolve fallback, per-enclave precedence, path-scope parse, public-logo 404, middleware injection global + enclave-scope, admin gating, form persistence, color-validation rejection). Migration 0033 backfilled into the 14 hand-rolled-migration test files.

Out of scope per the locked plan: favicon, custom CSS, i18n. In-app sidebar logo placement is a follow-up (login + admin-preview cover visibility for now); colors already propagate app-wide via the middleware.
fix(branding): code-review follow-ups (LC-96)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m20s
1ae022171f
- Blank-page bug: when the branding middleware's `to_bytes` fails (stream error / over the 64 MiB cap), it returned the original 200 status with an empty body - a successful-looking blank page. The body is gone either way once `to_bytes` consumes it, so surface a 500 instead of masking the failure.
- HTMX fast-path: skip the buffer + the per-request branding DB resolve when the request carries `HX-Request: true`. Those responses are fragments with no `<head>`, and they are the high-frequency path (every message send); full-page loads still get branded.
- Read-path color re-validation: the middleware now re-checks `is_valid_hex_color` on the DB values before stamping them into the `<style>` block, falling back to defaults on anything invalid. The write paths already validate, so this is defense-in-depth against a manual DB edit or a future un-validated writer breaking out of the stylesheet.
- Per-enclave save WYSIWYG: `post_branding` (enclave) now falls back to `resolve(Enclave(id))` instead of `defaults_for_global()`. The GET form renders the resolved branding (this enclave's row, else the global row), so a partial save - e.g. editing only the heading - keeps the colors the operator was looking at instead of silently reverting them to the built-in blue.
- New test: `middleware_skips_injection_for_htmx_requests`.

Follow-ups filed: LC-141 (in-app sidebar/switcher logo), LC-142 (per-scope favicon).
Merge remote-tracking branch 'origin/main' into feat/lc-96-branding
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m4s
7ed437bbde
# Conflicts:
#	server/src/db/uploads.rs
#	server/tests/admin_uploads.rs
#	server/tests/db_bookmarks.rs
#	server/tests/db_dm_mute.rs
#	server/tests/db_pinned.rs
#	server/tests/db_uploads.rs
#	server/tests/push_dispatch.rs
#	server/tests/routes_reconnect.rs
#	server/tests/routes_uploads.rs
#	server/tests/uploads_sweep.rs
nrupard deleted branch feat/lc-96-branding 2026-05-20 16:00:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!158
No description provided.