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:
96
admin/src/api/schema.d.ts
vendored
96
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 ?? '')
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
270
admin/src/views/AccountView.vue
Normal file
270
admin/src/views/AccountView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user