From 64d21c8cafcba4871f0cfbe41d01a0ab08a1cdd0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 17:45:43 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 154 +++++++++- admin/src/api/schema.d.ts | 134 ++++++++- admin/src/api/types.ts | 2 + admin/src/components/shell/UserMenu.vue | 47 +-- admin/src/state/useAuth.ts | 15 + admin/src/views/AccountView.vue | 167 ++++++++++- docs/backend/admin-spa.md | 2 +- docs/backend/users-and-permissions.md | 3 + .../boardwalk/dist/assets/index-1UJLNPmg.js | 9 - .../boardwalk/dist/assets/index-COkYalVD.css | 1 + .../boardwalk/dist/assets/index-D3quaPLf.js | 9 + .../boardwalk/dist/assets/index-go9AISaH.css | 1 - modules/boardwalk/dist/index.html | 4 +- modules/cabana/README.md | 12 +- modules/cabana/account.go | 283 +++++++++++++++++- modules/cabana/account_test.go | 101 +++++++ modules/cabana/admin_openapi.go | 41 ++- modules/cabana/contracts.go | 20 ++ modules/cabana/http.go | 2 + modules/cabana/openapi_conformance_test.go | 28 ++ modules/cabana/phase10_coverage_test.go | 4 +- modules/cabana/phase10_csrf_test.go | 6 +- modules/cabana/security_coverage_test.go | 2 + modules/phrasebook/backend/lang/en/lang.yaml | 7 + modules/phrasebook/backend/lang/pl/lang.yaml | 7 + 25 files changed, 1012 insertions(+), 49 deletions(-) delete mode 100644 modules/boardwalk/dist/assets/index-1UJLNPmg.js create mode 100644 modules/boardwalk/dist/assets/index-COkYalVD.css create mode 100644 modules/boardwalk/dist/assets/index-D3quaPLf.js delete mode 100644 modules/boardwalk/dist/assets/index-go9AISaH.css diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 8c4a4b0..d5ac07f 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -64,6 +64,20 @@ ], "type": "object" }, + "cabana.AdminAvatar": { + "properties": { + "thumb_url": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, "cabana.AdminFileCaptionRequest": { "properties": { "description": { @@ -158,6 +172,14 @@ }, "cabana.AdminProfile": { "properties": { + "avatar": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.AdminAvatar" + } + ], + "description": "Avatar is present when the admin has a public avatar." + }, "email": { "type": "string" }, @@ -2132,6 +2154,136 @@ ] } }, + "/account/avatar": { + "delete": { + "description": "Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Remove my avatar", + "tags": [ + "admin" + ] + }, + "post": { + "description": "Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\\Models\\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "file_data": { + "description": "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)", + "format": "binary", + "type": "string" + } + }, + "required": [ + "file_data" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Upload my avatar", + "tags": [ + "admin" + ] + } + }, "/auth/login": { "post": { "parameters": [ @@ -2240,7 +2392,7 @@ }, "/auth/me": { "get": { - "description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag and the role.", + "description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.", "responses": { "200": { "content": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 77fac23..aeee6fc 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -84,6 +84,132 @@ export interface paths { patch?: never; trace?: never; }; + "/account/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload my avatar + * @description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** + * Format: binary + * @description Image (jpg, jpeg, png, gif or webp, at most 2 MiB) + */ + file_data: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + /** + * Remove my avatar + * @description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200. + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/login": { parameters: { query?: never; @@ -209,7 +335,7 @@ export interface paths { }; /** * Current admin - * @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role. + * @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url. */ get: { parameters: { @@ -4401,6 +4527,10 @@ export interface components { }; message: string; }; + "cabana.AdminAvatar": { + thumb_url?: string; + url: string; + }; "cabana.AdminFileCaptionRequest": { description?: string; title?: string; @@ -4428,6 +4558,8 @@ export interface components { html: string; }; "cabana.AdminProfile": { + /** @description Avatar is present when the admin has a public avatar. */ + avatar?: components["schemas"]["cabana.AdminAvatar"]; email: string; first_name: string; id: number; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 001a89d..aa0678c 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -11,6 +11,8 @@ export type AdminLoginData = Schemas['cabana.AdminLoginData'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] export type AdminLogoutData = Schemas['cabana.AdminLogoutData'] export type AdminProfile = Schemas['cabana.AdminProfile'] +/** An admin's avatar: the public image URL and its 250x250 thumbnail URL. */ +export type AdminAvatar = Schemas['cabana.AdminAvatar'] /** Body of PUT /account: the only keys My account may change. */ export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest'] export type NavigationEntry = Schemas['cabana.NavigationEntry'] diff --git a/admin/src/components/shell/UserMenu.vue b/admin/src/components/shell/UserMenu.vue index d8ea510..630bbb2 100644 --- a/admin/src/components/shell/UserMenu.vue +++ b/admin/src/components/shell/UserMenu.vue @@ -11,13 +11,14 @@ import { } from 'reka-ui' import { ChevronDown, LogOut, UserRound } from '@lucide/vue' import { t } from '../../app/i18n' -import { currentUser, logout } from '../../state/useAuth' +import { currentUser, logout, profileInitials } from '../../state/useAuth' -// User menu (design: Top header): a 44px button with the initials avatar, -// the name above the role from /auth/me (both hidden at tablet width) and a -// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and -// e-mail, a My account item that opens /account and a Wyloguj item. Logging out always ends on the login screen, -// even when the logout call fails (T-10-23). +// User menu (design: Top header): a 44px button with the avatar image (the +// initials when the admin has none), the name above the role from /auth/me +// (both hidden at tablet width) and a chevron; the 280px menu (Reka +// DropdownMenu, role menu) shows the name and e-mail, a My account item that +// opens /account and a Wyloguj item. Logging out always ends on the login +// screen, even when the logout call fails (T-10-23). const router = useRouter() const displayName = computed(() => { @@ -30,20 +31,10 @@ const displayName = computed(() => { }) /** First and last name initials, else the login's first letters. */ -const initials = computed(() => { - const user = currentUser.value - if (!user) { - return '' - } - const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean) - if (parts.length > 0) { - return parts - .slice(0, 2) - .map((part) => part.charAt(0).toUpperCase()) - .join('') - } - return user.login.slice(0, 2).toUpperCase() -}) +const initials = computed(() => profileInitials(currentUser.value)) + +/** The avatar thumbnail, else the image; '' shows the initials. */ +const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '') const roleName = computed(() => currentUser.value?.role?.name ?? '') @@ -66,7 +57,15 @@ async function onLogout(): Promise { :aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`" class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover" > +