feat(cabana): add the My account page with subtitle and password change

- lagoon: 202610090001_backend_users_metadata adds WinterCMS's nullable
  jsonable backend_users.metadata column (TEXT); rollback drops only it
- cabana.BackendUser.Metadata and Subtitle read metadata.subtitle leniently
  (NULL, blank, null, [], invalid JSON and non-strings read as empty)
- GET /auth/me returns subtitle; PUT /account edits the signed-in admin's
  own names, email and subtitle, and the password with current_password;
  any other key is a 422 and nothing is written
- email: required, valid, 6-255 chars, unique against other admins' email
  and login ignoring case (soft-deleted included); a unique-index race is 422
- a password change sets tokens_valid_after, revokes the presented token and
  gives a cookie session a fresh cookie
- swag annotation, regenerated admin.json and schema.d.ts; route inventories,
  CSRF walk (27) and OpenAPI conformance learn the route
- SPA: My account item in the user menu opens /account (AccountView), which
  saves through PUT /account and updates the top bar via setCurrentUser
- backend::lang.myaccount strings (en, pl), rebuilt boardwalk dist
- cabana and lagoon READMEs, users-and-permissions and admin-spa docs
This commit is contained in:
2026-10-09 17:38:04 +02:00
parent 5ba1a4ae1d
commit 143eca5a1f
33 changed files with 1191 additions and 47 deletions

View File

@@ -4,6 +4,86 @@
*/
export interface paths {
"/account": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Update my account
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
*/
put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Profile and optional password change */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminAccountRequest"];
};
};
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"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/login": {
parameters: {
query?: never;
@@ -127,7 +207,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Current admin */
/**
* 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.
*/
get: {
parameters: {
query?: never;
@@ -4286,6 +4369,15 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"cabana.AdminAccountRequest": {
current_password?: string;
email: string;
first_name?: string;
last_name?: string;
password?: string;
password_confirmation?: string;
subtitle?: string;
};
"cabana.AdminActionRequest": {
/**
* @description Payload is the widget's own JSON value (the summer-action event's
@@ -4343,6 +4435,8 @@ export interface components {
last_name: string;
login: string;
role?: components["schemas"]["cabana.AdminRoleSummary"];
/** @description Subtitle is backend_users.metadata.subtitle; "" when not set. */
subtitle: string;
};
"cabana.AdminRecord": {
[key: string]: unknown;

View File

@@ -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']
/** Body of PUT /account: the only keys My account may change. */
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type SettingsEntry = Schemas['cabana.SettingsEntry']
export type SettingsResult = Schemas['cabana.SettingsResult']

View File

@@ -15,6 +15,9 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
if (route.name === 'settings') {
return t('backend::lang.settings.title')
}
if (route.name === 'account') {
return t('backend::lang.myaccount.menu_label')
}
if (route.name === 'settings-form') {
return settingsEntry(String(route.params.code ?? ''))?.label ?? String(route.params.code ?? '')
}

View File

@@ -10,6 +10,7 @@ import FormView from '../views/FormView.vue'
import PreviewView from '../views/PreviewView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import AccountView from '../views/AccountView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
@@ -67,6 +68,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
},
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
// My account: the signed-in admin's own profile and password.
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },

View File

@@ -22,6 +22,9 @@ interface Crumb {
const route = useRoute()
const crumbs = computed<Crumb[]>(() => {
if (route.name === 'account') {
return [{ label: t('backend::lang.myaccount.menu_label') }]
}
if (route.name === 'settings' || route.name === 'settings-form') {
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null

View File

@@ -9,14 +9,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import { ChevronDown, LogOut } from '@lucide/vue'
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
import { t } from '../../app/i18n'
import { currentUser, logout } 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 and a Wyloguj item. Logging out always ends on the login screen,
// 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()
@@ -47,6 +47,10 @@ const initials = computed(() => {
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)
}
@@ -92,6 +96,14 @@ async function onLogout(): Promise<void> {
<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"

View File

@@ -54,6 +54,11 @@ export async function me(): Promise<AdminProfile | null> {
return user.value
}
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
export function setCurrentUser(profile: AdminProfile): void {
user.value = profile
}
export function clearUser(): void {
cancelRefresh()
user.value = null
@@ -88,6 +93,7 @@ export function useAuth() {
login,
logout,
me,
setCurrentUser,
clearUser,
}
}

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminAccountRequest } from '../api/types'
import { t } from '../app/i18n'
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 { 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.
const ID_PREFIX = 'account'
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
type PasswordKey = 'current_password' | 'password' | 'password_confirmation'
interface Row<K extends string> {
name: K
label: string
type: 'text' | 'email' | 'password'
autocomplete: string
comment?: string
}
const profileRows: Row<ProfileKey>[] = [
{ name: 'first_name', label: 'backend::lang.myaccount.first_name', type: 'text', autocomplete: 'given-name' },
{ name: 'last_name', label: 'backend::lang.myaccount.last_name', type: 'text', autocomplete: 'family-name' },
{ name: 'email', label: 'backend::lang.myaccount.email', type: 'email', autocomplete: 'email' },
{
name: 'subtitle',
label: 'backend::lang.myaccount.subtitle',
type: 'text',
autocomplete: 'organization-title',
comment: 'backend::lang.myaccount.subtitle_comment',
},
]
const passwordRows: Row<PasswordKey>[] = [
{
name: 'current_password',
label: 'backend::lang.myaccount.current_password',
type: 'password',
autocomplete: 'current-password',
},
{ name: 'password', label: 'backend::lang.myaccount.password', type: 'password', autocomplete: 'new-password' },
{
name: 'password_confirmation',
label: 'backend::lang.myaccount.password_confirmation',
type: 'password',
autocomplete: 'new-password',
},
]
const FIELD_ORDER: string[] = [...profileRows, ...passwordRows].map((row) => row.name)
function initialProfile(): Record<ProfileKey, string> {
const user = currentUser.value
return {
first_name: user?.first_name ?? '',
last_name: user?.last_name ?? '',
email: user?.email ?? '',
subtitle: user?.subtitle ?? '',
}
}
const profile = ref<Record<ProfileKey, string>>(initialProfile())
const passwords = ref<Record<PasswordKey, string>>({ current_password: '', password: '', password_confirmation: '' })
const errors = ref<Record<string, string[]>>({})
const busy = ref(false)
const login = computed(() => currentUser.value?.login ?? '')
function inputId(name: string): string {
return `${ID_PREFIX}-${name}`
}
function describedBy(row: Row<string>): string | undefined {
const ids = [row.comment ? `${inputId(row.name)}-comment` : '', errors.value[row.name] ? `${inputId(row.name)}-error` : '']
const out = ids.filter(Boolean).join(' ')
return out === '' ? undefined : out
}
function clearError(name: string): void {
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
errors.value = next
}
}
function setProfile(name: ProfileKey, value: string): void {
profile.value = { ...profile.value, [name]: value }
clearError(name)
}
function setPassword(name: PasswordKey, value: string): void {
passwords.value = { ...passwords.value, [name]: value }
clearError(name)
}
/** The PUT /account body: the profile keys always, password keys only with a new password. */
function payload(): AdminAccountRequest {
const body: AdminAccountRequest = {
first_name: profile.value.first_name,
last_name: profile.value.last_name,
email: profile.value.email,
subtitle: profile.value.subtitle,
}
if (passwords.value.password !== '') {
body.current_password = passwords.value.current_password
body.password = passwords.value.password
body.password_confirmation = passwords.value.password_confirmation
}
return body
}
async function onSave(): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
const result = await api.PUT('/account', { body: payload() })
if (result.data) {
errors.value = {}
setCurrentUser(result.data.data)
profile.value = initialProfile()
passwords.value = { current_password: '', password: '', password_confirmation: '' }
showToast(t('backend::lang.myaccount.saved'))
return
}
if (result.response.status === 422) {
errors.value = fieldErrors(result.error?.error.details)
const first = FIELD_ORDER.find((name) => errors.value[name])
await nextTick()
if (first) {
focusField(ID_PREFIX, first)
}
return
}
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
</script>
<template>
<section class="flex w-full flex-col gap-5 pb-24" data-account>
<header class="flex min-w-0 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ t('backend::lang.myaccount.menu_label') }}</h1>
<p class="text-muted">{{ t('backend::lang.myaccount.menu_description') }}</p>
</header>
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
<form
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<fieldset class="flex flex-col gap-[18px]">
<legend class="mb-[18px] text-[18px] font-bold">{{ t('backend::lang.myaccount.profile') }}</legend>
<div class="flex min-w-0 flex-col gap-1.5">
<label :for="inputId('login')" class="font-semibold">{{ t('backend::lang.myaccount.login') }}</label>
<input
:id="inputId('login')"
name="login"
type="text"
:value="login"
readonly
data-account-login
:class="controlClass(false)"
class="h-input bg-hover text-muted"
/>
</div>
<div class="grid grid-cols-1 gap-[18px] md:grid-cols-2">
<div
v-for="row in profileRows"
:key="row.name"
class="flex min-w-0 flex-col gap-1.5"
:data-field="row.name"
>
<label :for="inputId(row.name)" class="font-semibold">{{ t(row.label) }}</label>
<input
:id="inputId(row.name)"
:name="row.name"
:type="row.type"
:autocomplete="row.autocomplete"
:value="profile[row.name]"
:aria-invalid="errors[row.name] ? 'true' : undefined"
:aria-describedby="describedBy(row)"
:class="controlClass(!!errors[row.name])"
class="h-input"
@input="setProfile(row.name, ($event.target as HTMLInputElement).value)"
/>
<p v-if="row.comment" :id="`${inputId(row.name)}-comment`" class="text-[13px] text-muted">
{{ t(row.comment) }}
</p>
<div
v-if="errors[row.name]"
:id="`${inputId(row.name)}-error`"
class="flex flex-col gap-0.5 text-[13px] text-danger"
>
<p v-for="(text, index) in errors[row.name]" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</div>
</fieldset>
<fieldset class="flex flex-col gap-[18px]">
<legend class="mb-1.5 text-[18px] font-bold">{{ t('backend::lang.myaccount.password_section') }}</legend>
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.password_comment') }}</p>
<div class="grid grid-cols-1 gap-[18px] md:grid-cols-3">
<div
v-for="row in passwordRows"
:key="row.name"
class="flex min-w-0 flex-col gap-1.5"
:data-field="row.name"
>
<label :for="inputId(row.name)" class="font-semibold">{{ t(row.label) }}</label>
<input
:id="inputId(row.name)"
:name="row.name"
type="password"
:autocomplete="row.autocomplete"
:value="passwords[row.name]"
:aria-invalid="errors[row.name] ? 'true' : undefined"
:aria-describedby="describedBy(row)"
:class="controlClass(!!errors[row.name])"
class="h-input"
@input="setPassword(row.name, ($event.target as HTMLInputElement).value)"
/>
<div
v-if="errors[row.name]"
:id="`${inputId(row.name)}-error`"
class="flex flex-col gap-0.5 text-[13px] text-danger"
>
<p v-for="(text, index) in errors[row.name]" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</div>
</fieldset>
</form>
<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"
>
<Button variant="primary" data-action="save" :disabled="busy" @click="onSave">
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</footer>
</section>
</template>