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:
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