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:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user