- 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
271 lines
9.8 KiB
Vue
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>
|