feat(cabana): add backend user avatars to My account and the top bar

- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
  cabana.BackendUserMorphType) with one public attachOne avatar
  (cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
  webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
  server-generated name) and replaces the previous avatar: old rows are
  deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
  (250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
  inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
  menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
  admin-spa docs
This commit is contained in:
2026-10-09 17:45:43 +02:00
parent 143eca5a1f
commit 64d21c8caf
25 changed files with 1012 additions and 49 deletions

View File

@@ -366,6 +366,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", "avatar.png")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(conformPNG(t)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
rec := e.sendWith(t, http.MethodPost, "/account/avatar", buf.Bytes(), mw.FormDataContentType(), nil)
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar == nil || body.Data.Avatar.URL == "" {
t.Fatalf("avatar upload body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"DELETE /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.sendWith(t, http.MethodDelete, "/account/avatar", nil, "", nil)
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar != nil {
t.Fatalf("avatar remove body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},