Files
summercms/admin/src/components/shell/UserMenu.vue
Jakub Zych 64d21c8caf 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
2026-10-09 17:45:43 +02:00

127 lines
4.8 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
import { t } from '../../app/i18n'
import { currentUser, logout, profileInitials } from '../../state/useAuth'
// 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(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
/** First and last name initials, else the login's first letters. */
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 ?? '')
async function onAccount(): Promise<void> {
await router.push({ name: 'account' }).catch(() => undefined)
}
async function onLogout(): Promise<void> {
await logout(router)
}
</script>
<template>
<!-- The menu stays mounted when the user clears (logout); the shell itself
unmounts on the login route. -->
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger
data-user-menu
:disabled="!currentUser"
: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
>{{ initials }}</span
>
<span class="flex flex-col leading-tight max-[1099px]:hidden" data-user-identity>
<span class="font-semibold">{{ displayName }}</span>
<span v-if="roleName" class="text-[12px] text-muted" data-user-role>{{ roleName }}</span>
</span>
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
data-user-menu-content
align="end"
:side-offset="8"
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
>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-bold">{{ displayName }}</span>
<span class="truncate text-[13px] text-muted">{{ currentUser?.email }}</span>
</span>
</div>
<DropdownMenuItem
data-action="account"
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
@select="onAccount"
>
<UserRound :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.myaccount.menu_label') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem
data-action="logout"
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
@select="onLogout"
>
<LogOut :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.auth.logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>