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

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminAccountRequest } from '../api/types'
import { t } from '../app/i18n'
@@ -8,15 +8,19 @@ import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import { controlClass } from '../components/form/control'
import { fieldErrors, focusField } from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { currentUser, setCurrentUser } from '../state/useAuth'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { currentUser, profileInitials, setCurrentUser } from '../state/useAuth'
import { showToast } from '../state/useToasts'
// My account (WinterCMS Users::myaccount): the signed-in admin edits their
// own first name, last name, email and subtitle, and changes the password
// with the current one. Login is shown read-only. PUT /account takes exactly
// the allowed keys; the password keys are sent only when a new password is
// typed. Every success passes the returned profile to setCurrentUser, so the
// top bar updates without a reload.
// typed. The avatar is uploaded (POST /account/avatar, one file_data part)
// and removed (DELETE /account/avatar, after a confirmation) on its own.
// Every success passes the returned profile to setCurrentUser, so the top
// bar updates without a reload.
const ID_PREFIX = 'account'
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
@@ -78,6 +82,90 @@ const busy = ref(false)
const login = computed(() => currentUser.value?.login ?? '')
// Avatar: the thumbnail, else the image, else the initials.
const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
const avatarInput = ref<HTMLInputElement | null>(null)
const avatarBusy = ref(false)
const avatarErrors = ref<string[]>([])
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
const initials = computed(() => profileInitials(currentUser.value))
const confirm = useConfirm()
function chooseAvatar(): void {
avatarInput.value?.click()
}
async function onAvatarPicked(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || avatarBusy.value) {
return
}
avatarBusy.value = true
avatarErrors.value = []
try {
// The typed client keeps the 401 refresh-and-replay; FormData lets the
// browser set the multipart Content-Type and boundary.
const result = await api.POST('/account/avatar', {
body: { file_data: '' },
bodySerializer: () => {
const form = new FormData()
form.append('file_data', file, file.name)
return form
},
})
if (result.data) {
setCurrentUser(result.data.data)
showToast(t('backend::lang.myaccount.avatar_saved'))
return
}
if (result.response.status === 422) {
const details = fieldErrors(result.error?.error.details)
avatarErrors.value = details.avatar ?? Object.values(details).flat()
return
}
if (result.response.status === 413) {
avatarErrors.value = [t('backend::lang.myaccount.avatar_hint')]
return
}
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
avatarBusy.value = false
}
}
async function onAvatarRemove(): Promise<void> {
if (avatarBusy.value) {
return
}
const confirmed = await confirm.ask({
message: t('backend::lang.myaccount.avatar_remove_confirm'),
confirmLabel: t('backend::lang.myaccount.avatar_remove'),
danger: true,
})
if (!confirmed) {
return
}
avatarBusy.value = true
avatarErrors.value = []
try {
const result = await api.DELETE('/account/avatar')
if (result.data) {
setCurrentUser(result.data.data)
showToast(t('backend::lang.myaccount.avatar_removed'))
return
}
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
avatarBusy.value = false
}
}
function inputId(name: string): string {
return `${ID_PREFIX}-${name}`
}
@@ -164,6 +252,67 @@ async function onSave(): Promise<void> {
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
<div class="flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card" data-account-avatar>
<h2 class="text-[18px] font-bold">{{ t('backend::lang.myaccount.avatar') }}</h2>
<div class="flex flex-wrap items-center gap-5">
<img
v-if="avatarSrc"
:src="avatarSrc"
alt=""
data-account-avatar-image
class="size-20 shrink-0 rounded-full object-cover"
/>
<span
v-else
aria-hidden="true"
data-account-avatar-initials
class="flex size-20 shrink-0 items-center justify-center rounded-full bg-accent text-[26px] font-bold text-on-accent"
>{{ initials }}</span
>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2.5">
<Button
variant="outline"
size="sm"
:icon="ImageUp"
data-action="avatar-upload"
:disabled="avatarBusy"
@click="chooseAvatar"
>
{{ t('backend::lang.myaccount.avatar_upload') }}
</Button>
<Button
v-if="currentUser?.avatar"
variant="danger"
size="sm"
:icon="Trash2"
data-action="avatar-remove"
:disabled="avatarBusy"
@click="onAvatarRemove"
>
{{ t('backend::lang.myaccount.avatar_remove') }}
</Button>
</div>
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.avatar_hint') }}</p>
</div>
<input
ref="avatarInput"
type="file"
name="file_data"
:accept="AVATAR_ACCEPT"
class="hidden"
data-account-avatar-input
@change="onAvatarPicked"
/>
</div>
<div v-if="avatarErrors.length > 0" role="alert" data-avatar-error class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in avatarErrors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
<form
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@@ -259,6 +408,16 @@ async function onSave(): Promise<void> {
</fieldset>
</form>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
:busy="confirm.busy.value"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>