docs(quick-261016-0fm): admin theme/language preferences and save-bar fix for v0.2.1
This commit is contained in:
@@ -5,10 +5,10 @@ current_phase: "19.3"
|
||||
current_phase_name: Framework transport and infrastructure for WavePath (INSERTED)
|
||||
status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
|
||||
stopped_at: Completed 19.3-06-PLAN.md (gap closure; release handed off to the phase 19.3 main session)
|
||||
last_updated: "2026-10-15T22:07:28.573Z"
|
||||
last_activity: 2026-10-15
|
||||
last_activity_desc: 19.3-06 gap closure complete, release handed off
|
||||
state_head: 853ea1fa6e6c34a84d066b22a0b34a8e3648260e
|
||||
last_updated: "2026-10-16T09:58:01.923Z"
|
||||
last_activity: 2026-10-16
|
||||
last_activity_desc: "Completed quick task 261016-0fm: Admin minors for v0.2.1"
|
||||
state_head: 66fd4ae4b702373adc818324270337796f32d71a
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 31
|
||||
@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
Phase: 19.3 (Framework transport and infrastructure for WavePath (INSERTED)) — EXECUTING
|
||||
Plan: 6 of 6
|
||||
Status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
|
||||
Last activity: 2026-10-15 — 19.3-06 gap closure complete, release handed off
|
||||
Last activity: 2026-10-16 - Completed quick task 261016-0fm: Admin minors for v0.2.1
|
||||
|
||||
Progress: [████░░░░░░] 44%
|
||||
|
||||
@@ -899,6 +899,7 @@ Recent decisions affecting current work:
|
||||
| 261013-0wa | Close T-17-15: validate sitemap frontend_url and escape it in Rewrite | 2026-10-12 | 6e5a8e7 | [261013-0wa-close-t-17-15-validate-sitemap-frontend-](./quick/261013-0wa-close-t-17-15-validate-sitemap-frontend-/) |
|
||||
| 261015-w7f | Framework docs since v0.1.14 + Upgrading to 0.2, release v0.2.0 (plugins, layers), deploy summercms.io | 2026-10-15 | a204d38 | [261015-w7f-update-framework-docs-for-phase-19-3-rel](./quick/261015-w7f-update-framework-docs-for-phase-19-3-rel/) |
|
||||
| 36 | summercms.io copy Alpha 0.1 → 0.2 (vue b5d6dbb, app 6a13a87), deployed | 2026-10-15 | 853ea1f | — |
|
||||
| 261016-0fm | Admin minors for v0.2.1: theme switch, language switch, settings nav on form pages | 2026-10-16 | 66fd4ae | [261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan](./quick/261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
phase: quick-261016-0fm
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/phrasebook/translator.go
|
||||
- modules/phrasebook/translator_test.go
|
||||
- modules/phrasebook/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/cabana/account_preferences.go
|
||||
- modules/cabana/account_preferences_test.go
|
||||
- modules/cabana/account_preferences_internal_test.go
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/lang.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
- docs/services/localization.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/theme.ts
|
||||
- admin/src/app/i18n.ts
|
||||
- admin/src/state/usePreferences.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/components/shell/PluginRail.vue
|
||||
- admin/src/components/shell/UserMenu.vue
|
||||
- admin/src/components/shell/AppShell.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/tests/helpers.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- admin/tests/app/theme.test.ts
|
||||
- admin/tests/app/i18n.test.ts
|
||||
- admin/tests/state/usePreferences.test.ts
|
||||
- admin/tests/shell/PluginRail.test.ts
|
||||
- admin/tests/shell/UserMenu.test.ts
|
||||
- admin/tests/shell/AppShell.test.ts
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- modules/boardwalk/dist/
|
||||
autonomous: true
|
||||
requirements: [QUICK-261016-0fm]
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- 'The top item of the left rail (the Sun mark, now a button with data-theme-toggle) cycles the admin theme system -> light -> dark -> system on click. light never sets the .dark class on <html>, dark always sets it, system follows prefers-color-scheme live (the pre-change behaviour). Its icon and aria-label/title name the current choice in the admin language.'
|
||||
- 'The theme choice is stored server-side for the signed-in admin only: PUT <prefix>/api/v1/account/preferences {"theme": ...} writes backend_users.metadata key admin_theme of the token subject, and GET <prefix>/api/v1/auth/me and GET <prefix>/api/v1/account/preferences return it as preferences.theme ("system" when unset). A second browser signed in as the same admin gets the same theme on load; the SPA applies the last choice from localStorage summer-admin.theme before the first await of boot, so the shell never renders in the wrong theme.'
|
||||
- 'The user menu shows a Language radio group listing every admin locale from GET /lang meta.locales (en and pl in the framework), each named in its own language. Choosing one saves preferences.locale through PUT /account/preferences, caches it in localStorage summer-admin.locale and reloads the page; after the reload the SPA strings (GET /lang?locale=<code>), the navigation, settings, list/form schemas and validation messages are in that language even when the request Accept-Language or a translate-plugin locale cookie names another one.'
|
||||
- 'GET and PUT /account/preferences act only on the row of the authenticated admin (bouncer principal id; no id in the path or body). PUT accepts only the keys theme and locale (any other key, including id or backend_user_id, is a 422 on that key and nothing is written); theme must be one of system, light, dark; locale must be "" (clear) or a code in phrasebook.Translator.Locales("backend::lang."); a value of the wrong type is a 422. Personal API tokens get 403 interactive_only on both routes, and a cookie PUT without X-Requested-With gets 403. Every other metadata key (subtitle, keys copied from WinterCMS) is kept on save, and metadata that is not a JSON object is never overwritten (422).'
|
||||
- 'Requests authenticated with a personal API token keep the request locale exactly as before: the stored admin_locale never becomes their bouncer.Principal.PreferredLocale.'
|
||||
- 'On form, settings form, My account and preview pages the bottom save bar begins at the right edge of the sidebar (the rail, plus the section panel when it is shown), so the Settings rail item at the bottom of the rail stays visible and clickable; list pages are unchanged.'
|
||||
- 'No backend_user_preferences table is created (backend_admin_migrations_test.go keeps asserting it is absent) and no migration is added: preferences live in the existing backend_users.metadata jsonable column.'
|
||||
- 'modules/boardwalk/dist is rebuilt and committed; scripts/check-admin-dist.sh, scripts/check-admin-openapi.sh --check, go vet ./..., go test -short ./..., the Docker-backed cabana preference tests, npm --prefix admin run test and typecheck, and go run ./cmd/summer docs:build --check all pass. Nothing is tagged, pushed or deployed; every commit is staged by explicit path and carries no co-author tag.'
|
||||
artifacts:
|
||||
- path: "modules/cabana/account_preferences.go"
|
||||
provides: "metadata keys admin_theme/admin_locale, preferencesOf, GET and PUT /account/preferences handlers"
|
||||
contains: "admin_theme"
|
||||
- path: "modules/phrasebook/translator.go"
|
||||
provides: "Translator.Locales(prefix): the locales that define keys under a prefix (supported admin locales)"
|
||||
contains: "func (t *Translator) Locales("
|
||||
- path: "modules/cabana/admin_openapi.go"
|
||||
provides: "AdminPreferences, AdminPreferencesRequest, AdminProfile.Preferences, LangMeta/LangEnvelope and swag docs of the new routes"
|
||||
contains: "type AdminPreferences struct"
|
||||
- path: "modules/cabana/lang.go"
|
||||
provides: "GET /lang honours a supported ?locale= and reports meta.locales"
|
||||
contains: "Locales(adminLangPrefix)"
|
||||
- path: "admin/src/state/usePreferences.ts"
|
||||
provides: "theme and locale preference state, localStorage cache, PUT /account/preferences, apply-on-load"
|
||||
contains: "summer-admin.theme"
|
||||
- path: "admin/src/components/shell/PluginRail.vue"
|
||||
provides: "the rail's top button cycles the theme"
|
||||
contains: "data-theme-toggle"
|
||||
- path: "admin/src/components/shell/UserMenu.vue"
|
||||
provides: "Language radio group in the user menu"
|
||||
contains: "DropdownMenuRadioGroup"
|
||||
- path: "admin/src/components/shell/AppShell.vue"
|
||||
provides: "--shell-offset CSS variable: the width the sidebar occupies"
|
||||
contains: "--shell-offset"
|
||||
- path: "modules/cabana/account_preferences_test.go"
|
||||
provides: "Docker-backed round trip, authz and locale-override tests"
|
||||
contains: "func TestAccountPreferences"
|
||||
- path: "admin/tests/state/usePreferences.test.ts"
|
||||
provides: "vitest coverage of cache validation, cycling, failure revert, locale switch reload"
|
||||
contains: "usePreferences"
|
||||
key_links:
|
||||
- from: "admin/src/components/shell/PluginRail.vue (theme button)"
|
||||
to: "modules/cabana/account_preferences.go (PUT /account/preferences)"
|
||||
via: "usePreferences.cycleTheme -> setTheme -> api.PUT('/account/preferences', { body: { theme } })"
|
||||
pattern: "cycleTheme"
|
||||
- from: "modules/cabana/auth.go (principalFrom)"
|
||||
to: "cabana guarded admin group (http.go service.mount)"
|
||||
via: "Principal.PreferredLocale from metadata admin_locale; the group lists surf's locale.from-principal after the backend guard, so towel.Locale and schemaLocale see the preference"
|
||||
pattern: "locale.from-principal"
|
||||
- from: "admin/src/main.ts (boot)"
|
||||
to: "modules/cabana/lang.go (langBundle)"
|
||||
via: "loadStrings(cachedLocale()) sends GET /lang?locale=<code>; langBundle uses it only when it is in Translator.Locales(backend::lang.)"
|
||||
pattern: "loadStrings\\("
|
||||
- from: "admin/src/components/shell/AppShell.vue (--shell-offset)"
|
||||
to: "FormView/SettingsFormView/AccountView/PreviewView footers"
|
||||
via: "footer class left-[var(--shell-offset,0px)] right-0"
|
||||
pattern: "shell-offset"
|
||||
- from: "modules/cabana/token_guard.go (personal token principal)"
|
||||
to: "surf LocaleFromPrincipal"
|
||||
via: "PreferredLocale cleared on token principals, so the override is a no-op for API tokens"
|
||||
pattern: "PreferredLocale"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Three admin minors for v0.2.1, all in the embedded admin SPA (`admin/`) and cabana:
|
||||
|
||||
1. A manual theme switch (system / light / dark) on the rail's top icon, persisted per backend admin on the server and applied on load without a wrong-theme flash.
|
||||
2. An admin language switcher (in the user menu) that persists in the same per-admin storage and makes the SPA bundle and the server-side admin locale agree.
|
||||
3. The Settings rail item stays reachable on pages with the fixed bottom save bar.
|
||||
|
||||
Then rebuild and commit the embedded dist and run every release gate. Release target v0.2.1, but nothing is tagged, pushed or deployed: that waits for the user.
|
||||
|
||||
Purpose: the deployed v0.2.0 admin has a theme-looking icon that does nothing, no way to change the backend language, and Settings is covered on every form page.
|
||||
Output: local commits on `master` (code+docs commits per logical change, a test commit or two, one dist rebuild commit) and the quick SUMMARY.
|
||||
|
||||
Hard limits: no tag, no push, no deploy. Other Claude sessions work in this repository: stage by explicit path, never `git add -A`; do not touch `.planning/STATE.md`, `.planning/state.json`, `.msd/` or `.planning/phases/17-blog-companions/17-VERIFICATION.md`. No co-author tags. One logical change per commit. Framework code, docs and tests never name a consuming application (use neutral names like `acme`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@modules/cabana/account.go
|
||||
@modules/cabana/auth.go
|
||||
@modules/cabana/lang.go
|
||||
@modules/cabana/http.go
|
||||
@modules/cabana/admin_openapi.go
|
||||
@modules/cabana/token_guard.go
|
||||
@modules/phrasebook/translator.go
|
||||
@modules/surf/locale_from_principal.go
|
||||
@admin/src/app/theme.ts
|
||||
@admin/src/app/i18n.ts
|
||||
@admin/src/main.ts
|
||||
@admin/src/state/useAuth.ts
|
||||
@admin/src/state/useSidebar.ts
|
||||
@admin/src/components/shell/AppShell.vue
|
||||
@admin/src/components/shell/PluginRail.vue
|
||||
@admin/src/components/shell/UserMenu.vue
|
||||
|
||||
## Planning-time observations (2026-10-16, live, HEAD 1a03399)
|
||||
|
||||
Edit scope rests on these facts. Re-check the ones a task's precondition names before editing.
|
||||
|
||||
- Working tree: only `.planning/state.json` (modified), `.msd/` and `.planning/phases/17-blog-companions/17-VERIFICATION.md` (untracked), all other sessions' files. Nothing uncommitted under `modules/`, `admin/` or `docs/`.
|
||||
- **Per-admin storage already exists:** `backend_users.metadata` is WinterCMS's nullable jsonable TEXT column (`lagoon/backend_admin_migrations.go` migration `202610090001_backend_users_metadata`), mapped as `cabana.BackendUser.Metadata *string`. `modules/cabana/account.go` has `decodeMetadata`, `metadataString` and `setMetadataString` (keeps every other key, NULL/blank/`null`/`[]` = empty object, refuses non-object text with `errMetadataNotObject`), used today for the `subtitle` key. `lockAccount(ctx, tx, id)` (account.go:227) loads the admin's own row `FOR UPDATE`; `accountPrincipal(w, r)` (account.go:216) returns the backend principal or writes 401; `refusedAccountKeys` (account.go:445) is the unknown-key 422 pattern; `writeAccountError` maps `errAccountGone` to 401; `s.decodeCappedObject(w, r)` (relation_child.go:709) decodes a capped JSON object; `fieldMessage(ctx, tr, rule, field, params)` (field_file.go:885) renders `lagoon::validation.<rule>`.
|
||||
- **WinterCMS's preference table is deliberately not ported:** `modules/lagoon/backend_admin_migrations_test.go:116` fails if `backend_user_preferences` exists (Phase 9 decision, re-confirmed in 16.1 research). So: no new table, no migration; store under metadata keys `admin_theme` and `admin_locale`.
|
||||
- **Locale today:** surf's `Router.locale` (surf/router.go:861) wraps every request before any guard: a published `surf.LocaleResolver` (the translate plugin publishes one in deployed apps; its `Resolve` prefers URL prefix, then principal, then a remembered cookie, then Accept-Language) or else raw Accept-Language goes into `towel.WithLocale`. `cabana.schemaLocale` reads `towel.Locale`. `bouncer.Principal.PreferredLocale` exists ("empty means no override"), and surf registers the named middleware `locale.from-principal` (`surf.LocaleFromPrincipal`, router.go:534) that overrides the towel locale with it. cabana's `principalFrom` (auth.go:103) never sets it today. Because the resolver runs before the guard and a remembered cookie beats Accept-Language, sending a header from the SPA alone cannot fix the admin language: cabana must apply the preference after its guard.
|
||||
- cabana's guarded admin group is `r.GroupRaw(api, []string{"backend"}, ...)` (http.go:264). Account routes sit at http.go:266-275 wrapped in `interactiveOnly(...)` and, for writes, `requireAjax(...)`. The personal-token guard builds its principal with `g.users.FindByID` (token_guard.go:101), i.e. through `principalFrom`, then sets `principal.Scope = limit` (token_guard.go:119).
|
||||
- `GET /lang` (`cabana.langBundle`, lang.go) is public (outside the guard), resolves `schemaLocale` then `tr.Resolved(locale, "backend::lang.")`, answers `meta.locale`. Swag: `AdminLang` documents `Envelope[LangBundle]` (admin_openapi.go:167-175); `SuccessMeta` is shared by every envelope (admin_openapi.go:33), so new /lang meta needs its own type.
|
||||
- `phrasebook.Translator` has `Bundle`, `Resolved`, `Forms`, `Has` but no way to list the locales a prefix exists in. Framework admin strings: `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (groups `nav:` and `myaccount:` exist).
|
||||
- Route tables that enumerate cabana's mounted routes and must list new ones in the same commit (else `go test -short` fails): `security_coverage_test.go` `phase09Routes` (~L33-48), `token_routes_test.go` `tokenRouteClasses` (~L37-43) and `interactivePaths` (~L169-171), `phase10_coverage_test.go` unsafe-route `want` list (~L85-120; its method switch accepts only GET/POST/PUT/DELETE, so use PUT, not PATCH), `openapi_conformance_test.go` cases (account cases ~L716; `GET /lang` expects schema `cabana.Envelope-cabana_LangBundle` at ~L406).
|
||||
- Docker works for the current user (`docker info` = 29.7.2). Docker-backed cabana tests use `adminGorm(t)`, `adminHandler(t, gdb, nil)`, `insertAdmin`, `signIn`, `getAuth`, `adminAPI`, `profileOf` (see `TestAccountProfileSmoke`, account_test.go:266). `summer` is not on PATH: use `go run ./cmd/summer docs:build --check`.
|
||||
- **SPA theme today:** `admin/src/app/theme.ts` `applyColorScheme(win)` toggles `.dark` on `<html>` from `(prefers-color-scheme: dark)` and follows changes; `main.ts` calls it first in `boot()`. `main.css` uses `@custom-variant dark (&:where(.dark, .dark *))` and `.dark { color-scheme: dark; }`, so a class switch is the whole mechanism. The "theme-looking icon" is the decorative `<span ... title="SummerCMS">` with a lucide `Sun` at the top of the `<nav>` in `PluginRail.vue` (template, first child). lucide `Sun`, `Moon`, `SunMoon`, `Check` and Reka `DropdownMenuRadioGroup`/`DropdownMenuRadioItem`/`DropdownMenuItemIndicator`/`DropdownMenuLabel` are available in the installed versions.
|
||||
- **SPA locale today:** `i18n.ts` `loadStrings()` calls public `GET /lang` and `setBundle(data.data, data.meta.locale)` (sets `<html lang>`). Boot order in `main.ts`: strings, then `me()`, then navigation/settings/field types, then mount. `LoginView.submit` runs `login`, `me()`, then the same loads. `useAuth.me()` (useAuth.ts:56) sets the profile and the list-state owner.
|
||||
- **CSP:** boardwalk sends `script-src 'self'`, so no inline script in `index.html`. Applying the cached theme synchronously at the top of `boot()` (before any await; the app mounts only after `me()`) is the no-flash approach.
|
||||
- **Settings bug:** `PluginRail` is `sticky top-0 h-screen self-start` with Settings pinned at its bottom; the four bottom bars are `fixed inset-x-0 bottom-0 z-30` and span the whole viewport, covering the rail bottom: `FormView.vue:596`, `SettingsFormView.vue:222`, `AccountView.vue:425`, `PreviewView.vue:285`. Sidebar widths are tokens `--spacing-rail: 96px` and `--spacing-panel: 224px` (main.css:72-73); the section panel shows when `active && !collapsed` in `AppShell.vue`.
|
||||
- `FormView.vue` registers `beforeunload` for unsaved changes (L530), so a page reload after a language switch is guarded by the browser prompt.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer — theme preference end to end (cabana preferences API + rail theme switch)</name>
|
||||
<precondition>`git status --short -- modules admin docs` prints nothing (no other session is editing cabana, phrasebook, the SPA or docs); `docker info` succeeds.</precondition>
|
||||
<files>modules/phrasebook/translator.go, modules/phrasebook/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/account_preferences.go, modules/cabana/account_preferences_test.go, modules/cabana/account.go, modules/cabana/admin_openapi.go, modules/cabana/http.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md, docs/services/localization.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/theme.ts, admin/src/state/usePreferences.ts, admin/src/state/useAuth.ts, admin/src/state/useSidebar.ts, admin/src/main.ts, admin/src/components/shell/PluginRail.vue, admin/tests/helpers.ts</files>
|
||||
<action>
|
||||
Backend (one commit, e.g. `feat(cabana): per-admin theme and locale preferences`):
|
||||
|
||||
- phrasebook: add `func (t *Translator) Locales(prefix string) []string` returning the sorted locale codes whose catalog holds at least one key starting with prefix (nil-safe like `Resolved`; nil translator or catalog returns nil). This is the single definition of "supported admin locale": `tr.Locales("backend::lang.")` (en and pl in the framework, plus any locale an application overrides). Document it in `modules/phrasebook/README.md` (Features bullet next to Bundle/Resolved and an API reference row) and in `docs/services/localization.md` (the admin SPA paragraph).
|
||||
- New file `modules/cabana/account_preferences.go`: constants for the metadata keys `admin_theme` and `admin_locale`; the theme set system, light, dark (system is the default); `preferencesOf(user BackendUser, tr *phrasebook.Translator) AdminPreferences` (theme = stored value when it is one of the three, else system; locale = stored value only when it is in `tr.Locales(adminLangPrefix)`, else ""). Handlers on `*service`:
|
||||
- `accountPreferencesGet` (GET /account/preferences): `accountPrincipal`, load the row like `me` does (missing or deactivated = 401), answer `WriteData(..., preferencesOf(user, tr), ...)`.
|
||||
- `accountPreferencesUpdate` (PUT /account/preferences): `accountPrincipal`; `s.decodeCappedObject` (errors via `writeRelationError` as accountUpdate does); every key other than theme and locale is a 422 on that key with the existing `myaccount.field_not_editable` message and nothing is written (mirror `refusedAccountKeys` with a preference key list). Each present key is saved, an absent key keeps its stored value, `{}` answers 200 with the current preferences and writes nothing (document this partial semantics in the swag description). theme must be a JSON string in the set; locale must be a JSON string that is "" or in `tr.Locales(adminLangPrefix)`; a wrong value or type is a 422 on that field with `fieldMessage(ctx, tr, "in", field, nil)`. Inside `lagoon.Transaction`: `lockAccount(ctx, tx, principal.ID)`, apply each key with `setMetadataString` (theme system and locale "" remove the key, so an untouched admin keeps NULL metadata); `errMetadataNotObject` is a 422 on the submitted field(s) with a new `myaccount.preferences_unavailable` message; write only the metadata column with `UpdateColumns` (a UI preference is not a profile edit, so updated_at stays). Errors go through `writeAccountError`. Answer the saved `AdminPreferences`. The row is always the principal's; never read an id from the path or body.
|
||||
- `admin_openapi.go`: `AdminPreferences {Theme string json:"theme"; Locale string json:"locale"}` with comments naming the allowed values; `AdminPreferencesRequest {Theme *string json:"theme,omitempty"; Locale *string json:"locale,omitempty"}`; add `Preferences AdminPreferences json:"preferences"` to `AdminProfile` (additive; `profileFor` in account.go fills it with `preferencesOf(user, s.translator())`, so GET /auth/me and the My account answers carry it). Swag functions `AdminAccountPreferences` (GET, `Envelope[AdminPreferences]`, 401/403) and `AdminAccountPreferencesUpdate` (PUT, body `AdminPreferencesRequest`, 200/401/403/413/422), both stating "Interactive session only (403 interactive_only for personal API tokens)". Update the `AdminMe` description to mention preferences.
|
||||
- `http.go` service.mount, next to the other account routes: `g.Get("/account/preferences", interactiveOnly(s.accountPreferencesGet))` and `g.Put("/account/preferences", interactiveOnly(requireAjax(s.accountPreferencesUpdate)))`.
|
||||
- Add both routes to the route tables in the same commit: `phase09Routes` (security_coverage_test.go), `tokenRouteClasses` as classInteractive and `interactivePaths` (token_routes_test.go), the unsafe `want` list for PUT (phase10_coverage_test.go), and conformance cases for both (openapi_conformance_test.go, modelled on the PUT /account case).
|
||||
- Lang strings in both `backend/lang/{en,pl}/lang.yaml`: `nav.theme` with a `:mode` placeholder (en "Theme: :mode", pl "Motyw: :mode"), `nav.theme_system`/`nav.theme_light`/`nav.theme_dark` (System/Light/Dark; Systemowy/Jasny/Ciemny), `nav.language` (Language/Język), `myaccount.preferences_unavailable` (the subtitle_unavailable wording for preferences), `myaccount.preferences_failed` (en "Your preference could not be saved.", pl "Nie udało się zapisać ustawienia.").
|
||||
- Run `scripts/check-admin-openapi.sh` to regenerate `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`; add `export type AdminPreferences = Schemas['cabana.AdminPreferences']` to `admin/src/api/types.ts`; give the typed profile fixtures in `admin/tests/helpers.ts` (and any other test fixture typed as AdminProfile that typecheck flags) `preferences: { theme: 'system', locale: '' }`, so this commit keeps `npm --prefix admin run typecheck` green.
|
||||
- Docs in the same commit (CLAUDE.md docs rule): `modules/cabana/README.md` (My account Features bullet: preferences under metadata keys admin_theme/admin_locale, other keys kept; route table rows for GET and PUT /account/preferences; API reference rows for `cabana.AdminPreferences`, `cabana.AdminPreferencesRequest`, `cabana.AdminAccountPreferences`, `cabana.AdminAccountPreferencesUpdate`; the `cabana.AdminProfile` row mentions `preferences`), `docs/backend/users-and-permissions.md` (My account section: the preferences wire contract and validation), `docs/backend/admin-spa.md` (the rail's theme switch).
|
||||
- Smoke test `modules/cabana/account_preferences_test.go` (package cabana_test, Docker-backed, skips under -short like its neighbours): `TestAccountPreferencesSmoke` signs in a fresh admin, PUTs `{"theme":"dark"}`, expects 200 with theme dark and locale "", then GET /auth/me shows `preferences.theme` dark and GET /account/preferences agrees; a subtitle stored beforehand is still in metadata.
|
||||
|
||||
SPA (second commit, e.g. `feat(admin): manual theme switch on the rail`):
|
||||
|
||||
- `theme.ts`: export `ThemeChoice` ('system' | 'light' | 'dark'), the ordered `THEME_CHOICES` and an `isThemeChoice` guard; extend `applyColorScheme(win = window, choice: ThemeChoice = 'system')`: light removes `.dark`, dark adds it (both return a no-op unsubscribe and subscribe to nothing), system keeps today's media-query behaviour. The default argument keeps the existing call and test valid. Update the header comment (A6 is superseded: system is the default, light and dark are manual).
|
||||
- New `admin/src/state/usePreferences.ts`: `THEME_KEY = 'summer-admin.theme'`; module state for the current choice exposed readonly as `themeChoice`; `applyTheme(choice)` (stops the previous media subscription, applies the new one); `cachedTheme()` reads localStorage and returns only a valid choice, else 'system' (guard storage access with try/catch exactly like useSidebar.ts); `setTheme(choice)` applies and caches optimistically, sends `api.PUT('/account/preferences', { body: { theme: choice } })` and on any failure reverts theme and cache and calls `showToast(t('backend::lang.myaccount.preferences_failed'), 'danger')`; `nextTheme` (system -> light -> dark -> system) and `cycleTheme()`; `applyProfilePreferences(profile)` applies and caches `profile.preferences.theme` when valid (tolerate a missing preferences object at runtime). Locale handling is added in Task 2.
|
||||
- `useAuth.me()`: after the profile loads, await `applyProfilePreferences(user.value)` (covers boot and LoginView). `main.ts`: replace the first `applyColorScheme()` call with `applyTheme(cachedTheme())`, still before any await. `useSidebar.ts`: update the storage comment (usePreferences also writes browser storage: the theme and locale codes, never tokens or record data).
|
||||
- `PluginRail.vue`: replace the decorative top span with `<button type="button" data-theme-toggle>` keeping its size, radius, `bg-accent-soft` and `text-accent` icon (size 22), adding a hover state; the icon is `SunMoon` for system, `Sun` for light, `Moon` for dark; `aria-label` and `title` are `t('backend::lang.nav.theme', { mode: t('backend::lang.nav.theme_' + choice) })`; click calls `cycleTheme`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana/... ./modules/phrasebook/... && go test -short ./modules/cabana ./modules/phrasebook && go test ./modules/cabana -run 'TestAccountPreferencesSmoke|TestAuthMeProfilePayload|TestAccountProfileSmoke' -count=1 && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin run test</automated>
|
||||
</verify>
|
||||
<done>Clicking the rail's top button switches light/dark/system immediately and the choice survives a reload and a second browser for the same admin (server-stored under metadata admin_theme, returned in /auth/me preferences). GET/PUT /account/preferences exist, are documented (swag, cabana README, docs) and listed in every route table; OpenAPI and TS types are regenerated; vet, short tests, the Docker smoke test, typecheck and vitest are green. Two commits, staged by path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Admin language follows the stored preference + language switcher + footers clear the sidebar</name>
|
||||
<files>modules/cabana/lang.go, modules/cabana/auth.go, modules/cabana/token_guard.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/openapi_conformance_test.go, modules/cabana/account_preferences_test.go, modules/cabana/README.md, docs/backend/admin-spa.md, docs/services/localization.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/app/i18n.ts, admin/src/state/usePreferences.ts, admin/src/main.ts, admin/src/components/shell/UserMenu.vue, admin/src/components/shell/AppShell.vue, admin/src/views/FormView.vue, admin/src/views/SettingsFormView.vue, admin/src/views/AccountView.vue, admin/src/views/PreviewView.vue</files>
|
||||
<action>
|
||||
Backend (commit, e.g. `feat(cabana): admin language follows the stored preference`):
|
||||
|
||||
- `auth.go` `principalFrom`: set `PreferredLocale` to `metadataString(user.Metadata, <admin_locale key>)` (empty when unset, so nothing changes for admins without a preference). The value was validated on write, and `schemaLocale` plus `tr.Resolved` already fall back for an unknown code.
|
||||
- `token_guard.go`: right where `principal.Scope = limit` is set, also clear `principal.PreferredLocale`, with a comment: personal API tokens keep the request locale; the stored admin language applies to interactive sessions only (API-token clients see no behaviour change).
|
||||
- `http.go`: the guarded group becomes `r.GroupRaw(api, []string{"backend", "locale.from-principal"}, ...)`, so surf's `LocaleFromPrincipal` runs after the backend guard and overrides the towel locale (whatever the LocaleResolver or Accept-Language chose) for interactive admins who have a preference. If a cabana test harness router rejects the name, register it there the way surf does; do not add a cabana copy of the middleware.
|
||||
- `lang.go` `langBundle`: compute `locales := tr.Locales(adminLangPrefix)` (empty slice when tr is nil); when the query parameter `locale` is non-empty and in `locales`, use it in place of `schemaLocale(...)`; any other value is ignored (never echoed, never an error). Answer meta `{"locale": resolved, "locales": locales}`. Keep `Cache-Control: no-cache`.
|
||||
- `admin_openapi.go`: `LangMeta {Locale string json:"locale"; Locales []string json:"locales"}` and `LangEnvelope {Data LangBundle json:"data"; Meta LangMeta json:"meta"}`; `AdminLang` gets `@Param locale query string false "..."` and `@Success 200 {object} LangEnvelope`, and its description explains the parameter and `meta.locales`. Update the GET /lang conformance case to the new schema name (plus a `?locale=pl` request). Regenerate with `scripts/check-admin-openapi.sh`.
|
||||
- Extend `account_preferences_test.go` (Docker-backed): `TestAdminLocalePreference`: after PUT `{"locale":"pl"}` a guarded admin route answers in Polish with `Accept-Language: en` (use a response that reflects the request locale, e.g. the 422 message of PUT /account/preferences with an invalid theme, or `meta.locale` of a schema route the harness mounts); PUT `{"locale":"xx"}` is a 422; `GET /lang?locale=pl` has meta.locale pl and meta.locales containing en and pl; `GET /lang?locale=xx` falls back to the request locale.
|
||||
- Docs in the same commit: `modules/cabana/README.md` (GET /lang row: `locale` parameter and `meta.locales`; the admin language override for interactive sessions; API reference rows `cabana.LangMeta` and `cabana.LangEnvelope`), `docs/services/localization.md` (admin language: preference, then request locale), `docs/backend/admin-spa.md` (strings from `GET /lang?locale=`, the language switcher).
|
||||
|
||||
SPA language (commit, e.g. `feat(admin): language switcher in the user menu`):
|
||||
|
||||
- `i18n.ts`: `loadStrings(preferred?: string)` sends `params: { query: { locale } }` only when preferred is non-empty; store `meta.locales` in a module ref exported readonly as `availableLocales` (default empty).
|
||||
- `usePreferences.ts`: `LOCALE_KEY = 'summer-admin.locale'`; `cachedLocale()` returns the stored value only when it is a short locale-shaped code (letters with an optional region, at most 16 characters), else ""; `applyProfilePreferences` also handles locale: a non-empty `profile.preferences.locale` that differs from `currentLocale` triggers `await loadStrings(locale)`, then the cache is set (or removed when the preference is ""); `setLocale(code)` does nothing for the current preference, otherwise PUTs `{ locale: code }`, and on success caches it and reloads the page through a small exported indirection (for example `page.reload()` wrapping `window.location.reload()`) that tests can stub; on failure it shows the preferences_failed toast. A reload re-fetches strings, navigation, settings, schemas and field types in the new language at once; unsaved forms are protected by FormView's beforeunload prompt.
|
||||
- `main.ts`: `await loadStrings(cachedLocale() || undefined)`.
|
||||
- `UserMenu.vue`: between the account item and the logout separator, when `availableLocales.length > 1`: a `DropdownMenuSeparator`, a `DropdownMenuLabel` with `t('backend::lang.nav.language')`, and a `DropdownMenuRadioGroup` whose model is the profile's preference locale or else `currentLocale`, with one `DropdownMenuRadioItem` per code (`data-locale-option` set to the code, a `Check` icon in `DropdownMenuItemIndicator`), labelled with the language's own name via `Intl.DisplayNames([code], { type: 'language' })` with the first letter upper-cased and the upper-cased code as fallback; selection calls `setLocale`. Match the existing item classes.
|
||||
|
||||
Footer layout (separate commit, e.g. `fix(admin): bottom save bars no longer cover the sidebar`):
|
||||
|
||||
- `AppShell.vue`: bind a `--shell-offset` CSS custom property on the root element: `calc(var(--spacing-rail) + var(--spacing-panel))` when the section panel is shown (`active && !collapsed`), else `var(--spacing-rail)`.
|
||||
- In the four bottom bars (`FormView.vue`, `SettingsFormView.vue`, `AccountView.vue`, `PreviewView.vue`) replace `inset-x-0` with `right-0 left-[var(--shell-offset,0px)]`, keeping everything else. The bar then starts at the sidebar's right edge, the sticky rail and its Settings item stay uncovered, and the bar follows the panel collapsing or expanding. Leave the Toast, dialogs and the flyout alone.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana/... && go test -short ./modules/cabana ./modules/surf ./modules/phrasebook && go test ./modules/cabana -run 'TestAccountPreferences|TestAdminLocalePreference|TestAuthMeProfilePayload' -count=1 && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin run test && for f in FormView SettingsFormView AccountView PreviewView; do grep -q 'left-\[var(--shell-offset' admin/src/views/$f.vue || { echo "missing offset in $f"; exit 1; }; done && grep -q -- '--shell-offset' admin/src/components/shell/AppShell.vue</automated>
|
||||
</verify>
|
||||
<done>An admin who picks Polski in the user menu gets Polish UI strings, navigation, schemas and validation messages after the reload, in any browser, whatever Accept-Language or the translate cookie says; API-token requests are unaffected; /lang honours only supported ?locale values and lists meta.locales. On form, settings, account and preview pages the save bar starts right of the sidebar and Settings stays clickable. Docs and OpenAPI updated; vet, short tests, Docker tests, typecheck and vitest green. Three commits, staged by path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Unit tests, dist rebuild and release gates for v0.2.1</name>
|
||||
<files>modules/phrasebook/translator_test.go, modules/cabana/account_preferences_internal_test.go, modules/cabana/account_preferences_test.go, admin/tests/fixtures/lang.json, admin/tests/helpers.ts, admin/tests/app/theme.test.ts, admin/tests/app/i18n.test.ts, admin/tests/state/usePreferences.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/UserMenu.test.ts, admin/tests/shell/AppShell.test.ts, admin/tests/form/FormView.test.ts, modules/boardwalk/dist/</files>
|
||||
<action>
|
||||
Unit tests come last (CLAUDE.md project rule 2). Go tests (commit, e.g. `test(cabana): admin preference coverage`):
|
||||
|
||||
- `translator_test.go`: `Locales` returns sorted codes that hold keys under the prefix, leaves out locales without such keys, and returns nil for a nil translator.
|
||||
- `account_preferences_internal_test.go` (package cabana, Docker-free): `preferencesOf` (unset gives system and ""; an invalid stored theme gives system; an unsupported stored locale gives ""; NULL, `[]` and malformed metadata are tolerated); setting theme and locale keeps subtitle and an unrelated Winter key; system and "" remove their keys and empty metadata becomes NULL; non-object metadata is refused; `principalFrom` sets PreferredLocale from admin_locale and leaves it empty without one.
|
||||
- Extend `account_preferences_test.go` (Docker-backed): unknown keys (`id`, `backend_user_id`, `is_superuser`) are a 422 per key and the row is unchanged; theme `"blue"` and theme `1` are 422; the save is visible only to its owner (a second admin's GET still shows defaults); a personal API token gets 403 `interactive_only` on GET and PUT; a cookie PUT without X-Requested-With gets 403; non-object metadata is a 422 and stays byte-identical; an API-token request to a guarded route keeps its Accept-Language locale even though the owner chose pl.
|
||||
|
||||
Vitest (commit, e.g. `test(admin): theme switch, language switcher and footer offset`): add the new lang keys to `tests/fixtures/lang.json` (and `meta.locales` ["en","pl"] where the fixture models the /lang answer); make `resetState()` in `tests/helpers.ts` also reset the theme to system.
|
||||
|
||||
- `theme.test.ts`: light and dark ignore the media query and subscribe to nothing; system follows it (keep the existing cases).
|
||||
- `i18n.test.ts`: `loadStrings('en')` sends `?locale=en`, a call without an argument sends no locale, and `availableLocales` follows meta.locales.
|
||||
- New `state/usePreferences.test.ts`: `cachedTheme`/`cachedLocale` reject invalid stored values; `cycleTheme` order and the PUT body; a failed PUT reverts the theme and the cache and shows a danger toast; `applyProfilePreferences` applies the theme and reloads strings only when the locale differs; `setLocale` PUTs, caches and calls the stubbed reload, and on failure does not reload.
|
||||
- `PluginRail.test.ts`: the `data-theme-toggle` button's aria-label names the current choice and a click cycles it and PUTs; the Settings link is still rendered.
|
||||
- `UserMenu.test.ts`: with locales en and pl the menu lists `data-locale-option` items named English and polski/Polski, the current one checked; selecting the other one PUTs; with a single locale the group is absent.
|
||||
- `AppShell.test.ts`: `--shell-offset` equals the rail width with the panel collapsed and rail plus panel with it shown. `FormView.test.ts`: the footer class contains `left-[var(--shell-offset`.
|
||||
|
||||
Release build (commit, e.g. `build(admin): rebuild embedded admin dist for v0.2.1`): `npm --prefix admin run build`, then stage `modules/boardwalk/dist` by path (include deletions of replaced hashed assets: `git add -- modules/boardwalk/dist` stages that directory only, which is allowed; never `git add -A`).
|
||||
|
||||
Then run the full gate list in `<verify>` and record every result in the SUMMARY. A failure means fixing the cause in the owning task's files and committing the fix as its own commit. Do not tag, push or deploy: the SUMMARY ends with the release steps that wait for the user's go-ahead (tag v0.2.1, push, deploy per application).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short ./... && go test ./modules/cabana -run 'TestAccountPreferences|TestAdminLocalePreference|TestAuthMe|TestAccountProfile|TestPhase09|TestPhase10|TestTokenRouteGates|TestInteractiveOnly' -count=1 && go test ./modules/lagoon -run 'TestBackendAdminMigration$|TestBackendAdminMetadataColumn' -count=1 && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && (cd examples/hello && go test ./...) && npm --prefix admin run typecheck && npm --prefix admin run test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && test -z "$(git status --porcelain -- modules admin docs)"</automated>
|
||||
</verify>
|
||||
<done>Go internal and Docker-backed tests and vitest suites cover the preferences API, authorization, locale override, token exemption, theme cycling, language switching and the footer offset. modules/boardwalk/dist matches a fresh build and is committed. Every listed gate passes, the tree under modules/, admin/ and docs/ is clean, nothing is tagged or pushed, and the SUMMARY lists commits, gate results and the pending release steps.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser (SPA) -> cabana admin API | Untrusted JSON on PUT /account/preferences and an untrusted `locale` query on public GET /lang cross here. |
|
||||
| backend_users.metadata -> request locale | A stored, admin-written value becomes `bouncer.Principal.PreferredLocale` and then the towel locale of admin requests. |
|
||||
| localStorage -> SPA boot | Cached theme and locale codes, writable by any script or user on the origin, are read before authentication. |
|
||||
| personal API token clients -> admin API | Machine clients (MCP server and others) must see no behaviour change from an admin's UI preference. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-261016-0fm-01 | Elevation of privilege | PUT /account/preferences (`accountPreferencesUpdate`) | high | mitigate | The row is always `accountPrincipal(...).ID` locked with `lockAccount`; no id is read from the path or body; any key other than theme/locale (id, backend_user_id, is_superuser, ...) is a 422 and nothing is written. Docker test proves another admin's row is untouched and unknown keys are refused. |
|
||||
| T-261016-0fm-02 | Spoofing (CSRF) | PUT /account/preferences with the admin cookie | high | mitigate | `requireAjax` wraps the PUT (X-Requested-With required on cookie requests); listed in the phase10 unsafe-route walk; Docker test sends a cookie PUT without the header and expects 403. |
|
||||
| T-261016-0fm-03 | Elevation of privilege | GET/PUT /account/preferences with a personal API token | medium | mitigate | Both routes wrapped in `interactiveOnly` (403 `interactive_only`), classified `classInteractive` in token_routes_test.go; Docker test with a `*`-scope token. |
|
||||
| T-261016-0fm-04 | Tampering | preference values | medium | mitigate | theme must be one of system/light/dark; locale must be "" or in `phrasebook.Translator.Locales("backend::lang.")`; wrong types are 422; the body is size-capped by `decodeCappedObject`. Internal and Docker tests cover each refusal. |
|
||||
| T-261016-0fm-05 | Tampering | backend_users.metadata (shared with subtitle and copied WinterCMS keys) | medium | mitigate | `setMetadataString` keeps every other key and refuses non-object metadata (422, never overwritten); read-modify-write happens under the `FOR UPDATE` row lock inside `lagoon.Transaction`, so it cannot race PUT /account's subtitle save. Tests assert subtitle and an unrelated key survive and non-object metadata stays byte-identical. |
|
||||
| T-261016-0fm-06 | Tampering / Information disclosure | GET /lang `?locale=` (public) | low | mitigate | The parameter is used only when it equals a code in `Translator.Locales`; anything else is ignored, never echoed and never errors; the response keeps `Cache-Control: no-cache`; only the public backend::lang namespace is ever served. |
|
||||
| T-261016-0fm-07 | Information disclosure (behaviour change) | personal-token principal locale | medium | mitigate | token_guard clears `PreferredLocale` on token principals, so `locale.from-principal` is a no-op for them; Docker test proves an API-token request keeps its Accept-Language locale after the owner chose pl. |
|
||||
| T-261016-0fm-08 | Tampering | localStorage summer-admin.theme / summer-admin.locale | low | accept | Values are validated (theme enum, locale-shaped code of at most 16 characters) before use; an invalid locale is also ignored by the server; the server preference overwrites the cache after `me()`; the effect is limited to the local user's own display. |
|
||||
| T-261016-0fm-09 | Information disclosure | cached theme/locale surviving logout on a shared device | low | accept | The values are display settings, not personal data or credentials (T-16.1-03 covers list queries, which are still cleared); keeping them lets the login screen show the last language. |
|
||||
| T-261016-0fm-10 | Denial of service | stored locale feeding the translator | low | mitigate | Written values are restricted to supported locales; `schemaLocale`/`Translator.Resolved` fall back to the default for any unknown code, so a stale stored value (locale removed later) degrades to the default language, never an error. |
|
||||
| T-261016-0fm-SC | Tampering | npm/Go dependency installs | low | accept | No new dependency: everything uses installed packages (lucide icons, Reka DropdownMenu radio parts, stdlib); `npm --prefix admin ci` uses the committed lockfile. No package-legitimacy checkpoint is needed. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 3's verify line is the release gate list: `go vet ./...`, `go test -short ./...`, the Docker-backed cabana preference, route-table and conformance tests (`TestPhase09*`, `TestPhase10*` including `TestPhase10OpenAPIConformance`, `TestTokenRouteGates`, `TestInteractiveOnly`), lagoon `TestBackendAdminMigration` (backend_user_preferences still absent) and `TestBackendAdminMetadataColumn`, docs checker (`TestDocsTree` and `go run ./cmd/summer docs:build --check`), `examples/hello`, admin typecheck and vitest, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`, and a clean `modules/ admin/ docs/` tree.
|
||||
- Hand check (record in SUMMARY): `git log --format='%H %s%n%b' 1a03399..HEAD` shows no co-author tag and no `.planning/state.json`, `.msd/` or 17-VERIFICATION.md in any commit (`git diff --name-only 1a03399..HEAD`); `git diff 1a03399..HEAD -- modules docs admin/src admin/tests | grep -ci grzyby` prints 0 (no consuming application named); `git tag --points-at HEAD` prints nothing.
|
||||
- Optional manual check if a dev server is at hand: `npm --prefix admin run dev` against a running `summer serve`, open a record form, confirm Settings is clickable above the save bar, toggle the theme and the language.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The rail's top icon switches the theme system/light/dark, persisted per admin on the server and applied on load from the cache without a wrong-theme render.
|
||||
- The user menu switches the admin language between the available admin locales; after the reload the SPA strings and the server-side admin locale agree, independent of Accept-Language and the translate plugin's cookie; API-token clients are unaffected.
|
||||
- Settings stays reachable on every page with the bottom save bar.
|
||||
- GET/PUT /account/preferences are own-row only, validated, CSRF- and token-guarded, documented, and in the OpenAPI document and TS types.
|
||||
- Embedded dist rebuilt and committed; all gates green; no tag, push or deploy.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261016-0fm-admin-minors-for-v0-2-1-theme-switch-lan/261016-0fm-SUMMARY.md` when done: commits (hash and subject), gate results, the Docker test run, decisions taken at the executor's discretion, and the release steps pending the user's go-ahead (tag v0.2.1, push, deploy per application).
|
||||
</output>
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
phase: quick-261016-0fm
|
||||
plan: 01
|
||||
status: complete
|
||||
subsystem: admin (cabana, phrasebook, admin SPA, boardwalk dist)
|
||||
tags: [admin, preferences, theme, i18n, layout, v0.2.1]
|
||||
requires:
|
||||
- backend_users.metadata jsonable column (lagoon 202610090001_backend_users_metadata)
|
||||
- surf locale.from-principal named middleware
|
||||
provides:
|
||||
- GET/PUT <prefix>/api/v1/account/preferences (metadata keys admin_theme, admin_locale)
|
||||
- AdminProfile.preferences in GET /auth/me and the My account answers
|
||||
- phrasebook.Translator.Locales(prefix)
|
||||
- GET /lang ?locale= and meta.locales (cabana.LangEnvelope, cabana.LangMeta)
|
||||
- rail theme switch, user-menu language switcher, --shell-offset save-bar layout
|
||||
affects:
|
||||
- every guarded admin route now runs locale.from-principal after the backend guard
|
||||
- personal API token principals have PreferredLocale cleared (no behaviour change for token clients)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- per-admin UI preferences as metadata keys (no backend_user_preferences table, no migration)
|
||||
- localStorage cache of display codes applied before the first await of boot
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/account_preferences.go
|
||||
- modules/cabana/account_preferences_test.go
|
||||
- modules/cabana/account_preferences_internal_test.go
|
||||
- admin/src/state/usePreferences.ts
|
||||
- admin/tests/state/usePreferences.test.ts
|
||||
modified:
|
||||
- modules/phrasebook/translator.go
|
||||
- modules/cabana/{account.go,auth.go,token_guard.go,http.go,lang.go,admin_openapi.go,README.md}
|
||||
- modules/cabana/{security_coverage,token_routes,phase10_coverage,phase10_csrf,openapi_conformance,messages}_test.go
|
||||
- admin/src/app/{theme.ts,i18n.ts}, admin/src/main.ts, admin/src/state/{useAuth.ts,useSidebar.ts}
|
||||
- admin/src/components/shell/{PluginRail.vue,UserMenu.vue,AppShell.vue}
|
||||
- admin/src/views/{FormView,SettingsFormView,AccountView,PreviewView}.vue
|
||||
- admin/openapi/admin.json, admin/src/api/{schema.d.ts,types.ts}
|
||||
- docs/backend/{users-and-permissions.md,admin-spa.md}, docs/services/localization.md
|
||||
- modules/boardwalk/dist
|
||||
decisions:
|
||||
- "The default choices (theme system, locale \"\") are stored as absent keys both in backend_users.metadata and in localStorage, so an admin who never chose keeps NULL metadata and browser storage holds only real choices."
|
||||
- "PUT /account/preferences writes only the metadata column (UpdateColumns, updated_at untouched) and skips the write entirely when the metadata would not change."
|
||||
- "usePreferences tracks the stored locale itself (localePreference) instead of reading useAuth, avoiding a useAuth <-> usePreferences import cycle; setLocale(code) is a no-op for that value."
|
||||
- "Admin language precedence: interactive admin's stored preference, then the request locale (LocaleResolver or Accept-Language); personal API tokens always use the request locale."
|
||||
metrics:
|
||||
started: 2026-10-15T22:34:27Z
|
||||
completed: 2026-10-16
|
||||
duration: "about 11 h wall clock, including a long pause from an auth interruption mid Task 1"
|
||||
actuals:
|
||||
tokens: 37800 # chars/4 over the realized diff excluding the generated dist (151063 chars)
|
||||
tasks: 3
|
||||
commits: 9
|
||||
plan_head_before: 1a03399357d376e6f7acefe6ad301a79161fae19
|
||||
plan_head_after: 66fd4ae4b702373adc818324270337796f32d71a
|
||||
---
|
||||
|
||||
# Phase quick-261016-0fm Plan 01: Admin minors for v0.2.1 Summary
|
||||
|
||||
Admins get a server-stored theme switch (system, light, dark) on the rail's top button and a language switcher in the user menu. The stored admin language overrides the request locale on guarded admin routes for interactive sessions only. The fixed save bars now start at the sidebar's right edge, so the rail's Settings item stays clickable. The embedded dist is rebuilt and every release gate passes. Nothing is tagged, pushed or deployed.
|
||||
|
||||
## Commits
|
||||
|
||||
| # | Hash | Subject |
|
||||
|---|------|---------|
|
||||
| 1 | 047858a | feat(quick-261016-0fm): per-admin theme and locale preferences |
|
||||
| 2 | 21b0d39 | feat(quick-261016-0fm): manual theme switch on the rail |
|
||||
| 3 | 3d2f9d0 | fix(quick-261016-0fm): full key literals for the rail theme label |
|
||||
| 4 | f67e891 | feat(quick-261016-0fm): admin language follows the stored preference |
|
||||
| 5 | 5244a37 | feat(quick-261016-0fm): language switcher in the user menu |
|
||||
| 6 | 85e52d9 | fix(quick-261016-0fm): bottom save bars no longer cover the sidebar |
|
||||
| 7 | 119f1f8 | test(quick-261016-0fm): admin preference coverage |
|
||||
| 8 | f0ab26b | test(quick-261016-0fm): theme switch, language switcher and footer offset |
|
||||
| 9 | 66fd4ae | build(quick-261016-0fm): rebuild embedded admin dist for v0.2.1 |
|
||||
|
||||
All commits are on `master` in the main checkout, as the orchestrator directed. Each was staged by explicit path, and none carries a co-author tag.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Preferences API (cabana):** `GET` and `PUT /account/preferences`. Both are wrapped in `interactiveOnly`, and the PUT also in `requireAjax`. They act only on the principal's own row, locked with `lockAccount`. PUT accepts only `theme` and `locale`, saves each key that is present and keeps the stored value of an absent key. Validation: theme must be in {system, light, dark}; locale must be `""` or a code in `Translator.Locales("backend::lang.")`; a wrong type is a 422. Metadata that is not a JSON object is a 422 and is never overwritten. `AdminProfile.preferences` is filled by `profileFor`.
|
||||
- **Locale override:** `principalFrom` sets `PreferredLocale` from `admin_locale`, and `token_guard` clears it for personal tokens. The guarded group is now `[]string{"backend", "locale.from-principal"}`. `GET /lang` honours a `?locale=` only when it is a supported admin locale, and answers `meta.locales`.
|
||||
- **SPA:**
|
||||
- `theme.ts` gained `ThemeChoice`, `THEME_CHOICES`, `isThemeChoice` and the choice argument to `applyColorScheme`.
|
||||
- New `usePreferences.ts`: theme and locale state, the localStorage cache, the PUT, revert with a toast on failure, apply-on-load, and `setLocale` followed by a stubbable `page.reload()`.
|
||||
- Boot applies the cached theme before the first await and loads strings in the cached language.
|
||||
- Rail button with `data-theme-toggle`: SunMoon/Sun/Moon icon and a translated label.
|
||||
- User menu: `DropdownMenuRadioGroup` of the admin locales, each named in its own language via `Intl.DisplayNames`.
|
||||
- **Layout:** `AppShell` binds `--shell-offset` (rail, or rail plus panel). The four save bars use `right-0 left-[var(--shell-offset,0px)]`; the compiled CSS rule is confirmed in the dist.
|
||||
- **Docs:** cabana README (feature bullet, route rows, override paragraph, API reference rows), phrasebook README, `users-and-permissions.md` (Preferences), `admin-spa.md` (theme switch, language switcher, `?locale=`) and `localization.md` (`Locales`, precedence).
|
||||
|
||||
## Gate results (final run at 66fd4ae)
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -short ./...` | PASS |
|
||||
| Docker cabana: `TestAccountPreferences*`, `TestAdminLocalePreference`, `TestAuthMe*`, `TestAccountProfile*`, `TestPhase09*`, `TestPhase10*` (incl. OpenAPI conformance), `TestTokenRouteGates`, `TestInteractiveOnly` | PASS |
|
||||
| Docker lagoon: `TestBackendAdminMigration` (backend_user_preferences still absent), `TestBackendAdminMetadataColumn` | PASS (both ran, not skipped) |
|
||||
| Full Docker-backed `go test ./...` (extra) | PASS |
|
||||
| `go test ./cmd/summer -run TestDocsTree` | PASS |
|
||||
| `go run ./cmd/summer docs:build --check` | PASS ("no problems found") |
|
||||
| `examples/hello` `go test ./...` | PASS |
|
||||
| `npm --prefix admin run typecheck` | PASS |
|
||||
| `npm --prefix admin run test` | PASS (93 files, 1429 tests) |
|
||||
| `scripts/check-admin-openapi.sh --check` | PASS |
|
||||
| `scripts/check-admin-dist.sh` | PASS ("matches a fresh build") |
|
||||
| clean `modules/ admin/ docs/` tree | PASS |
|
||||
|
||||
Docker was available to the current user (29.7.2); no `sudo -u golem` was needed.
|
||||
|
||||
Hand checks: 0 co-author lines in `1a03399..HEAD`. No `.planning/state.json`, `.msd/` or `17-VERIFICATION.md` in any commit. `grzyby` appears 0 times in the modules/docs/admin diff. `git tag --points-at HEAD` is empty.
|
||||
|
||||
A mutation check confirmed that `TestAccountPreferencesAuthorization` fails when the `PreferredLocale` clear in `token_guard.go` is removed: the token request then answers `pl`. The file was restored from HEAD afterwards.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The rail theme label used a template-string lang key**
|
||||
- **Found during:** Task 2, the first short-test run after commit 21b0d39.
|
||||
- **Issue:** `phrasebook`'s `TestPhase10SPAKeysResolve` scans `admin/src` for `backend::lang.*` literals and found the unresolvable prefix `backend::lang.nav.theme_`. Commit 21b0d39 on its own therefore fails `go test -short ./modules/phrasebook`. This is the one commit in the series that is not green on its own.
|
||||
- **Fix:** a `themeModes` map with full key literals.
|
||||
- **Files:** `admin/src/components/shell/PluginRail.vue`
|
||||
- **Commit:** 3d2f9d0
|
||||
|
||||
**2. [Rule 3 - Blocking] Route-count and envelope tests outside the plan's list**
|
||||
- `phase10_csrf_test.go` counts state-changing routes. The count went from 33 to 34 for `PUT /account/preferences`, in commit 047858a.
|
||||
- `messages_test.go` decodes `/lang` with `DisallowUnknownFields`, so its meta struct gained `locales`. `admin/tests/fixtures/typed.ts` (now `cabana.LangEnvelope`) and the `meta.locales` field of `admin/tests/fixtures/lang.json` were updated in the same commit to keep typecheck green. Both changes are in f67e891.
|
||||
|
||||
**3. [Rule 3 - Blocking] Theme cache vs the existing storage-invariant tests**
|
||||
- Existing tests assert that the sidebar key is the only localStorage entry after sign-in or logout. Caching `system` would have broken them, so the default choices are stored as absent keys, which is equivalent for `cachedTheme()` and `cachedLocale()`.
|
||||
|
||||
### Executor-discretion choices
|
||||
- The `admin-spa.md` theme paragraph went into the SPA commit (21b0d39) rather than the backend commit, because it describes SPA behaviour.
|
||||
- `resetState()` resets the theme and locale preference through `applyProfilePreferences(profile.data)`, so no test-only hook was added to the source.
|
||||
- The earlier gofmt-induced reflow of `token_routes_test.go` (the file was already not gofmt-clean) was reverted to a minimal diff.
|
||||
|
||||
### Notes
|
||||
- One run of `tests/shell` failed once in the timing-sensitive "keeps the flyout open while focus stays on its rail item" test, then passed on rerun and in every full run since. This looks like existing flakiness, not a regression.
|
||||
- The execution was interrupted by an auth error mid Task 1. All uncommitted edits were intact on resume, and nothing was lost or redone.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. Every `mitigate` entry is implemented and tested:
|
||||
- T-01: own row and refused keys.
|
||||
- T-02: cookie PUT without the header gets 403.
|
||||
- T-03: token gets 403 interactive_only.
|
||||
- T-04: value and type refusals.
|
||||
- T-05: other keys kept, non-object metadata stays byte-identical.
|
||||
- T-06: unsupported `?locale` ignored, never echoed.
|
||||
- T-07: tokens keep the request locale (mutation-checked).
|
||||
- T-10: stale values degrade to defaults in `preferencesOf`.
|
||||
|
||||
## Release steps pending the user's go-ahead
|
||||
|
||||
1. Tag `v0.2.1` on `master` at or after 66fd4ae, once the orchestrator's docs commit lands.
|
||||
2. Push `master` and the tag.
|
||||
3. Bump the framework in consuming applications and deploy each per its own DEPLOY.md.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: `modules/cabana/account_preferences.go`, `modules/cabana/account_preferences_test.go`, `modules/cabana/account_preferences_internal_test.go`, `admin/src/state/usePreferences.ts` and `admin/tests/state/usePreferences.test.ts` all exist.
|
||||
- Commits: 047858a, 21b0d39, 3d2f9d0, f67e891, 5244a37, 85e52d9, 119f1f8, f0ab26b and 66fd4ae are all present in `git log`.
|
||||
Reference in New Issue
Block a user