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:
134
admin/src/api/schema.d.ts
vendored
134
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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<void> {
|
||||
: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"
|
||||
>
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
data-user-avatar
|
||||
class="size-[34px] shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
data-user-initials
|
||||
@@ -86,7 +85,15 @@ async function onLogout(): Promise<void> {
|
||||
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-2 py-2">
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
data-user-avatar
|
||||
class="size-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
>{{ initials }}</span
|
||||
|
||||
@@ -54,6 +54,21 @@ export async function me(): Promise<AdminProfile | null> {
|
||||
return user.value
|
||||
}
|
||||
|
||||
/** First and last name initials, else the login's first two letters. */
|
||||
export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_name' | 'login'> | null): string {
|
||||
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()
|
||||
}
|
||||
|
||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||
export function setCurrentUser(profile: AdminProfile): void {
|
||||
user.value = profile
|
||||
|
||||
@@ -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