docs(quick-261009-mys): subtitle, avatar and My account page for backend users

This commit is contained in:
2026-10-09 18:01:43 +02:00
parent 89611eaaf9
commit 7c5b8a8cb7
5 changed files with 928 additions and 1 deletions

View File

@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 16 (grzybyfunkcjonalne.pl on reusable blog views) — EXECUTING
Plan: 1 of 5
Status: Executing Phase 16
Last activity: 2026-10-09 - Completed quick task 261009-mgk: add the category content field to sm-journal-plugin (D-21)
Last activity: 2026-10-09 - Completed quick task 261009-mys: subtitle, avatar and My account page for backend users
Progress: [██████████] 100%
@@ -676,6 +676,7 @@ Recent decisions affecting current work:
| 261008-o6k | summercms.io/update: update one application in place (framework checkout, CLI, .env admin secret, build) | 2026-10-08 | 9b3803c | [261008-o6k-bootstrap-upgrade-mode-for-existing-chec](./quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/) |
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
## Deferred Items

View File

@@ -0,0 +1,617 @@
---
phase: quick-261009-mys
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/lagoon/backend_admin_migrations.go
- modules/lagoon/backend_admin_migrations_test.go
- modules/lagoon/README.md
- modules/cabana/contracts.go
- modules/cabana/account.go
- modules/cabana/auth.go
- modules/cabana/http.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/cabana/account_test.go
- modules/cabana/account_internal_test.go
- modules/cabana/security_coverage_test.go
- modules/cabana/phase10_coverage_test.go
- modules/cabana/phase10_csrf_test.go
- modules/cabana/openapi_conformance_test.go
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/state/useAuth.ts
- admin/src/app/router.ts
- admin/src/app/pageTitle.ts
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/components/shell/UserMenu.vue
- admin/src/views/AccountView.vue
- admin/tests/helpers.ts
- admin/tests/fixtures/lang.json
- admin/tests/shell/UserMenu.test.ts
- admin/tests/state/useAuth.test.ts
- admin/tests/views/AccountView.test.ts
- modules/boardwalk/dist/index.html
- modules/boardwalk/dist/assets/
- docs/backend/users-and-permissions.md
- docs/backend/admin-spa.md
autonomous: true
requirements: [QUICK-261009-mys]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "backend_users gains WinterCMS's nullable TEXT metadata column through a new gormigrate entry 202610090001_backend_users_metadata, appended last to lagoon.BackendAdminMigrations. RollbackLast drops only that column and keeps every row; migrate adds it back. A row copied from WinterCMS with metadata {\"subtitle\":\"Editor\",\"x\":1} loads unchanged."
- "The subtitle is stored as metadata.subtitle, the key the WinterCMS Journal author component reads. cabana.BackendUser.Subtitle() returns it and treats NULL, blank, null, [], invalid JSON and a non-string value as an empty subtitle, so no stored metadata can break admin sign-in. Saving a subtitle changes only that key and keeps every other metadata key; saving an empty subtitle removes the key."
- "The avatar is a WinterCMS attachOne: one system_files row with attachment_type Backend\\Models\\User (cabana.BackendUserMorphType), field avatar (cabana.BackendUserAvatarField), attachment_id the admin's id and is_public true, so avatar rows copied from WinterCMS are picked up as they are."
- "GET <prefix>/api/v1/auth/me always returns subtitle and, when the admin has an avatar, avatar {url, thumb_url}. thumb_url is a 250x250 crop thumbnail. The top bar user menu shows that image in place of the initials."
- "PUT <prefix>/api/v1/account changes only the signed-in admin's own row. The row id comes from the principal and never from the request. It accepts first_name, last_name, email, subtitle, current_password, password and password_confirmation, and nothing else. Any other key (login, role_id, is_superuser, permissions, is_activated, id and so on) answers 422 validation_failed with a detail on that key, and nothing is written."
- "Email is required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of any other admin, soft-deleted admins included. The admin's own email in a different case is accepted. A unique-index race also answers 422 on email, never 500."
- "A password change needs current_password matching the stored bcrypt hash (otherwise 422 on current_password, logged as admin.auth failed, nothing written), a password of at least 4 characters and at most 72 bytes, and an equal password_confirmation. On success the password is rehashed at admin.password.bcrypt_cost and tokens_valid_after is set to the start of the current second, which ends every other session. A cookie-transport request gets a fresh session cookie and stays signed in."
- "POST <prefix>/api/v1/account/avatar takes one multipart part, file_data. It accepts only images (extension jpg, jpeg, png, gif or webp, a sniffed image MIME type and the attach.IsAllowedImage decode guard) of at most cabana.AvatarMaxBytes (2 MiB), further capped by http.body_limits.upload_bytes. The disk name is generated by the server and the client file name is never used in a storage key. The upload replaces the previous avatar; the old row is deleted in the same transaction and its blobs after the commit. DELETE <prefix>/api/v1/account/avatar removes the avatar and is idempotent."
- "In the admin SPA the avatar menu has a My account item that opens /account. That page edits first name, last name, email, subtitle and avatar (upload and remove), changes the password with the current password, shows 422 errors on their fields, and updates the top bar without a reload. Login is shown read-only."
- "All three new routes sit behind the backend guard and requireAjax, and they are in every route inventory: the permission matrix, the CSRF walk (26 becomes 29), the unsafe-route coverage list and the OpenAPI conformance cases. admin.json and schema.d.ts are regenerated from the swag annotations."
- "cabana and lagoon READMEs, docs/backend/users-and-permissions.md and docs/backend/admin-spa.md describe the feature in the same commits as the code. They name no consuming application. go vet ./..., go test ./..., TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check and check-admin-dist.sh are green, and go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged."
artifacts:
- path: modules/lagoon/backend_admin_migrations.go
provides: "202610090001_backend_users_metadata: ADD COLUMN IF NOT EXISTS metadata TEXT, rollback DROP COLUMN IF EXISTS metadata"
contains: "202610090001_backend_users_metadata"
- path: modules/cabana/contracts.go
provides: "BackendUser.Metadata (*string, column metadata), Subtitle, MorphName, AttachRelations, BackendUserMorphType, BackendUserAvatarField"
contains: "column:metadata"
- path: modules/cabana/account.go
provides: "accountUpdate, avatarUpload, avatarRemove handlers, profileFor, BackendUserAvatar, AvatarMaxBytes, metadata subtitle helpers"
contains: "func BackendUserAvatar"
- path: modules/cabana/admin_openapi.go
provides: "AdminProfile.Subtitle and Avatar, AdminAvatar, AdminAccountRequest, swag docs for PUT /account, POST and DELETE /account/avatar"
contains: "@Router /account [put]"
- path: admin/src/views/AccountView.vue
provides: "My account screen: profile fields, avatar upload and remove, password change"
contains: "/account"
- path: admin/src/components/shell/UserMenu.vue
provides: "My account menu item and avatar image in the trigger and the menu header"
contains: "data-action=\"account\""
- path: modules/cabana/account_test.go
provides: "Postgres-backed tests of the account routes, the profile payload and the avatar lifecycle"
contains: "TestAccount"
- path: admin/tests/views/AccountView.test.ts
provides: "SPA tests of the My account screen"
contains: "AccountView"
key_links:
- from: modules/cabana/http.go
to: modules/cabana/account.go
via: "mount registers PUT /account, POST /account/avatar and DELETE /account/avatar in the backend-guarded group, each wrapped in requireAjax"
pattern: "requireAjax\\(s\\.(accountUpdate|avatarUpload|avatarRemove)\\)"
- from: modules/cabana/auth.go
to: modules/cabana/account.go
via: "me answers s.profileFor, which adds Subtitle() and the BackendUserAvatar URLs"
pattern: "profileFor"
- from: admin/src/components/shell/UserMenu.vue
to: admin/src/views/AccountView.vue
via: "the My account item pushes the account route registered in router.ts"
pattern: "name: 'account'"
- from: admin/src/views/AccountView.vue
to: admin/src/state/useAuth.ts
via: "every successful save, upload or remove passes the returned AdminProfile to setCurrentUser, so the top bar updates"
pattern: "setCurrentUser"
- from: modules/cabana/admin_openapi.go
to: admin/src/api/schema.d.ts
via: "scripts/check-admin-openapi.sh regenerates admin.json and schema.d.ts; types.ts aliases AdminAvatar and AdminAccountRequest"
pattern: "cabana.AdminAccountRequest"
---
<objective>
Give backend administrators a subtitle and an avatar, and add a WinterCMS-style My account page, opened from the avatar menu, where an admin edits their own profile and password.
**Repository.** All code lands in summercms.go, the framework. It stays application-neutral: no consuming-application names in code, READMEs or docs. Planning documents (this plan and its SUMMARY) are committed separately by the orchestrator. Never stage `.planning/`, `.msd/` or `.planning/state.json` together with code.
Purpose: WinterCMS keeps a free-form `metadata` JSON column and an `avatar` attachOne on `backend_users`, and its Journal author component shows `metadata.subtitle`. The Go `backend_users` table has neither, so authors imported into the framework lose their subtitle and picture, and no admin can edit them (todo `journal-author-profile-fields`). This plan settles the framework storage the same way WinterCMS does and adds the editor.
Output:
- Three code commits in summercms.go:
1. The profile slice: migration, model, GET /auth/me subtitle, PUT /account, SPA page and menu entry.
2. The avatar slice: attachment, upload and remove routes, avatar in /auth/me, SPA upload and top-bar image.
3. Unit tests that cover both slices in full.
- A SUMMARY that says which part of the todo is closed.
Source coverage:
- Task description, subtitle and avatar on backend users: Tasks 1 and 2.
- My account page reached from the avatar menu: Tasks 1 and 2.
- The admin edits their own data, enforced server-side: Task 1.
- Password change requires the current password: Task 1.
- Avatar validation of type and size, and safe storage names: Task 2.
- Avatar and subtitle on the current-user payload: Tasks 1 and 2.
- Additive migration with rollback: Task 1.
- README and docs updated in the same change: Tasks 1 and 2.
- Unit tests in the last task: Task 3.
- Todo `journal-author-profile-fields`: this plan closes its framework-storage half. Still open: the description field and the plugin's author payload (see output).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/STATE.md
@.planning/todos/pending/journal-author-profile-fields.md
@modules/lagoon/backend_admin_migrations.go
@modules/cabana/contracts.go
@modules/cabana/auth.go
@modules/cabana/http.go
@modules/cabana/admin_openapi.go
@modules/cabana/field_file.go
@modules/lagoon/attach/store.go
@modules/lagoon/attach/file.go
@admin/src/components/shell/UserMenu.vue
@admin/src/state/useAuth.ts
@admin/src/app/router.ts
@admin/src/views/SettingsFormView.vue
Facts established while planning (do not re-derive):
- **WinterCMS reference** (examples/golem15-wintercms-starter, modules/backend):
- `Backend\Controllers\Users::myaccount` edits the signed-in admin's own record in the `myaccount` form context. That form shows login, email, first_name, last_name, password, password_confirmation and the `avatar` fileupload (mode image, 250x250). Role, groups, superuser and permissions are excluded from that context.
- After a password or login change, Winter re-authenticates the user.
- `Backend\Models\User` has `$attachOne = ['avatar' => System\Models\File]` and `$jsonable = ['permissions', 'metadata']`.
- Migration `2023_02_16_000012_Db_Backend_Add_User_Metadata` adds `metadata` as a nullable mediumText.
- Validation: email `required|between:6,255|email|unique`, password `min:4|confirmed`.
- Lang: `backend::lang.myaccount.menu_label` is "My account" and `backend::lang.user.avatar` is "Avatar".
- **Where the PHP Journal plugin keeps the subtitle.** The WinterCMS Journal `components/Author.php` reads `$this->author->metadata['subtitle']`, the core `backend_users.metadata` JSON. The Go column is therefore `metadata` (TEXT, Winter jsonable), with the subtitle under the `subtitle` key, not a dedicated column. A verbatim row copy then carries the subtitle without any transform.
- **Stored metadata can be odd.** PHP `json_encode([])` writes `[]`, so a copied row can hold `[]`, NULL or blank. Map `BackendUser.Metadata` as a raw `*string` (as `Permissions` is a raw string). Never use a strict `lagoon.Jsonable[map...]` type here: a scan error would break the guard's `BackendUsers.FindByID` and lock every admin out. Parse leniently in helpers.
- **No backend-user admin controller exists** in the framework. The only admin screens are plugin controllers, and backend admins are managed with the `admin:create` and `admin:reset-password` commands. My account is therefore the only editor of subtitle and avatar. There is no "users admin form" to extend.
- **Admin route plumbing** (pattern commit `b492e79`, the markdown preview route):
- Routes are mounted in `service.mount` (http.go) inside `r.GroupRaw(api, []string{"backend"}, ...)`. State-changing routes are wrapped in `requireAjax`.
- Every route needs all of the following, in the same commit:
- a swag doc function in admin_openapi.go;
- a regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` (`bash scripts/check-admin-openapi.sh`);
- an entry in `phase09Routes` (security_coverage_test.go, checked by TestPhase09PermissionMatrix);
- a line in the `want` list of TestPhase10Coverage and an updated count message (phase10_coverage_test.go);
- an updated count and comment in TestPhase10CSRF (phase10_csrf_test.go, now 26);
- a case in TestPhase10OpenAPIConformance (openapi_conformance_test.go, decoded with DisallowUnknownFields against the admin.json schema).
- The cases run in slice order with one Bearer token, and POST /auth/logout is last. A conformance case for PUT /account must not change the password: that sets tokens_valid_after and invalidates the token for later cases.
- **Path choice.** Controller routes have at least three segments (`/{vendor}/{plugin}/{controller}`). `/account` and `/account/avatar` (one and two segments) collide with nothing, so no reserved vendor segment is needed. Do not use `/auth/me/avatar`: it would shadow a three-segment controller `auth.me.avatar`. In the SPA, `/account` is one segment and cannot collide with `/:vendor/:plugin/:controller`.
- **Body helpers in cabana.**
- `s.decodeCappedObject(w, r)` (relation_child.go) decodes a JSON object with UseNumber under `s.jsonCap()`. Too large is a `*http.MaxBytesError` (answer 413 `payload_too_large` as `writeRelationError` does), and anything malformed is `invalidBody()`.
- Validation failures are `&ValidationError{Details: map[string]any{field: []string{msg}}}`, written with `writeCRUDError`.
- `s.translator()` returns the phrasebook translator. `fieldMessage(ctx, tr, rule, field, params)` gives a `lagoon::validation` line with an English fallback.
- **lagoon.ValidateRequest** supports `required`, `nullable`, `string`, `email`, `min`, `max`, `between` and closures built with `lagoon.CustomRule`.
- It has no `confirmed` and no `unique` rule.
- A non-implicit rule, custom rules included, is skipped for an absent or blank value.
- So run the confirmation, current-password and uniqueness checks in Go after ValidateRequest, and add their messages to the same details map.
- bcrypt refuses input over 72 bytes: `bouncer.HashPassword` returns an error.
- **Upload plumbing to reuse** (field_file.go `fileUploadOn`):
- Read `http.MaxBytesReader` through a `bodyReader`, then `r.MultipartReader()`, and require exactly one part named `file_data` with a non-blank file name.
- Call `attach.Store(ctx, tx, bucket, attach.Upload{FileName, Body, Public}, attach.Limits{...})`. It generates the disk name, keeps only a sanitized base name as file_name, enforces MaxBytes while streaming, and in Image mode applies `attach.IsAllowedImage`.
- Map errors with the `writeFileError` logic: `attach.ErrTooLarge`, `ErrFileType`, `ErrMIMEType` and `ErrNotImage` are a 422 on the field, and a `*http.MaxBytesError` is 413.
- Store writes the blob before the row, so on a rolled-back transaction delete `attach.BlobKeys(*stored)` with `attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys)`.
- The bucket comes from `s.bucket()`, and the cap arithmetic is `uploadCap` with `multipartOverhead`.
- `attach.DefaultImageExtensions` is jpg, jpeg, png, gif and webp. `attach.AllowedImageMIMEs` lists the four raster MIME types.
- `File.URL()` builds the public URL with no I/O. `File.Thumb(ctx, bucket, 250, 250, "crop")` returns a thumbnail URL and generates it lazily.
- `fileItem` shows the pattern: a thumbnail failure is logged at warn level and the thumb_url is left out.
- **Session plumbing** (auth.go):
- `sessionToken(r)` reports whether the token came from the cookie.
- `bouncer.MintAudience(s.secret, uitoa(id), s.issuer, s.ttl, bouncer.AudienceBackend)` mints a token, and `s.writeSessionCookie(w, token)` sets the cookie.
- The guard rejects a token when `iat.Before(TokensValidAfter)`, and JWT iat has whole-second precision. Set the cutoff to `time.Now().UTC().Truncate(time.Second)` and mint after it, so the fresh token (iat in the same or a later second) passes. `admin:reset-password` instead uses now+1s, because it re-issues nothing.
- `s.logAuth(r, outcome, id)` writes the `admin.auth` audit line.
- **Admin SPA.**
- Vue 3 and TypeScript, openapi-fetch client `api` (src/api/client.ts). It adds X-Requested-With and refreshes once on a 401, replaying a cloned request.
- Every `backend::lang.*` key used under admin/src must resolve in both the en and pl catalogs (`modules/phrasebook/backend/lang/{en,pl}/lang.yaml`), or TestPhase10SPAKeysResolve fails `go test`.
- tsconfig includes `tests/**/*.ts`, so a test fixture typed as AdminProfile without the new required `subtitle` breaks `npm --prefix admin run typecheck`.
- The embedded build `modules/boardwalk/dist` is committed with the SPA change (`npm --prefix admin run build`). `bash scripts/check-admin-dist.sh` fails when it is stale.
- Page pattern: SettingsFormView.vue (header, card form, fixed footer with Save, `FormErrorBanner`, `fieldErrors`, `showToast`). Plain input markup: LoginView.vue.
- **Test harness.**
- cabana_test: `adminGorm(t)` starts a shared testcontainers Postgres and runs `lagoon.Migrate`. `adminHandler(t, gdb, mutate)` assembles the router without a bucket, with upload_bytes 1 MiB. `insertAdmin(...)` and `getAuth`/`postAuth`/`postJSON`/`accessToken` are in auth_test.go. The conformance env has a memblob bucket.
- lagoon: `dedicatedDB(t, name)` and `rollbackBackendAdmin` (which loops over `BackendAdminMigrations`, so the new entry is picked up).
- **Docker is not reachable for user golem.** Integration fallback, proven by quick tasks i6e and mgk:
1. Start a throwaway cluster in the session scratchpad: `initdb -D <scratch>/pg -U postgres --auth=trust`, then `pg_ctl -D <scratch>/pg -l <scratch>/pg.log -o "-p 55493 -k '' -c listen_addresses=127.0.0.1" start`.
2. Make temporary, uncommitted edits to the Postgres starters: `adminGorm` in modules/cabana/auth_test.go, the container starter in modules/cabana/query_test.go, `startLagoonPostgres` in modules/lagoon/postgres_test.go, and modules/lagoon/attach/lifecycle_test.go if attach tests run. When env `SUMMER_TEST_PG_DSN` is set, each opens that DSN with `sql.Open("pgx", dsn)` instead of a container. Each stop function must tolerate a nil container.
3. Use a fresh database per full run (`createdb -h 127.0.0.1 -p 55493 -U postgres runN`): the cabana harness keeps state in one database.
4. Revert the harness files with `git checkout --` before every `git add`, and stop the cluster at the end.
5. Record this in the SUMMARY.
- Never pass `-short` to an acceptance run. Never commit a harness override.
- **Planning-time HEAD** is `5ba1a4a`, used as the base of the diff gates.
- **Repo hygiene.** Stage by explicit path. Commit messages carry no co-author or attribution trailer: the user's global and project CLAUDE.md override the session reminder.
Planning decisions (Claude's discretion; record them in the SUMMARY):
- P-01 Subtitle in `metadata.subtitle`, not in its own column. This gives WinterCMS row parity and matches what the Journal author component reads. Unknown metadata keys are preserved.
- P-02 Avatar as a public `system_files` attachOne under the WinterCMS morph name `Backend\Models\User`, field `avatar`, the same as WinterCMS. Public, because author pages will show it.
- P-03 Routes `PUT /account`, `POST /account/avatar` and `DELETE /account/avatar`. The profile is read from the existing `GET /auth/me`, with no separate GET /account.
- P-04 Login stays read-only on My account. The task lists first name, last name, email, subtitle, avatar and password. WinterCMS also lets an admin change their login there; changing it would also need the sign-in ambiguity checks.
- P-05 An email change needs no current password (WinterCMS parity). A password change does need it (task requirement). The framework has no email-based password reset, so an email change cannot take over the account.
- P-06 A password change ends every other session (tokens_valid_after) and keeps the current browser signed in with a fresh cookie, which matches Winter's re-login. A Bearer client must sign in again.
- P-07 `cabana.AvatarMaxBytes` is a fixed 2 MiB with no new config key. `http.body_limits.upload_bytes` still caps the request.
- P-08 The password rule is WinterCMS `min:4`, plus a 72-byte maximum, which bcrypt requires.
- P-09 PUT /account has no throttle. It requires an authenticated session, and a login-style throttle would block normal saves. Failed current-password checks are logged instead.
</context>
<tasks>
<task type="tracer">
<name>Task 1: My account profile end-to-end: metadata column, subtitle, PUT /account, SPA page and menu entry</name>
<files>modules/lagoon/backend_admin_migrations.go, modules/lagoon/README.md, modules/cabana/contracts.go, modules/cabana/account.go, modules/cabana/auth.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/account_test.go, modules/cabana/security_coverage_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/state/useAuth.ts, admin/src/app/router.ts, admin/src/app/pageTitle.ts, admin/src/components/shell/Breadcrumbs.vue, admin/src/components/shell/UserMenu.vue, admin/src/views/AccountView.vue, admin/tests/helpers.ts, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md</files>
<action>Build the thinnest complete path first: an admin opens My account from the avatar menu, changes the subtitle, saves, and sees the change returned by GET /auth/me. Then fill in the rest of the profile form on the same path.
1. **Migration** (lagoon, per P-01):
- Append `202610090001_backend_users_metadata` as the last entry of `lagoon.BackendAdminMigrations`.
- Migrate: `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT` (nullable, no default).
- Rollback: `ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`.
- Leave the two existing migrations untouched.
- Update the doc comment on BackendAdminMigrations to name the column as WinterCMS's jsonable `metadata`.
2. **Model** (contracts.go):
- Add `Metadata *string` with tag `gorm:"column:metadata"` to `BackendUser`, after `Permissions`.
- Add the method `(BackendUser) Subtitle() string`.
- In account.go, add two package-level helpers: `metadataString(raw *string, key string) string` and `setMetadataString(raw *string, key, value string) (*string, error)`.
- Decode into `map[string]json.RawMessage` so other values survive unchanged.
- Treat NULL, blank, `null` and `[]` as an empty object.
- The reader returns "" for invalid JSON or a non-string value.
- The writer deletes the key when value is "", and returns nil (SQL NULL) when no keys remain.
- The writer returns an error, and never overwrites, when the stored text is neither empty nor `[]` nor a JSON object.
3. **Profile payload** (admin_openapi.go, auth.go, account.go):
- Add `Subtitle string` with tag `json:"subtitle"` to `AdminProfile`.
- Replace the free function `profileOf` with the method `(s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile`, and use it in `me`. Task 2 extends it with the avatar.
4. **PUT /account** (account.go, `(s *service) accountUpdate`), mounted in the backend-guarded group as `g.Put("/account", requireAjax(s.accountUpdate))`:
- **Principal.** Read it from `bouncer.User`. It must be a backend principal, otherwise answer 401.
- **Body.** Decode with `s.decodeCappedObject`.
- **Allow-list (enforced server-side).** The accepted keys are first_name, last_name, email, subtitle, current_password, password and password_confirmation. Any other key answers 422 `validation_failed` with one detail per offending key. The message is `backend::lang.myaccount.field_not_editable`, translated through `s.translator()` in the request locale. Nothing is written.
- **Transaction.** Inside `lagoon.Transaction`, load the user with `Preload("Role")` and `clause.Locking{Strength: "UPDATE"}` by `principal.ID`, never by any request value. A missing or deactivated user answers 401, as `me` does.
- **Field rules,** through `lagoon.ValidateRequest` with an ordered `[]lagoon.RequestRule`:
- first_name and last_name: `nullable|string|max:255`.
- email: `required|string|email|between:6,255`.
- subtitle: `nullable|string|max:255`.
- password: `nullable|string|min:4|max:72`.
- **Checks run in Go afterwards,** because custom rules are skipped for absent or blank values. Add their messages to the same details map, ordered by `lagoon.ErrorKeys`:
- password over 72 bytes: the `max.string` line.
- password_confirmation must equal password: `lagoon::validation.confirmed` through `fieldMessage`.
- a non-blank password requires current_password (the `required` line), and `bouncer.CheckPassword` against the stored hash must pass. Otherwise use `backend::lang.myaccount.current_password_invalid` and call `s.logAuth(r, "failed", user.ID)`.
- email uniqueness: `Unscoped` (soft-deleted rows included), `id <> user.ID AND (lower(email) = lower(?) OR lower(login) = lower(?))`, answered with the `unique` validation line.
- A Postgres 23505 on `backend_users_email_lower_unique` maps to the same 422 on email.
- **Write.** One `Updates` map with only these columns:
- first_name, last_name and email, each trimmed;
- metadata, through `setMetadataString(user.Metadata, "subtitle", trimmed subtitle)`; absent or null means "";
- updated_at = now;
- when a password was given: password (`bouncer.HashPassword(s.bcryptCost, ...)`) and tokens_valid_after = `time.Now().UTC().Truncate(time.Second)`.
- Absent first_name, last_name or subtitle mean empty: PUT replaces the profile.
- **Session (per P-06).** After the commit of a password change, when `sessionToken(r)` came from the cookie, mint a fresh backend token with `bouncer.MintAudience` and call `s.writeSessionCookie`. Call `s.logAuth(r, "password_changed", user.ID)`.
- **Response.** 200 with `Envelope[AdminProfile]` built by `profileFor` from the reloaded row.
- **Errors.** Malformed body 422, too large 413, anything else through `writeCRUDError`, logged.
5. **OpenAPI** (admin_openapi.go):
- Add `AdminAccountRequest`: Email `json:"email"`; FirstName, LastName and Subtitle as `*string` with omitempty; CurrentPassword, Password and PasswordConfirmation as strings with omitempty. Its comment states that it documents the only accepted keys.
- Add the doc function `AdminAccountUpdate` (`@Router /account [put]`, `@Security BackendBearer`, success `Envelope[AdminProfile]`, failures 401, 403, 413 and 422) in the style of `AdminMarkdownPreview`.
- Extend the `AdminMe` doc description with subtitle.
- Run `bash scripts/check-admin-openapi.sh` and commit admin.json and schema.d.ts.
6. **Inventories:**
- `{key: "PUT /account"}` in `phase09Routes`.
- `"PUT /account"` in the sorted `want` list of TestPhase10Coverage.
- The CSRF count 26 becomes 27, in both the message and the comment list.
- A conformance case "PUT /account" (200, `cabana.Envelope-cabana_AdminProfile`) that changes first_name and subtitle and keeps the conform admin's email, with no password. Place it before POST /auth/logout.
7. **Lang:** add a `myaccount:` group to both catalogs with these keys:
- menu_label (My account / Moje konto)
- menu_description
- profile
- login
- first_name
- last_name
- email
- subtitle
- subtitle_comment
- password_section
- password_comment (leave blank to keep the current password)
- current_password
- password
- password_confirmation
- current_password_invalid
- field_not_editable
- saved
Every key the SPA uses must resolve in both locales.
8. **SPA:**
- **types.ts:** alias `AdminAccountRequest`.
- **useAuth.ts:** export `setCurrentUser(profile: AdminProfile)`.
- **router.ts:** add `{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } }` before the controller routes.
- **pageTitle.ts and Breadcrumbs.vue:** the account route shows `backend::lang.myaccount.menu_label`, as the settings route is handled.
- **AccountView.vue:** follow the SettingsFormView layout and the LoginView input markup.
- Login is read-only.
- Inputs for first_name, last_name, email (type email) and subtitle, with its comment.
- A password section with current_password (autocomplete current-password) and password and password_confirmation (autocomplete new-password).
- Initial values come from `currentUser`.
- Save sends `api.PUT('/account', { body })` with exactly the allowed keys, and the password keys only when a new password is typed.
- On 200: `setCurrentUser`, clear the password inputs and show the toast `myaccount.saved`.
- On 422: show the errors per field with `fieldErrors` and `FormErrorBanner`, and focus the first one.
- On any other failure: show the `form.error_generic` toast.
- **UserMenu.vue:** add a `DropdownMenuItem` with `data-action="account"`, a lucide user icon and the label `myaccount.menu_label`, above the separator and logout. It calls `router.push({ name: 'account' })`.
- **admin/tests/helpers.ts:** update any profile fixture so typecheck and the existing tests stay green.
- Run `npm --prefix admin run build` and commit `modules/boardwalk/dist`.
9. **Smoke test:** add `TestAccountProfileSmoke` in a new modules/cabana/account_test.go (package cabana_test, `adminGorm`, `adminHandler`, `insertAdmin`). Using a Bearer token, PUT /account sets names, email and subtitle, and GET /auth/me returns them. A body with `is_superuser` answers 422. Full coverage is Task 3.
10. **Docs, in this commit** (CLAUDE.md documentation rules):
- **cabana README:**
- a Features bullet for My account;
- in the Admin API routes table, a PUT `/account` row, and GET `/auth/me` now returning subtitle;
- in the API reference: `cabana.AdminProfile`, `cabana.AdminAccountRequest`, `cabana.BackendUser.Subtitle`.
- **lagoon README:** BackendAdminMigrations adds Winter's nullable `metadata` column.
- **docs/backend/users-and-permissions.md:** a `## My account` section covering:
- which fields can be edited, and that login, role, superuser, permissions and activation cannot;
- the email rules;
- the password rules and session effects, including that a Bearer client signs in again;
- storage in `backend_users.metadata` under `subtitle`, compatible with copied WinterCMS rows.
- **docs/backend/admin-spa.md:** the `/account` screen opened from the user menu.
- Use neutral names only. Name no consuming application.
11. **Commit** with subject `feat(cabana): add the My account page with subtitle and password change`. Stage by explicit path, with no planning files and no harness overrides.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && out=$(go test ./modules/cabana -count=1 -v -run '^(TestAccountProfileSmoke|TestPhase10OpenAPIConformance|TestPhase09PermissionMatrix|TestPhase09SecurityCoverage|TestPhase10CSRF|TestPhase10Coverage|TestAdminAuthLifecycle)$' 2>&1) && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestAccountProfileSmoke' && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestPhase10OpenAPIConformance' && go test ./modules/lagoon -count=1 -run '^(TestBackendAdmin|TestPhase09MigrationsFreshRollback)' && grep -qF '202610090001_backend_users_metadata' modules/lagoon/backend_admin_migrations.go && grep -qF 'requireAjax(s.accountUpdate)' modules/cabana/http.go && grep -qF "name: 'account'" admin/src/app/router.ts && grep -qF 'data-action="account"' admin/src/components/shell/UserMenu.vue && grep -qF 'PUT `/account`' modules/cabana/README.md && grep -qF '## My account' docs/backend/users-and-permissions.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -qF 'My account' && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json</automated>
</verify>
<done>
- The migration adds and rolls back `backend_users.metadata`.
- GET /auth/me returns subtitle.
- PUT /account edits only the caller's own first_name, last_name, email, subtitle and (with current_password) password, and refuses every other key with 422.
- The avatar menu opens /account, and saving there updates the top bar.
- Inventories, admin.json, schema.d.ts, dist, READMEs and docs are in one commit.
- Every check is green with Docker or the context fallback, which is reverted before staging.
</done>
</task>
<task type="auto">
<name>Task 2: Avatar end-to-end: attachOne relation, upload and remove routes, avatar in /auth/me, SPA upload and top-bar image</name>
<files>modules/cabana/contracts.go, modules/cabana/account.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/account_test.go, modules/cabana/security_coverage_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/views/AccountView.vue, admin/src/components/shell/UserMenu.vue, admin/tests/helpers.ts, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/, docs/backend/users-and-permissions.md</files>
<action>Add the avatar along the path Task 1 proved (per P-02 and P-07).
1. **Model** (contracts.go):
- Exported constants `BackendUserMorphType = "Backend\\Models\\User"` (WinterCMS's class string, so copied rows match) and `BackendUserAvatarField = "avatar"`.
- `BackendUser` implements `attach.Owner` (`MorphName()` returns BackendUserMorphType).
- `BackendUser` implements `attach.HasRelations` (`AttachRelations()` returns one `attach.Relation{Name: BackendUserAvatarField, Public: true}`, with Many false).
2. **Lookup** (account.go):
- Exported `const AvatarMaxBytes int64 = 2 << 20`.
- Exported `func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error)`. It returns the newest `system_files` row (attachment_type BackendUserMorphType, field avatar, attachment_id the decimal id, ordered by id DESC), or nil, nil when there is none. Plugins can call it to show an author's picture.
- Extend `profileFor` to set `Avatar *AdminAvatar` (admin_openapi.go: `AdminAvatar{URL string json:"url"; ThumbURL string json:"thumb_url,omitempty"}`, with tag `json:"avatar,omitempty"` on AdminProfile).
- `URL` is `File.URL()`.
- `ThumbURL` is `File.Thumb(ctx, bucket, 250, 250, "crop")`, only when `s.bucket()` is not nil and the content type is in `attach.AllowedImageMIMEs`.
- A thumbnail error is logged at warn level and leaves thumb_url out. A lookup error is logged and leaves the avatar out, so /auth/me never fails on it.
3. **POST /account/avatar** (`(s *service) avatarUpload`, mounted as `g.Post("/account/avatar", requireAjax(s.avatarUpload))`):
- The principal is resolved as in Task 1. A nil bucket answers 500 and is logged.
- The cap is `AvatarMaxBytes + multipartOverhead`, lowered to `s.uploadBytes` when that is set and smaller.
- Read the body as `fileUploadOn` does. Require exactly one part named `file_data` with a non-blank file name, otherwise answer 422 `invalidBody()`. A trailing part is refused.
- Inside `lagoon.Transaction`:
- lock the user row FOR UPDATE by `principal.ID` (this serialises concurrent avatar changes);
- call `attach.Store` with `attach.Upload{FileName: part.FileName(), Body: part, Public: true}` and `attach.Limits{MaxBytes: AvatarMaxBytes, Extensions: attach.DefaultImageExtensions, MIMETypes: attach.AllowedImageMIMEs, Image: true}`;
- set attachment_id, attachment_type (BackendUserMorphType) and field (avatar) on the stored row;
- load the previous avatar rows FOR UPDATE and delete them, collecting `attach.BlobKeys` of each.
- After the commit, call `attach.DeleteKeys(context.WithoutCancel(ctx), bucket, oldKeys)`, logging a failure without failing the request.
- On a rolled-back transaction, delete the new blob's keys.
- Map errors as `writeFileError` does, with the field name `avatar`:
- ErrTooLarge: `max.file` with the KB value of AvatarMaxBytes.
- ErrFileType and ErrMIMEType: `mimes` listing the default image extensions.
- ErrNotImage: `image`.
- MaxBytesError: 413.
- Never use the client file name in any key or path. Store generates the disk name.
- Answer 200 with `Envelope[AdminProfile]` from `profileFor`.
4. **DELETE /account/avatar** (`(s *service) avatarRemove`, `g.Delete("/account/avatar", requireAjax(s.avatarRemove))`):
- In a transaction, lock the user, then delete every avatar row of the user and collect the blob keys.
- After the commit, delete the blobs.
- Answer 200 with the profile, also when there was no avatar.
5. **OpenAPI:**
- Add the doc functions `AdminAccountAvatarUpload` (`@Accept multipart/form-data`, `@Param file_data formData file true "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)"`, `@Router /account/avatar [post]`, failures 401, 403, 413 and 422) and `AdminAccountAvatarRemove` (`@Router /account/avatar [delete]`).
- Extend the AdminMe description with the avatar.
- Regenerate with `bash scripts/check-admin-openapi.sh`.
- Alias `AdminAvatar` in types.ts.
6. **Inventories:**
- phase09Routes gets `POST /account/avatar` and `DELETE /account/avatar`.
- TestPhase10Coverage `want` gets both lines.
- The CSRF count 27 becomes 29.
- Two conformance cases before logout: an upload of `conformPNG(t)` (200, `cabana.Envelope-cabana_AdminProfile`; assert avatar.url is set), then a delete (200).
7. **Lang:** add these keys to both catalogs:
- avatar
- avatar_upload
- avatar_remove
- avatar_remove_confirm
- avatar_hint (JPG, PNG, GIF or WebP, up to 2 MB)
- avatar_saved
- avatar_removed
8. **SPA:**
- **AccountView.vue gets an avatar block:**
- It shows the current thumb_url, or url, or the initials.
- A hidden file input with `accept="image/jpeg,image/png,image/gif,image/webp"` sits behind an Upload button.
- The upload goes through `api.POST('/account/avatar', ...)` with a `bodySerializer` that returns a FormData holding the single part `file_data`. The typed client's 401 refresh-and-replay then applies. Do not set Content-Type by hand.
- A Remove button confirms through the existing confirm helper in components/ui/confirm.ts, then calls `api.DELETE('/account/avatar')`.
- Both pass the returned profile to `setCurrentUser` and show a toast.
- A 422 shows the `avatar` detail under the block, and a 413 shows the hint.
- **UserMenu.vue:** when `currentUser.avatar` is set, render an `img` (attribute `data-user-avatar`, `alt=""`, rounded-full, object-cover, the same size as the initials circle) in the trigger and in the menu header. Otherwise keep the initials span with `data-user-initials`.
- Update the profile fixtures in admin/tests/helpers.ts if typecheck needs it.
- Rebuild the dist.
9. **Smoke test:** add `TestAccountAvatarSmoke` to account_test.go, with a local helper that builds the handler like `adminHandler` but also publishes a memblob bucket through `attach.Publish` and sets upload_bytes to 8 MiB. Uploading a PNG makes GET /auth/me return avatar.url and thumb_url, and DELETE removes the avatar.
10. **Docs, in this commit:**
- **cabana README:**
- the POST and DELETE `/account/avatar` rows;
- the /auth/me avatar;
- in the API reference: `cabana.AdminAvatar`, `cabana.AvatarMaxBytes`, `cabana.BackendUserAvatar`, `cabana.BackendUserMorphType`, `cabana.BackendUserAvatarField`, `cabana.BackendUser.MorphName` and `cabana.BackendUser.AttachRelations`.
- **users-and-permissions.md My account section:**
- avatar storage (a public `system_files` attachOne, compatible with WinterCMS rows);
- the type and size limits;
- replace and remove semantics;
- that plugins read the picture through `cabana.BackendUserAvatar` and the subtitle through `cabana.BackendUser.Subtitle`.
11. **Commit** with subject `feat(cabana): add backend user avatars to My account and the top bar`.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && out=$(go test ./modules/cabana -count=1 -v -run '^(TestAccountAvatarSmoke|TestAccountProfileSmoke|TestPhase10OpenAPIConformance|TestPhase09PermissionMatrix|TestPhase09SecurityCoverage|TestPhase10CSRF|TestPhase10Coverage)$' 2>&1) && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestAccountAvatarSmoke' && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestPhase10OpenAPIConformance' && grep -qF 'requireAjax(s.avatarUpload)' modules/cabana/http.go && grep -qF 'requireAjax(s.avatarRemove)' modules/cabana/http.go && grep -qF 'func BackendUserAvatar(' modules/cabana/account.go && grep -qF 'data-user-avatar' admin/src/components/shell/UserMenu.vue && grep -qF 'cabana.BackendUserAvatar' modules/cabana/README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -qF 'avatar' && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json</automated>
</verify>
<done>
- An admin uploads and removes their avatar on My account.
- The image is validated as a raster image of at most 2 MiB and stored under a server-generated name as a WinterCMS-compatible public attachOne.
- It shows in the top bar and in GET /auth/me (url and thumb_url).
- Replacing it deletes the old row and its blobs.
- Inventories, OpenAPI output, dist, README and docs are in the same commit, and every check is green.
</done>
</task>
<task type="auto">
<name>Task 3: Unit tests: full coverage of the My account profile, password and avatar code (last task, per project rule)</name>
<files>modules/cabana/account_test.go, modules/cabana/account_internal_test.go, modules/lagoon/backend_admin_migrations_test.go, admin/tests/views/AccountView.test.ts, admin/tests/shell/UserMenu.test.ts, admin/tests/state/useAuth.test.ts, admin/tests/fixtures/lang.json</files>
<action>Bring this plan's code to full unit coverage. Change no production code. If a test exposes a bug, fix it in its own `fix(cabana): ...` commit before this one.
1. **modules/cabana/account_internal_test.go** (package cabana), table-driven, no database. It holds two tests, `TestMetadataSubtitleHelpers` and `TestBackendUserAttachContract`:
- `metadataString` and `setMetadataString` over these inputs:
- nil, "", "null" and "[]";
- `{"subtitle":"Editor"}`;
- `{"subtitle":5}` (reads "");
- invalid JSON (reads "", and write returns an error);
- `"text"` and `[1]` (write returns an error);
- `{"subtitle":"a","x":{"y":[1,2]}}`: the write keeps x byte-equal;
- clearing the last key returns nil.
- `BackendUser.Subtitle`.
- `MorphName` and `AttachRelations` values.
2. **modules/cabana/account_test.go** (package cabana_test), extended with subtests under these tests: `TestAccountProfileUpdate`, `TestAccountForbiddenKeys`, `TestAccountEmailRules`, `TestAccountPasswordChange`, `TestAccountAvatarUpload`, `TestAccountAvatarRefusals`, `TestAccountAvatarReplaceAndRemove`, `TestAuthMeProfilePayload`. Cover:
- **Payload:** GET /auth/me returns subtitle "" and no avatar key for a fresh admin, and returns the subtitle of a row whose metadata was written with raw SQL as `{"subtitle":"Editor","x":1}`. A row with metadata `[]` or invalid JSON still signs in and serves /auth/me.
- **Profile update:** names, email and subtitle are saved and trimmed. The x key survives a subtitle change. An empty subtitle removes the key. updated_at advances.
- **Forbidden keys:** each of login, role_id, is_superuser, permissions, is_activated, id, password_hash and metadata answers 422 with that key in the details, and a raw SELECT shows the row unchanged. A second admin's row is never touched: same names, and the target is always the token subject.
- **Email:** missing, invalid and short addresses answer 422. Another admin's email in a different case answers 422, as does another admin's login used as an email. A soft-deleted admin's email answers 422. The caller's own email in upper case is accepted.
- **Password:**
- A missing current_password answers 422 on current_password.
- A wrong one answers 422 and writes an `admin.auth` failed log line (capture slog as TestAdminAuthLogging does). The hash is unchanged.
- A mismatched confirmation answers 422. Under 4 characters answers 422. Over 72 bytes, for example 40 two-byte runes, answers 422 and never 500.
- Success makes `bouncer.CheckPassword` pass with the new password and sets tokens_valid_after.
- Over Bearer, the old token then gets 401 on /auth/me.
- Over cookie transport (`X-Requested-With`, cookie from a cookie login), the response sets a new `cabana.AdminCookieName` cookie that works for /auth/me while the old cookie value gets 401.
- **CSRF:** a cookie PUT without X-Requested-With is refused.
- **Avatar upload** (helper with memblob bucket and upload_bytes 8 MiB):
- A PNG creates exactly one system_files row: attachment_type `Backend\Models\User`, field avatar, attachment_id the id, is_public true.
- Its disk_name contains no slash and does not contain the client name. An upload named `../../evil.png` stores file_name `evil.png` and a key under the partition directory.
- /auth/me shows url and thumb_url.
- **Avatar refusals:**
- SVG bytes named .svg, and HTML bytes named .png, answer 422 on avatar.
- A file over AvatarMaxBytes answers 413 or 422. Assert the status the code defines.
- A wrong part name, no part, and two parts answer 422.
- No bucket answers 500.
- No row is left behind and no blob remains in the bucket for any refusal.
- **Replace and remove:**
- A second upload deletes the first row and its original and thumbnail blobs (`bucket.Exists` is false) and leaves one row.
- DELETE removes the row and the blobs and answers 200 with no avatar key.
- A second DELETE also answers 200.
- `cabana.BackendUserAvatar` returns nil, nil for a user without an avatar.
3. **modules/lagoon/backend_admin_migrations_test.go:** add `TestBackendAdminMetadataColumn`.
- After Migrate, the column exists, is nullable TEXT, and a Winter-shaped INSERT with metadata `{"subtitle":"Editor"}` round-trips.
- RollbackLast on the summercms.cabana history drops only metadata and keeps the row.
- Migrate adds the column back.
- 202610090001_backend_users_metadata is the last history id.
4. **SPA tests:**
- **admin/tests/views/AccountView.test.ts:**
- it renders the profile from currentUser with login read-only;
- PUT sends only the allowed keys, and the password keys only when a new password is typed;
- a 200 updates currentUser and clears the password inputs;
- a 422 shows field errors and focuses the first field;
- an upload posts FormData with file_data, and the profile with the avatar reaches currentUser;
- remove confirms and calls DELETE.
- **admin/tests/shell/UserMenu.test.ts:** the account item has role menuitem and navigates to the account route. The avatar img replaces the initials when avatar is set, and the initials stay without it.
- **admin/tests/state/useAuth.test.ts:** `setCurrentUser`.
- **admin/tests/fixtures/lang.json:** add the new `backend::lang.myaccount.*` keys.
5. **Run everything:**
- Without -short: `go test ./modules/cabana ./modules/lagoon/... -count=1` (Docker, or the context fallback reverted before staging); `go test -short ./... -count=1`; `npm --prefix admin test`.
- Record per-function coverage of modules/cabana/account.go with `go test ./modules/cabana -count=1 -coverprofile` and `go tool cover -func` in the SUMMARY. No function in account.go may be at 0.0%.
- With Docker reachable, also run `go test ./... -count=1`.
6. **Commit** with subject `test(cabana): cover My account profile, password and avatar`.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && d=$(mktemp -d) && out=$(go test ./modules/cabana -count=1 -v -coverprofile="$d/c.out" -run '^(TestAccount|TestAuthMeProfilePayload|TestMetadataSubtitleHelpers|TestBackendUserAttachContract|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase09PermissionMatrix)' 2>&1) && for t in TestMetadataSubtitleHelpers TestBackendUserAttachContract TestAccountProfileUpdate TestAccountForbiddenKeys TestAccountEmailRules TestAccountPasswordChange TestAccountAvatarUpload TestAccountAvatarRefusals TestAccountAvatarReplaceAndRemove TestAuthMeProfilePayload; do printf '%s\n' "$out" | grep -q -- "^--- PASS: $t " || { echo "missing PASS $t"; exit 1; }; done && ! printf '%s\n' "$out" | grep -q -- '^--- FAIL' && cov=$(go tool cover -func="$d/c.out") && printf '%s\n' "$cov" | grep -qF 'cabana/account.go' && test -z "$(printf '%s\n' "$cov" | grep -F 'cabana/account.go' | awk '$NF=="0.0%"')" && mig=$(go test ./modules/lagoon -count=1 -v -run '^TestBackendAdminMetadataColumn$' 2>&1) && printf '%s\n' "$mig" | grep -q -- '^--- PASS: TestBackendAdminMetadataColumn' && npm --prefix admin run typecheck && npm --prefix admin test && test -f admin/tests/views/AccountView.test.ts && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go -C ../sm-journal-plugin vet ./... && go -C ../fonoteka.go vet ./... && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json && changed=$(git diff --name-only --diff-filter=d 5ba1a4a HEAD -- modules docs README.md) && test -n "$changed" && test -z "$(printf '%s\n' "$changed" | xargs -r grep -EIil 'fonoteka|plytarium|płytarium|grzyby')" && test -z "$(git status --porcelain -- modules/cabana/auth_test.go modules/cabana/query_test.go modules/lagoon/postgres_test.go modules/lagoon/attach/lifecycle_test.go)"</automated>
</verify>
<done>
- Every new code path in account.go, contracts.go and the metadata migration is exercised: profile update, forbidden keys, email rules, password change and session rotation, avatar upload, refusals, replace and remove, and the payload.
- SPA tests cover AccountView, the user menu and setCurrentUser.
- No account.go function is at 0% coverage, and the per-function table is in the SUMMARY.
- go vet, go test (full, Docker or the reverted fallback), the SPA tests, the docs checker, the OpenAPI and dist gates, and vet of the dependent repos are green.
- Harness files are unmodified.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin browser (cookie session) → PUT /account, POST and DELETE /account/avatar | An authenticated but untrusted request body and multipart upload cross into the admin API |
| request body → backend_users row | Mass-assignment surface: the body must not reach role, superuser, permissions, activation, login or another admin's row |
| client upload → system_files and blob bucket → public storage URL | Uploaded bytes and the client file name become a stored, publicly served file |
| backend_users.metadata copied from WinterCMS → guard and /auth/me | Legacy JSON text that may be `[]`, blank or malformed |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-mys-01 | Elevation of Privilege | accountUpdate body (role_id, is_superuser, permissions, is_activated, login) | critical | mitigate | Key allow-list; any other key answers 422 and nothing is written. The Updates map is built only from the allow-listed columns. TestAccountForbiddenKeys checks the row is unchanged by raw SELECT. |
| T-mys-02 | Tampering | accountUpdate and avatar routes targeting another admin | high | mitigate | No id is read from the path or body. Every query uses `principal.ID` from the verified token (FOR UPDATE). The test shows a second admin's row stays untouched. |
| T-mys-03 | Spoofing | password change through a stolen session | high | mitigate | current_password must pass `bouncer.CheckPassword`. A failure is logged as admin.auth failed. Success sets tokens_valid_after, which ends every other session. TestAccountPasswordChange covers it. |
| T-mys-04 | Tampering (CSRF) | PUT /account, POST and DELETE /account/avatar over cookie transport | high | mitigate | Each is wrapped in requireAjax. The TestPhase10CSRF walk count rises to 29, and the coverage list includes the routes. |
| T-mys-05 | Tampering (stored XSS) | avatar upload of SVG or HTML disguised as an image | high | mitigate | attach.Store image mode: extension allow-list jpg, jpeg, png, gif and webp; sniffed MIME limited to `attach.AllowedImageMIMEs`; `attach.IsAllowedImage` decode guard. TestAccountAvatarRefusals covers SVG and HTML-as-PNG. |
| T-mys-06 | Tampering (path traversal) | client file name | high | mitigate | The disk name is generated by Store, and the file_name is only a sanitized base name used for display, never in a key. The test uploads `../../evil.png`. |
| T-mys-07 | Denial of Service | oversized avatar body | medium | mitigate | MaxBytesReader at AvatarMaxBytes plus multipart overhead, further capped by http.body_limits.upload_bytes, and Store enforces the limit while streaming. The answer is 413 or 422, and no row or blob is left behind (tested). |
| T-mys-08 | Denial of Service | malformed legacy metadata breaking the guard's user load | high | mitigate | Metadata is a raw `*string`, never a strict JSON scan type, and helpers parse leniently. The test signs in an admin whose metadata is `[]` or invalid JSON. |
| T-mys-09 | Tampering | email set to another admin's email or login (sign-in ambiguity locks both out) | medium | mitigate | Case-insensitive uniqueness against every other admin's email and login, soft-deleted included. A unique-index race maps to 422. TestAccountEmailRules covers it. |
| T-mys-10 | Tampering | avatar replace races and orphaned blobs | low | mitigate | The user row is locked FOR UPDATE. Old rows are deleted in the transaction and their blobs after the commit. A new blob is deleted on rollback. The replace test checks `bucket.Exists`. |
| T-mys-11 | Information Disclosure | public avatar URL | low | accept | WinterCMS parity: avatars are public, and author pages need them. The URL uses an unguessable server-generated disk name. |
| T-mys-12 | Spoofing | brute force of current_password over PUT /account | low | accept | It needs an authenticated, activated session, which already controls the account. Each attempt costs bcrypt and is logged. A login-style throttle would block normal saves (P-09). |
| T-mys-13 | Spoofing | email change without the current password | low | accept | WinterCMS parity (P-05). The framework has no email-based password reset, so the email is not a recovery channel. |
| T-mys-SC | Tampering | npm and Go dependency installs | low | accept | No new packages. The verify gates prove go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged since 5ba1a4a. |
</threat_model>
<verification>
- go vet ./... and go test -short ./... are green at every commit. The cabana and lagoon Postgres suites pass without -short, with Docker or the reverted context fallback. With Docker reachable, go test ./... -count=1 is green.
- `bash scripts/check-admin-openapi.sh --check` and `bash scripts/check-admin-dist.sh` pass after each feature commit.
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass. No consuming-application name appears in changed modules or docs files.
- The admin SPA typecheck and vitest pass. TestPhase10SPAKeysResolve proves every new SPA key resolves in en and pl.
- Manual smoke (optional, recorded in the SUMMARY if done): on a dev build, open the avatar menu, choose My account, change the subtitle, upload a PNG, and check that the top bar shows the image; then change the password and confirm the session stays.
- go -C ../sm-journal-plugin vet ./... and go -C ../fonoteka.go vet ./... still pass against the changed framework.
</verification>
<success_criteria>
- A backend admin can open My account from the avatar menu and change their first name, last name, email, subtitle and avatar, and change their password with the current one, all without touching role, permissions, superuser, activation or login.
- The subtitle and avatar are stored where WinterCMS stores them (`backend_users.metadata.subtitle` and a `Backend\Models\User` avatar attachOne), so a later data copy needs no transform. Plugins read them through `cabana.BackendUser.Subtitle` and `cabana.BackendUserAvatar`.
- GET /auth/me serves subtitle and avatar, and the top bar shows the avatar.
- Three commits (two feature commits, one test commit), with docs in the feature commits and all gates green.
</success_criteria>
<output>
Create `.planning/quick/261009-mys-add-subtitle-and-avatar-to-backend-users/261009-mys-SUMMARY.md` when done. It must contain:
- The three commit SHAs.
- How the Postgres suites ran: Docker, or the fallback with its database names.
- The per-function coverage table of modules/cabana/account.go.
- Decisions P-01 to P-09 as built.
- A section "Todo journal-author-profile-fields". It states that the framework storage decision is made and shipped: `backend_users.metadata` holds the subtitle under `subtitle`, the avatar is an attachOne under `cabana.BackendUserMorphType` and field `avatar`, both are edited on My account, and plugins read them with `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`. It also lists what stays open:
1. The author description has no editor yet. It would fit `metadata.description` the same way.
2. sm-journal-plugin must add the subtitle and avatar keys to `authorProfile` and extend `TestJournalAuthorProfile`.
3. The author import should fill `metadata` and copy the avatar `system_files` rows.
The orchestrator updates the todo file. The executor does not edit `.planning/todos`.
</output>

View File

@@ -0,0 +1,293 @@
---
phase: quick-261009-mys
plan: 01
subsystem: cabana (admin API and SPA), lagoon (backend admin migrations)
tags: [admin, my-account, backend-users, avatar, subtitle, wintercms-parity]
status: complete
requires:
- lagoon.BackendAdminMigrations (backend_users)
- lagoon/attach (Store, File, BlobKeys, DeleteKeys)
- bouncer (MintAudience, CheckPassword, HashPassword, blacklist)
provides:
- backend_users.metadata column (202610090001_backend_users_metadata)
- cabana.BackendUser.Subtitle / Metadata
- cabana.BackendUserAvatar, cabana.BackendUserMorphType, cabana.BackendUserAvatarField, cabana.AvatarMaxBytes
- PUT /account, POST and DELETE /account/avatar
- GET /auth/me subtitle and avatar
- SPA My account screen (/account) and avatar in the user menu
affects:
- sm-journal-plugin authorProfile (still open, see the todo section)
tech-stack:
added: []
patterns:
- Winter jsonable column kept as raw *string and parsed leniently
- key allow-list on a self-service PUT, with the row chosen by the token subject
- attachOne through attach.Store with blobs deleted after the commit
key-files:
created:
- modules/cabana/account.go
- modules/cabana/account_test.go
- modules/cabana/account_internal_test.go
- admin/src/views/AccountView.vue
- admin/tests/views/AccountView.test.ts
modified:
- modules/lagoon/backend_admin_migrations.go
- modules/lagoon/backend_admin_migrations_test.go
- modules/lagoon/README.md
- modules/cabana/contracts.go
- modules/cabana/auth.go
- modules/cabana/http.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/cabana/security_coverage_test.go
- modules/cabana/phase10_coverage_test.go
- modules/cabana/phase10_csrf_test.go
- modules/cabana/openapi_conformance_test.go
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/state/useAuth.ts
- admin/src/app/router.ts
- admin/src/app/pageTitle.ts
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/components/shell/UserMenu.vue
- admin/tests/helpers.ts
- admin/tests/fixtures/lang.json
- admin/tests/shell/UserMenu.test.ts
- admin/tests/state/useAuth.test.ts
- modules/boardwalk/dist (rebuilt)
- docs/backend/users-and-permissions.md
- docs/backend/admin-spa.md
decisions:
- "P-01: the subtitle is stored in backend_users.metadata under the subtitle key, not in its own column; unknown metadata keys are kept"
- "P-02: the avatar is a public system_files attachOne under the WinterCMS morph name Backend\\Models\\User, field avatar"
- "P-03: the routes are PUT /account, POST /account/avatar and DELETE /account/avatar; the profile is read from GET /auth/me"
- "P-04: login is read-only on My account"
- "P-05: an email change needs no current password; a password change does"
- "P-06: a password change sets tokens_valid_after and gives a cookie session a fresh cookie; it also blacklists the token that made the request"
- "P-07: cabana.AvatarMaxBytes is a fixed 2 MiB with no new config key"
- "P-08: passwords need at least 4 characters and at most 72 bytes"
- "P-09: PUT /account has no throttle; failed current-password checks are logged as admin.auth failed"
metrics:
duration: "about 45 min"
completed: 2026-10-09
tasks: 3
files: 37
estimate:
tokens: 170000
tasks: 3
actuals:
tokens: 48600
tasks: 3
commits: 6
plan_head_before: 5ba1a4ae1dc87f705ac9e6ae00564d3bb09d094f
plan_head_after: 89611eaaf92c56104a25c0c96fd5bf60dcfa0194
---
# Phase quick-261009-mys Plan 01: Backend user subtitle, avatar and My account Summary
Backend administrators now have a My account page, opened from the avatar menu. On it they edit their own names, email and subtitle, change their password with the current one, and upload or remove an avatar. The subtitle is stored in WinterCMS's `backend_users.metadata.subtitle` and the avatar is a WinterCMS-compatible `Backend\Models\User` attachOne, so copied WinterCMS rows need no transform.
## Commits (summercms.go, branch master)
| Task | Commit | Subject |
|------|--------|---------|
| 1 | `143eca5` | feat(cabana): add the My account page with subtitle and password change |
| 2 | `64d21c8` | feat(cabana): add backend user avatars to My account and the top bar |
| 3 (bug found by its tests) | `4f1cda3` | fix(cabana): answer an empty or malformed avatar upload body with 422 |
| 3 | `89611ea` | test(cabana): cover My account profile, password and avatar |
The ledger counts 6 commits between `5ba1a4a` and `89611ea`, but only 4 are from this plan. Two planning commits from a concurrent session landed in between and are not part of this plan: `02936b1` docs(17) and `a03302e` docs(state). No commit carries a co-author trailer. go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged since `5ba1a4a`.
## What was built
- **Migration.** `202610090001_backend_users_metadata` is the last entry of `lagoon.BackendAdminMigrations`. It adds `metadata TEXT` (nullable), and its rollback drops only that column.
- **Model.**
- `cabana.BackendUser.Metadata` is a raw `*string`.
- `Subtitle()` reads the subtitle leniently: NULL, blank, `null`, `[]`, invalid JSON and non-string values all read as "".
- `MorphName()` and `AttachRelations()` declare the avatar attachOne.
- The constants are `BackendUserMorphType` and `BackendUserAvatarField`.
- **`GET /auth/me`.** Returns `subtitle` always, and `avatar {url, thumb_url}` (a 250x250 crop) when the admin has a public avatar. Both `me` and the account routes build the payload through `service.profileFor`.
- **`PUT /account`.**
- The key allow-list is enforced on the server. Any other key is a 422 on that key, with the translated `backend::lang.myaccount.field_not_editable` message, and nothing is written.
- Field rules: `first_name` and `last_name` are `nullable|string|max:255`; `email` is `required|string|email|between:6,255`; `subtitle` is `nullable|string|max:255`; `password` is `min:4|max:72`.
- Checks in Go: the 72-byte limit, the confirmation, the current password (a failure is logged as `admin.auth failed`), and email uniqueness against other admins' emails and logins, ignoring case and including soft-deleted admins. A 23505 on the email index also answers 422.
- The row is locked FOR UPDATE by the principal id.
- Saving keeps every other metadata key, and an empty subtitle removes its key.
- **`POST /account/avatar`.**
- Takes one `file_data` part through `attach.Store`, in image mode, with the default image extensions and `AllowedImageMIMEs`, up to `AvatarMaxBytes`.
- The body cap is `AvatarMaxBytes` plus 64 KiB, lowered to `upload_bytes` when that is smaller.
- The previous avatar rows are deleted in the same transaction and their blobs after the commit.
- If the transaction rolls back, the new blob is deleted.
- **`DELETE /account/avatar`.** Idempotent. It deletes the rows now and the blobs after the commit.
- **`cabana.BackendUserAvatar(ctx, db, userID)`.** Lets plugins read the picture.
- **SPA.**
- The user menu has a My account item (`data-action="account"`) that opens `/account` (AccountView).
- The avatar image (`data-user-avatar`) replaces the initials in the menu trigger and the menu header.
- New exports in `useAuth.ts`: `setCurrentUser` and `profileInitials`.
- Breadcrumbs and the page title name the page.
- **Inventories and generated files.**
- `phase09Routes` and the TestPhase10Coverage list include the three routes.
- The CSRF walk count went from 26 to 29.
- There are three conformance cases, all placed before logout. The PUT case does not change the password.
- admin.json, schema.d.ts and the dist build were regenerated in each feature commit.
- **Docs** (written in the same commits as the code):
- cabana and lagoon READMEs;
- `docs/backend/users-and-permissions.md` (`## My account`);
- `docs/backend/admin-spa.md`.
- They name no consuming application.
## Test results
**Docker was not reachable.** User golem cannot open `/var/run/docker.sock`, so I used the plan's fallback:
- I started a throwaway PostgreSQL 18.6 cluster with `initdb` in the session scratchpad, listening on 127.0.0.1:55493 with no unix socket.
- I made temporary, uncommitted `SUMMER_TEST_PG_DSN` overrides in four files:
- `adminGorm` (modules/cabana/auth_test.go);
- `newListService`'s container starter (modules/cabana/query_test.go);
- `startLagoonPostgres` (modules/lagoon/postgres_test.go);
- `attachGorm` (modules/lagoon/attach/lifecycle_test.go). This one creates a fresh `attach_<nanos>` database per call, because each attach test expects an empty database.
- Each full run used a fresh database: `t1full` (Task 1), `t2full` (Task 2), and `t3full` (coverage) plus `t3final` (acceptance) for Task 3. Smaller runs used `base1`, `t1run1`, `t2run1`, `t3run1` and `t3run2`.
- I reverted the four harness files with `git checkout --` before every `git add`. `git status` on them is clean, and I stopped the cluster at the end.
| Gate | Result |
|------|--------|
| `go test ./modules/cabana ./modules/lagoon/... -count=1 -v` (no `-short`, fallback DB `t3final`) | cabana, lagoon and lagoon/attach all ok: 442 `--- PASS`, 0 SKIP, 0 FAIL |
| New tests | PASS: TestMetadataSubtitleHelpers, TestBackendUserAttachContract, TestAccountRoutesRefuseNonBackendPrincipals, TestAccountProfileSmoke, TestAccountAvatarSmoke, TestAuthMeProfilePayload, TestAccountProfileUpdate, TestAccountForbiddenKeys, TestAccountEmailRules, TestAccountPasswordChange, TestAccountAvatarUpload, TestAccountAvatarRefusals, TestAccountAvatarReplaceAndRemove, TestBackendAdminMetadataColumn |
| Inventory tests | PASS: TestPhase10OpenAPIConformance, TestPhase10CSRF (29), TestPhase10Coverage, TestPhase09PermissionMatrix, TestPhase09SecurityCoverage, TestAdminAuthLifecycle, TestPhase09MigrationsFreshRollback, TestBackendAdmin* |
| `go vet ./...` | green at every commit |
| gofmt on every changed `.go` file | clean. The plan's whole-tree `gofmt -l modules cmd internal` already fails at base on six unrelated files (deferred-items.md 1). |
| `go test -short ./... -count=1` | Every package passes except the ones that start testcontainers even under `-short`: beachcomber, cabana, conga, lagoon and lighthouse. All their failures are Docker-provider errors, and the same failures happen at base (deferred-items.md 2). One run also hit a tide centrifugo timeout, which passed when re-run alone (deferred-items.md 3). The cabana and lagoon packages pass in full against the fallback database (above). beachcomber, conga and lighthouse were not run against Postgres. This task does not touch them. |
| `bash scripts/check-admin-openapi.sh --check` | OK after each feature commit and at the end |
| `npm --prefix admin run typecheck` | green |
| `npm --prefix admin test` | 75 files, 1104 tests passed |
| `bash scripts/check-admin-dist.sh` | dist matches a fresh build |
| `go test ./cmd/summer -run TestDocsTree`, `go run ./cmd/summer docs:build --check` | ok, no problems found |
| `go -C ../sm-journal-plugin vet ./...`, `go -C ../fonoteka.go vet ./...` | both green |
| Consuming-application names in changed modules/docs files | none |
| `go test ./... -count=1` with Docker | **not run.** Docker is unreachable. Re-run it on a machine with Docker before release. |
**RED evidence for the fix commit.** Before `4f1cda3`, `TestAccountAvatarRefusals/no_part` and `/malformed_bodies` both got 500 `{"code":"error"}`. After the fix, both answer 422 on `body`.
### Coverage of modules/cabana/account.go
Run: `go test ./modules/cabana -count=1 -coverprofile` (fallback DB `t3full`), then `go tool cover -func`. Package coverage is 87.1%. No function is at 0.0%.
| Function | Coverage |
|----------|----------|
| `decodeMetadata` | 100.0% |
| `metadataString` | 100.0% |
| `setMetadataString` | 87.5% |
| `profileFor` | 100.0% |
| `avatarOf` | 76.9% |
| `BackendUserAvatar` | 81.8% |
| `accountPrincipal` | 100.0% |
| `lockAccount` | 66.7% |
| `backendMessage` | 100.0% |
| `accountMessage` | 100.0% |
| `accountString` | 100.0% |
| `accountUpdate` | 91.3% |
| `revokePresentedToken` | 63.6% |
| `refusedAccountKeys` | 100.0% |
| `accountDetails` | 100.0% |
| `emailTaken` | 80.0% |
| `emailConflict` | 100.0% |
| `writeAccountError` | 62.5% |
| `avatarCap` | 100.0% |
| `detachAvatars` | 91.7% |
| `deleteAvatarBlobs` | 87.5% |
| `avatarUpload` | 93.0% |
| `avatarRemove` | 83.3% |
| `multipartFailure` | 75.0% |
| `writeAvatarError` | 88.2% |
The branches left uncovered are database, storage and token failure paths. Examples are a lock query error, a blacklist write error, a thumbnail storage error, and a deactivated admin who still gets past the guard. The guard refuses deactivated admins before the handler runs.
## Decisions P-01 to P-09 as built
- **P-01:** The subtitle is stored under `metadata.subtitle`, and every other key is kept. Stored text that is not empty, `[]` or a JSON object is never overwritten. Saving a different subtitle onto such text answers 422 on `subtitle` (`myaccount.subtitle_unavailable`). A save that leaves the subtitle unchanged still succeeds and leaves the text as it is.
- **P-02:** The avatar is a public attachOne with type `Backend\Models\User` and field `avatar`. A copied WinterCMS row is picked up as it is (tested). A protected row (`is_public` false) is left out of the profile, because it has no public URL.
- **P-03:** The routes are `PUT /account`, `POST /account/avatar` and `DELETE /account/avatar`. There is no GET /account.
- **P-04:** Login is read-only. A `login` key answers 422.
- **P-05:** An email change needs no current password. A password change does.
- **P-06:** A password change sets `tokens_valid_after` to the start of the current second. A cookie session gets a fresh cookie and stays signed in, and a Bearer client must sign in again. In addition, the presented token is blacklisted (see deviation 2).
- **P-07:** `AvatarMaxBytes` is 2 MiB with no config key, and `upload_bytes` still caps the request. A file over 2 MiB answers 422 (`max.file`, 2048 KB). A body over the request cap answers 413.
- **P-08:** Passwords need at least 4 characters and at most 72 bytes. 40 two-byte runes answer 422, never 500.
- **P-09:** There is no throttle. A wrong current password is logged as `admin.auth failed` with the admin id and no secrets (tested).
## Todo journal-author-profile-fields
**Closed: the framework storage decision is made and shipped.**
- `backend_users.metadata` holds the subtitle under the `subtitle` key, as WinterCMS does.
- The avatar is a public system_files attachOne under `cabana.BackendUserMorphType` (`Backend\Models\User`) and field `avatar` (`cabana.BackendUserAvatarField`).
- Both are edited on My account.
- Plugins read them with `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`.
**Still open:**
1. The author description has no editor yet. It would fit `metadata.description` in the same way, through the same metadata helpers.
2. sm-journal-plugin must add the subtitle and avatar keys to `authorProfile` and extend `TestJournalAuthorProfile`.
3. The author import should fill `metadata` and copy the avatar `system_files` rows.
I did not edit `.planning/todos`; the orchestrator updates the todo file.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] An empty or malformed avatar upload body answered 500**
- **Found during:** Task 3 (TestAccountAvatarRefusals).
- **Issue:** A multipart body with no part (`NextPart` returns `io.EOF`) or with a broken boundary fell through `writeAvatarError` to the generic 500. The plan requires 422 for "no part".
- **Fix:** `multipartFailure` maps any multipart read error that is not a `MaxBytesError` to the invalid-body 422, both for the first part and for the trailing-part check.
- **Files modified:** modules/cabana/account.go.
- **Commit:** `4f1cda3`. Committed on its own before the test commit, as the plan asks.
**2. [Rule 2 - Security] A password change also blacklists the token that made the request**
- **Found during:** Task 1.
- **Issue:** JWT `iat` has whole-second precision, so the `tokens_valid_after` cutoff cannot end a token issued in the same second as the change. That includes the token that changed the password.
- **Fix:** After the commit, `revokePresentedToken` adds the presented token's jti to the backend blacklist, as logout does. The old cookie or Bearer token is therefore refused at once. Other sessions issued in an earlier second are cut off by `tokens_valid_after`.
- **Files modified:** modules/cabana/account.go.
- **Commit:** `143eca5`.
**3. [Rule 3 - Blocking] Two existing lagoon migration tests assumed the old last entry**
- **Found during:** Task 1.
- **Issue and fix:**
- `TestBackendAdminMigration`'s column list gained `metadata`.
- `TestBackendAdminEmailIndexRefusesCaseDuplicates` rolled back the email index with `RollbackLast`, which now drops metadata instead. It now uses `RollbackTo("202609240001_backend_admin_identity")`.
- **Files modified:** modules/lagoon/backend_admin_migrations_test.go.
- **Commit:** `143eca5`.
**4. [Rule 3 - Blocking] The fallback harness needed a fresh database per attach test**
- **Found during:** the Task 1 full run.
- **Issue:** `TestStore/row_failure_deletes_the_blob` expects `system_files` not to exist yet. testcontainers gives every `attachGorm` call a new database, but a single shared fallback DSN does not.
- **Fix:** The temporary `attachGorm` override creates `attach_<nanos>` per call. This was a harness-only change and was never committed.
**5. Small additions beyond the plan text**
- `profileInitials` is exported from `useAuth.ts` and shared by UserMenu and AccountView.
- A `myaccount.subtitle_unavailable` lang key exists for metadata that is not a JSON object.
- `TestAccountRoutesRefuseNonBackendPrincipals` is a third internal test. It checks that all three handlers answer 401 for no principal, a frontend principal or a zero id, without reading the body.
- The confirmation mismatch is reported on `password`, which matches Laravel's `confirmed` rule.
- The test `accountHandler` raises `admin.login.max_attempts`, because these tests sign many admins in from one address.
- `admin-spa.md` also mentions the avatar.
### Deferred (out of scope, pre-existing)
See `deferred-items.md`:
- the whole-tree gofmt failures at base;
- `-short` still starting testcontainers;
- the tide centrifugo flake under load.
## Known Stubs
None. Every field on the screen is wired to the API, and every new route is wired to storage.
## Threat Flags
None beyond the plan's threat model. T-mys-01 to T-mys-10 are mitigated, and each has the test the register names. T-mys-11 to T-mys-13 and T-mys-SC are accepted as planned.
## Self-Check: PASSED
- FOUND: modules/cabana/account.go, modules/cabana/account_test.go, modules/cabana/account_internal_test.go, admin/src/views/AccountView.vue, admin/tests/views/AccountView.test.ts.
- FOUND: commits `143eca5`, `64d21c8`, `4f1cda3` and `89611ea` on master.
- The harness files are unmodified and the Postgres cluster is stopped.

View File

@@ -0,0 +1,7 @@
# Deferred items (quick 261009-mys)
These problems were already there before this task started, in files the task did not change. They are logged here and were not fixed.
1. **`gofmt -l modules cmd internal` is not clean at base `5ba1a4a`.** Six files are unformatted: `modules/compass/config_test.go`, `modules/compass/persist_test.go`, `modules/party/registry_test.go`, `modules/tide/flow_test.go`, `modules/tide/headers_test.go`, `modules/wristband/server.go` and `internal/build/registry.go`. gofmt also reports a parse error on the intentional testdata file `internal/docsite/testdata/violations/snippet-unparsable-go/lib/lib.go`. As a result, the plan's `test -z "$(gofmt -l modules cmd internal)"` gate cannot pass at base. Every file this task changed is gofmt-clean.
2. **`go test -short ./...` still starts testcontainers.** The `TestMain` `testShort()` helpers in lagoon, conga, lighthouse and beachcomber look for the literal argument `-test.short`, but `go test -short` passes `-test.short=true`, so the helpers miss it. Many cabana tests also never check `testing.Short()`, including those built on `newListService`, `hookFixture` and `TestPhase09SecurityCoverage`'s hook subtests. Without Docker, these packages fail on the Docker provider error even under `-short`. The same failures happen at base `5ba1a4a`; I checked this in a scratch worktree.
3. **`modules/tide` centrifugo replay is flaky under load.** `centrifugo_test.go:223` (`context deadline exceeded`) failed once during a parallel `go test -short ./...` and passed when re-run alone.

View File

@@ -15,3 +15,12 @@ The WinterCMS author component (`journalAuthor`) shows `metadata.subtitle`, and
## Solution
Make a framework decision on backend user profile storage first. Either add a `metadata` JSON column plus an avatar attachment on backend users (in `modules/lagoon/backend_admin_migrations.go` and the admin user form), or give the journal plugin its own author profile table edited in the admin. Then add the new keys to the author payload additively (`authorProfile` in `controllers/api/authors.go`), document them in the plugin README, and extend `TestJournalAuthorProfile`'s exact key set. Never emit a key without a storage source.
## Progress (2026-10-09, quick task 261009-mys)
The framework decision is made and shipped (commits 143eca5, 64d21c8, 4f1cda3, 89611ea): `backend_users.metadata` holds the subtitle under `subtitle` (WinterCMS-compatible), and the avatar is a WinterCMS-style `Backend\Models\User` `avatar` attachOne, both edited on the My account page. Plugins read them via `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`.
Still open:
- an editor for the author description (would fit `metadata.description`);
- `subtitle` and `avatar` keys in sm-journal-plugin's `authorProfile`, README and `TestJournalAuthorProfile`;
- the author import filling `metadata` and copying avatar file rows.