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

@@ -1,6 +1,35 @@
{
"components": {
"schemas": {
"cabana.AdminAccountRequest": {
"properties": {
"current_password": {
"type": "string"
},
"email": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"password": {
"type": "string"
},
"password_confirmation": {
"type": "string"
},
"subtitle": {
"type": "string"
}
},
"required": [
"email"
],
"type": "object"
},
"cabana.AdminActionRequest": {
"properties": {
"payload": {
@@ -149,6 +178,10 @@
},
"role": {
"$ref": "#/components/schemas/cabana.AdminRoleSummary"
},
"subtitle": {
"description": "Subtitle is backend_users.metadata.subtitle; \"\" when not set.",
"type": "string"
}
},
"required": [
@@ -157,7 +190,8 @@
"id",
"is_superuser",
"last_name",
"login"
"login",
"subtitle"
],
"type": "object"
},
@@ -2021,6 +2055,83 @@
},
"openapi": "3.0.3",
"paths": {
"/account": {
"put": {
"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.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminAccountRequest"
}
}
},
"description": "Profile and optional password change",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Update my account",
"tags": [
"admin"
]
}
},
"/auth/login": {
"post": {
"parameters": [
@@ -2129,6 +2240,7 @@
},
"/auth/me": {
"get": {
"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.",
"responses": {
"200": {
"content": {

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>

View File

@@ -25,6 +25,7 @@ export const profile = {
email: 'dev@example.test',
first_name: 'Dana',
last_name: 'Dev',
subtitle: '',
is_superuser: false,
role: { id: 2, code: 'developer', name: 'Developer' },
},

View File

@@ -33,6 +33,8 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
## Types from OpenAPI
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:

View File

@@ -74,6 +74,15 @@ Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminB
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
## My account
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body.
- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here, and role, superuser flag, permissions and activation stay with the operator commands below.
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
## Managing administrators
The application binary has two commands for operators:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BFvYYWYJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
<script type="module" crossorigin src="./assets/index-1UJLNPmg.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-go9AISaH.css">
</head>
<body>
<div id="app"></div>

View File

@@ -33,6 +33,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
@@ -45,7 +46,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle`). |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
@@ -212,6 +214,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
| `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. |
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
| `cabana.AdminAccountUpdate` | Swag annotation of the My account route. |
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |

432
modules/cabana/account.go Normal file
View File

@@ -0,0 +1,432 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// metadataSubtitle is the backend_users.metadata key of the admin subtitle,
// the key WinterCMS's Journal author component reads.
const metadataSubtitle = "subtitle"
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
const passwordMaxBytes = 72
// accountKeys are the only keys PUT /account accepts. Any other key (login,
// role_id, is_superuser, permissions, is_activated, id and so on) is refused
// with a 422 and nothing is written.
var accountKeys = []string{
"first_name", "last_name", "email", "subtitle",
"current_password", "password", "password_confirmation",
}
// accountRules are the field rules of PUT /account. The confirmation, the
// current password and the email uniqueness are checked in Go afterwards.
var accountRules = []lagoon.RequestRule{
{Field: "first_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "last_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "email", Rules: lagoon.ParseRules("required|string|email|between:6,255")},
{Field: "subtitle", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "current_password", Rules: lagoon.ParseRules("nullable|string")},
{Field: "password", Rules: lagoon.ParseRules("nullable|string|min:4|max:72")},
{Field: "password_confirmation", Rules: lagoon.ParseRules("nullable|string")},
}
// errAccountGone is a signed-in admin whose row is missing or deactivated.
var errAccountGone = errors.New("cabana: account is missing or deactivated")
// errMetadataNotObject is stored metadata that is neither empty, [] nor a
// JSON object; it is never overwritten.
var errMetadataNotObject = errors.New("cabana: backend user metadata is not a JSON object")
// decodeMetadata parses Winter's jsonable metadata. NULL, blank, null and []
// (PHP's json_encode of an empty array) are an empty object. ok is false for
// text that is not a JSON object.
func decodeMetadata(raw *string) (map[string]json.RawMessage, bool) {
if raw == nil {
return map[string]json.RawMessage{}, true
}
text := strings.TrimSpace(*raw)
if text == "" || text == "null" || text == "[]" {
return map[string]json.RawMessage{}, true
}
var out map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &out); err != nil || out == nil {
return nil, false
}
return out, true
}
// metadataString returns the string value of key in Winter's jsonable
// metadata, or "" when the metadata is empty or malformed or the value is
// not a string.
func metadataString(raw *string, key string) string {
values, ok := decodeMetadata(raw)
if !ok {
return ""
}
encoded, ok := values[key]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(encoded, &s); err != nil {
return ""
}
return s
}
// setMetadataString returns metadata with key set to value, every other key
// kept as stored. An empty value removes the key, and metadata without keys
// is nil (SQL NULL). Stored text that is not empty, [] or a JSON object is
// an error and is never overwritten.
func setMetadataString(raw *string, key, value string) (*string, error) {
values, ok := decodeMetadata(raw)
if !ok {
return nil, errMetadataNotObject
}
if value == "" {
delete(values, key)
} else {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
values[key] = encoded
}
if len(values) == 0 {
return nil, nil
}
out, err := json.Marshal(values)
if err != nil {
return nil, err
}
text := string(out)
return &text, nil
}
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
// the My account routes.
func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Subtitle: user.Subtitle(),
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return profile
}
// accountPrincipal is the signed-in backend principal, or nil after a 401
// was written.
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil
}
return principal
}
// lockAccount loads the admin's own row FOR UPDATE with the role, by the
// principal id only. A missing or deactivated row is errAccountGone.
func lockAccount(ctx context.Context, tx *gorm.DB, id uint) (BackendUser, error) {
var user BackendUser
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Role").Where("id = ?", id).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BackendUser{}, errAccountGone
}
if err != nil {
return BackendUser{}, err
}
if !user.IsActivated {
return BackendUser{}, errAccountGone
}
return user, nil
}
// backendMessage is a backend::lang line in the request locale, or fallback
// when no translator has the key.
func backendMessage(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
full := "backend::lang." + key
if tr != nil && tr.Has(full) {
if s := tr.Get(ctx, full, nil); s != "" && s != full {
return s
}
}
return fallback
}
// accountMessage is a lagoon::validation line for a My account field, with
// Laravel's English text for the rules fieldMessage has no fallback for.
func accountMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
if params == nil {
params = map[string]string{}
}
key := "lagoon::validation." + rule
if tr == nil || !tr.Has(key) {
attr := strings.ReplaceAll(field, "_", " ")
switch rule {
case "confirmed":
return "The " + attr + " confirmation does not match."
case "unique":
return "The " + attr + " has already been taken."
case "max.string":
return "The " + attr + " may not be greater than " + params["max"] + " characters."
}
}
return fieldMessage(ctx, tr, rule, field, params)
}
// accountString is the trimmed string value of key, "" when it is absent,
// null or not a string.
func accountString(body map[string]any, key string) string {
s, _ := body[key].(string)
return strings.TrimSpace(s)
}
// accountUpdate serves PUT /account: the signed-in admin edits their own
// first name, last name, email and subtitle and, with the current password,
// changes the password. The row is chosen by the token subject, never by the
// request. A password change ends every other session (tokens_valid_after)
// and gives a cookie session a fresh cookie.
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
tr := s.translator()
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
// Laravel's TrimStrings: names, email and subtitle are trimmed before
// they are validated; passwords are taken as typed.
for _, key := range []string{"first_name", "last_name", "email", "subtitle"} {
if v, ok := body[key].(string); ok {
body[key] = strings.TrimSpace(v)
}
}
password, _ := body["password"].(string)
changePassword := password != ""
var saved BackendUser
var failedCurrent bool
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
errs, err := lagoon.ValidateRequest(ctx, tx, body, accountRules, tr)
if err != nil {
return err
}
if errs == nil {
errs = map[string][]string{}
}
add := func(field, message string) { errs[field] = append(errs[field], message) }
if changePassword {
if _, failed := errs["password"]; !failed && len(password) > passwordMaxBytes {
add("password", accountMessage(ctx, tr, "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)}))
}
if confirmation, _ := body["password_confirmation"].(string); confirmation != password {
add("password", accountMessage(ctx, tr, "confirmed", "password", nil))
}
current, _ := body["current_password"].(string)
switch {
case len(errs["current_password"]) > 0:
case current == "":
add("current_password", fieldMessage(ctx, tr, "required", "current_password", nil))
case !bouncer.CheckPassword(user.Password, current):
add("current_password", backendMessage(ctx, tr, "myaccount.current_password_invalid", "The current password is incorrect."))
failedCurrent = true
}
}
email := accountString(body, "email")
if len(errs["email"]) == 0 {
taken, err := emailTaken(ctx, tx, user.ID, email)
if err != nil {
return err
}
if taken {
add("email", accountMessage(ctx, tr, "unique", "email", nil))
}
}
now := time.Now().UTC()
updates := map[string]any{
"first_name": accountString(body, "first_name"),
"last_name": accountString(body, "last_name"),
"email": email,
"updated_at": now,
}
if subtitle := accountString(body, "subtitle"); len(errs["subtitle"]) == 0 && subtitle != user.Subtitle() {
metadata, err := setMetadataString(user.Metadata, metadataSubtitle, subtitle)
if err != nil {
add("subtitle", backendMessage(ctx, tr, "myaccount.subtitle_unavailable", "The subtitle cannot be saved because the stored profile metadata is not a JSON object."))
} else {
updates["metadata"] = metadata
}
}
if len(errs) > 0 {
return &ValidationError{Details: accountDetails(errs)}
}
if changePassword {
hash, err := bouncer.HashPassword(s.bcryptCost, password)
if err != nil {
return err
}
updates["password"] = hash
// JWT iat has whole-second precision: the cutoff is the start of
// this second, so the fresh cookie minted after it still passes
// while every token issued in an earlier second is refused.
updates["tokens_valid_after"] = now.Truncate(time.Second)
}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
if emailConflict(err) {
return &ValidationError{Details: fieldDetail("email", accountMessage(ctx, tr, "unique", "email", nil))}
}
return err
}
saved, err = lockAccount(ctx, tx, user.ID)
return err
})
if failedCurrent {
s.logAuth(r, "failed", principal.ID)
}
if err != nil {
s.writeAccountError(w, r, err)
return
}
if changePassword {
s.logAuth(r, "password_changed", principal.ID)
s.revokePresentedToken(r)
if _, fromCookie := sessionToken(r); fromCookie {
token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err)
} else {
s.writeSessionCookie(w, token)
}
}
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// revokePresentedToken blacklists the token the request was made with, so
// the session that changed the password cannot be replayed even within the
// second the tokens_valid_after cutoff cannot separate.
func (s *service) revokePresentedToken(r *http.Request) {
if s.bl == nil {
return
}
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
if err != nil {
return
}
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
if until := exp.Add(time.Minute); until.After(expiresAt) {
expiresAt = until
}
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err)
}
}
// refusedAccountKeys answers one detail per body key PUT /account does not
// accept.
func refusedAccountKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
var details map[string]any
for key := range body {
if slices.Contains(accountKeys, key) {
continue
}
if details == nil {
details = map[string]any{}
}
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")}
}
return details
}
// accountDetails orders each field's messages as lagoon.ErrorKeys ranks the
// fields and returns them as validation details.
func accountDetails(errs map[string][]string) map[string]any {
details := make(map[string]any, len(errs))
for _, key := range lagoon.ErrorKeys(errs, accountRules) {
details[key] = errs[key]
}
return details
}
// emailTaken reports whether email equals, ignoring case, the email or login
// of any other admin, soft-deleted admins included.
func emailTaken(ctx context.Context, tx *gorm.DB, id uint, email string) (bool, error) {
if email == "" {
return false, nil
}
var n int64
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Unscoped().Model(&BackendUser{}).
Where("id <> ? AND (lower(email) = lower(?) OR lower(login) = lower(?))", id, email, email).
Count(&n).Error
return n > 0, err
}
// emailConflict reports a unique violation on a backend_users email index,
// the race the emailTaken check cannot close.
func emailConflict(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
return false
}
return pgErr.ConstraintName == "backend_users_email_lower_unique" || pgErr.ConstraintName == "backend_users_email_key"
}
// writeAccountError maps a My account failure: a missing or deactivated
// admin is 401, a body past the cap 413, validation 422 and anything else a
// logged 500.
func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, errAccountGone) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
var ve *ValidationError
var tooBig *http.MaxBytesError
if !errors.As(err, &ve) && !errors.As(err, &tooBig) {
slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err)
}
writeCRUDError(w, err)
}

View File

@@ -0,0 +1,93 @@
package cabana_test
import (
"encoding/json"
"net/http"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// accountStamp is a per-run suffix for logins and emails, so the shared
// harness database can hold every run's admins side by side.
func accountStamp() string {
return time.Now().Format("150405.000000000")
}
// putAccount sends PUT /account with a Bearer token.
func putAccount(t *testing.T, h http.Handler, token string, body any) *httpResult {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, raw)
return &httpResult{Code: rec.Code, Body: rec.Body.Bytes(), Header: rec.Header()}
}
// httpResult is a recorded response.
type httpResult struct {
Code int
Body []byte
Header http.Header
}
// profileOf decodes an AdminProfile envelope.
func profileOf(t *testing.T, raw []byte) cabana.AdminProfile {
t.Helper()
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("profile json: %v body=%s", err, raw)
}
return body.Data
}
// signIn logs an admin in over Bearer transport and returns the token.
func signIn(t *testing.T, h http.Handler, login string) string {
t.Helper()
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
return accessToken(t, rec.Body.Bytes())
}
func TestAccountProfileSmoke(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
stamp := accountStamp()
login := "acct-smoke-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
res := putAccount(t, h, token, map[string]any{
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace-" + stamp + "@example.test",
"subtitle": "Rear admiral",
})
if res.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", res.Code, res.Body)
}
if got := profileOf(t, res.Body); got.FirstName != "Grace" || got.Subtitle != "Rear admiral" {
t.Fatalf("put profile = %+v", got)
}
me := getAuth(t, h, adminAPI("/auth/me"), token)
if me.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
}
got := profileOf(t, me.Body.Bytes())
if got.FirstName != "Grace" || got.LastName != "Hopper" || got.Email != "grace-"+stamp+"@example.test" || got.Subtitle != "Rear admiral" || got.Login != login {
t.Fatalf("me profile = %+v", got)
}
refused := putAccount(t, h, token, map[string]any{"email": "grace-" + stamp + "@example.test", "is_superuser": true})
if refused.Code != http.StatusUnprocessableEntity {
t.Fatalf("is_superuser status=%d body=%s", refused.Code, refused.Body)
}
var superuser bool
if err := gdb.Raw(`SELECT is_superuser FROM backend_users WHERE login = ?`, login).Scan(&superuser).Error; err != nil || superuser {
t.Fatalf("is_superuser=%v err=%v", superuser, err)
}
}

View File

@@ -99,15 +99,32 @@ type AdminRoleSummary struct {
// AdminProfile is the GET /auth/me payload.
type AdminProfile struct {
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
// Subtitle is backend_users.metadata.subtitle; "" when not set.
Subtitle string `json:"subtitle"`
IsSuperuser bool `json:"is_superuser"`
Role *AdminRoleSummary `json:"role,omitempty"`
}
// AdminAccountRequest is the body of PUT /account. It documents the only keys
// the route accepts: any other key is a 422 on that key and nothing is
// written. PUT replaces the profile, so an absent first_name, last_name or
// subtitle is saved empty. The password keys change the password only when
// password is not empty, and then current_password must match.
type AdminAccountRequest struct {
Email string `json:"email"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
Subtitle *string `json:"subtitle,omitempty"`
CurrentPassword string `json:"current_password,omitempty"`
Password string `json:"password,omitempty"`
PasswordConfirmation string `json:"password_confirmation,omitempty"`
}
// AdminLogin documents POST /auth/login.
//
// @Summary Admin login
@@ -164,6 +181,7 @@ func AdminLogout() {}
// AdminMe documents GET /auth/me.
//
// @Summary 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.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -174,6 +192,23 @@ func AdminLogout() {}
// @Router /auth/me [get]
func AdminMe() {}
// AdminAccountUpdate documents PUT /account.
//
// @Summary 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.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param body body AdminAccountRequest true "Profile and optional password change"
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /account [put]
func AdminAccountUpdate() {}
// AdminNavigation documents GET /navigation.
//
// @Summary Admin navigation

View File

@@ -356,22 +356,7 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
}
func profileOf(user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return profile
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, user), map[string]any{})
}
func bearerToken(r *http.Request) string {

View File

@@ -30,6 +30,9 @@ func (BackendUserRole) TableName() string { return "backend_user_roles" }
// BackendUser is the Winter backend_users row. It is not a frontend user.
// Nullable Winter columns are mapped so a copied row round-trips without a
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
// Metadata is Winter's jsonable metadata column kept as raw text, so a copied
// value that is not a JSON object (NULL, blank or []) still loads; read it
// through Subtitle.
type BackendUser struct {
ID uint `gorm:"column:id;primaryKey"`
FirstName string `gorm:"column:first_name"`
@@ -41,6 +44,7 @@ type BackendUser struct {
PersistCode string `gorm:"column:persist_code"`
ResetPasswordCode string `gorm:"column:reset_password_code"`
Permissions string `gorm:"column:permissions"`
Metadata *string `gorm:"column:metadata"`
IsActivated bool `gorm:"column:is_activated"`
IsSuperuser bool `gorm:"column:is_superuser"`
RoleID *uint `gorm:"column:role_id"`
@@ -55,6 +59,11 @@ type BackendUser struct {
func (BackendUser) TableName() string { return "backend_users" }
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
// all read as an empty subtitle.
func (u BackendUser) Subtitle() string { return metadataString(u.Metadata, metadataSubtitle) }
// Option is a dropdown choice shared with later schema plans.
type Option = pact.Option

View File

@@ -236,6 +236,8 @@ func (s *service) mount(r pact.Router) {
})
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
// My account: the signed-in admin edits their own profile.
g.Put("/account", requireAjax(s.accountUpdate))
g.Get("/navigation", s.navigation)
// Form previews render through RenderMarkdown; nothing is stored.
g.Post("/markdown/preview", requireAjax(s.markdownPreview))

View File

@@ -356,6 +356,16 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"PUT /account", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
// No password: a password change would end the token the later
// cases still use.
rec := e.send(t, http.MethodPut, "/account", map[string]any{"first_name": "Conform", "last_name": "Admin", "email": e.login + "@example.test", "subtitle": "Conformance"}, true)
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.FirstName != "Conform" || body.Data.Subtitle != "Conformance" {
t.Fatalf("account body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},

View File

@@ -109,6 +109,7 @@ func TestPhase10Coverage(t *testing.T) {
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /account",
"PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}",
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
@@ -117,7 +118,7 @@ func TestPhase10Coverage(t *testing.T) {
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n"))
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 27 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.

View File

@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
}
})
}
// refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action,
// refresh, logout, account update, markdown preview, settings put, create, bulk-delete, bulk action,
// widget action, toolbar action, update, delete, record action, link,
// unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove
if unsafe != 26 {
t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order)
if unsafe != 27 {
t.Fatalf("walked %d state-changing routes, want 27: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -39,6 +39,7 @@ var phase09Routes = []adminRoute{
// refreshable token can be revoked) and is therefore not behind the guard.
{key: "POST /auth/logout", public: true},
{key: "GET /auth/me"},
{key: "PUT /account"},
{key: "GET /navigation"},
{key: "GET /settings"},
{key: "GET /settings/{code}/schema"},

View File

@@ -124,7 +124,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles and adds a case-insensitive unique index on `backend_users.email`. The index migration refuses to run while emails differ only in case and names the clashing logins. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles, adds a case-insensitive unique index on `backend_users.email` and adds WinterCMS's nullable jsonable `backend_users.metadata` column (TEXT; the admin subtitle lives under its `subtitle` key). The index migration refuses to run while emails differ only in case and names the clashing logins. Rolling back the metadata migration drops only that column. |
| `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. |
| `lagoon.QueueHistoryID` | History id of the job-queue set, `summercms.conga`. |
| `lagoon.JobsTable` | Name of the job record table, `summer_jobs`. |

View File

@@ -13,8 +13,9 @@ import (
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
// the developer and publisher system roles and makes backend user emails
// unique case-insensitively. History is isolated under the summercms.cabana
// the developer and publisher system roles, makes backend user emails
// unique case-insensitively and adds WinterCMS's nullable jsonable metadata
// column to backend_users (the admin subtitle lives there). History is isolated under the summercms.cabana
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
// the history row is removed.
var BackendAdminMigrations = []*gormigrate.Migration{
@@ -117,4 +118,16 @@ FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&
return tx.Exec(`DROP INDEX IF EXISTS backend_users_email_lower_unique`).Error
},
},
{
// WinterCMS 2023_02_16_000012_Db_Backend_Add_User_Metadata: a
// nullable jsonable column. It is TEXT, not JSONB, so a copied value
// such as [] or a blank string loads unchanged.
ID: "202610090001_backend_users_metadata",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
},
},
}

View File

@@ -100,7 +100,7 @@ func TestBackendAdminMigration(t *testing.T) {
"password": false, "activation_code": true, "persist_code": true, "reset_password_code": true,
"permissions": true, "is_activated": false, "is_superuser": false, "role_id": true,
"activated_at": true, "last_login": true, "created_at": false, "updated_at": false,
"deleted_at": true, "tokens_valid_after": true,
"deleted_at": true, "tokens_valid_after": true, "metadata": true,
}
assertColumns(t, "backend_users", users, wantUsers)
roles := columnNullability(t, gdb, "backend_user_roles")
@@ -481,7 +481,9 @@ func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
// Roll back to the identity tables, which drops the email index and the
// later metadata column.
if err := m.RollbackTo("202609240001_backend_admin_identity"); err != nil {
t.Fatal(err)
}
// A Winter copy can hold emails that differ only in case.

View File

@@ -229,3 +229,22 @@ permissioneditor:
locked: You cannot change this permission.
empty: No permissions are defined yet.
other: Other
myaccount:
menu_label: My account
menu_description: Edit your name, email, subtitle and password.
profile: Profile
login: Login
first_name: First name
last_name: Last name
email: Email
subtitle: Subtitle
subtitle_comment: A short line shown under your name, for example your role or position.
password_section: Change password
password_comment: Leave blank to keep the current password.
current_password: Current password
password: New password
password_confirmation: Confirm new password
current_password_invalid: The current password is incorrect.
field_not_editable: This field cannot be changed on My account.
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
saved: Account saved

View File

@@ -251,3 +251,22 @@ permissioneditor:
locked: Nie możesz zmienić tego uprawnienia.
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
other: Inne
myaccount:
menu_label: Moje konto
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.
profile: Profil
login: Login
first_name: Imię
last_name: Nazwisko
email: E-mail
subtitle: Podtytuł
subtitle_comment: Krótki opis pod Twoim imieniem, na przykład rola lub stanowisko.
password_section: Zmiana hasła
password_comment: Pozostaw puste, aby zachować obecne hasło.
current_password: Obecne hasło
password: Nowe hasło
password_confirmation: Powtórz nowe hasło
current_password_invalid: Obecne hasło jest nieprawidłowe.
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
saved: Zapisano konto