Files
summercms/admin/src/views/AccountView.vue
Jakub Zych 143eca5a1f 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
2026-10-09 17:38:04 +02:00

271 lines
9.8 KiB
Vue

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