feat(quick-261016-0fm): per-admin theme and locale preferences
- GET and PUT /account/preferences (own row only, interactive sessions, CSRF header on cookie writes) store admin_theme and admin_locale in backend_users.metadata, keeping every other key - GET /auth/me and the My account answers carry preferences - phrasebook.Translator.Locales lists the locales defining a prefix - route tables, conformance cases, OpenAPI document, TS types and docs
This commit is contained in:
@@ -352,6 +352,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminPreferences": {
|
||||
"properties": {
|
||||
"locale": {
|
||||
"description": "Locale is an admin locale code from GET /lang meta.locales, or \"\" when\nthe admin follows the request locale.",
|
||||
"type": "string"
|
||||
},
|
||||
"theme": {
|
||||
"description": "Theme is system, light or dark; system (follow the browser) when unset.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"locale",
|
||||
"theme"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminPreferencesRequest": {
|
||||
"properties": {
|
||||
"locale": {
|
||||
"description": "Locale is \"\" (follow the request locale) or a code from GET /lang\nmeta.locales.",
|
||||
"type": "string"
|
||||
},
|
||||
"theme": {
|
||||
"description": "Theme is system, light or dark.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminProfile": {
|
||||
"properties": {
|
||||
"avatar": {
|
||||
@@ -380,6 +410,14 @@
|
||||
"login": {
|
||||
"type": "string"
|
||||
},
|
||||
"preferences": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.AdminPreferences"
|
||||
}
|
||||
],
|
||||
"description": "Preferences are the admin's own UI preferences (theme and language)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/cabana.AdminRoleSummary"
|
||||
},
|
||||
@@ -395,6 +433,7 @@
|
||||
"is_superuser",
|
||||
"last_name",
|
||||
"login",
|
||||
"preferences",
|
||||
"subtitle"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -851,6 +890,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminPreferences": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminPreferences"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminProfile": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -2963,6 +3017,127 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/preferences": {
|
||||
"get": {
|
||||
"description": "The signed-in admin's own UI preferences: theme (system, light or dark; system when not set) and locale (an admin locale code, or \"\" to follow the request locale). Interactive session only (403 interactive_only for personal API tokens).",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminPreferences"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "My UI preferences",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Saves the signed-in admin's own UI preferences in backend_users.metadata (admin_theme, admin_locale); every other metadata key is kept and the row is the token subject, never a request value. Only theme and locale are accepted: any other key is a 422 on that key and nothing is written. Each present key is saved and an absent key keeps its stored value, so {} answers the current preferences and writes nothing. theme must be system, light or dark; locale must be \"\" (follow the request locale) or a code from GET /lang meta.locales; another value or a non-string is a 422 on that field. Stored metadata that is not a JSON object is never overwritten (422). Interactive session only (403 interactive_only for personal API tokens).",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminPreferencesRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Preferences to change",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminPreferences"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 UI preferences",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/tokens": {
|
||||
"get": {
|
||||
"description": "Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).",
|
||||
@@ -3305,7 +3480,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, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.",
|
||||
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag, the role, the UI preferences (theme, \"system\" when not set, and locale, \"\" when not set) and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
||||
147
admin/src/api/schema.d.ts
vendored
147
admin/src/api/schema.d.ts
vendored
@@ -210,6 +210,127 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/account/preferences": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* My UI preferences
|
||||
* @description The signed-in admin's own UI preferences: theme (system, light or dark; system when not set) and locale (an admin locale code, or "" to follow the request locale). Interactive session only (403 interactive_only for personal API tokens).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminPreferences"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Update my UI preferences
|
||||
* @description Saves the signed-in admin's own UI preferences in backend_users.metadata (admin_theme, admin_locale); every other metadata key is kept and the row is the token subject, never a request value. Only theme and locale are accepted: any other key is a 422 on that key and nothing is written. Each present key is saved and an absent key keeps its stored value, so {} answers the current preferences and writes nothing. theme must be system, light or dark; locale must be "" (follow the request locale) or a code from GET /lang meta.locales; another value or a non-string is a 422 on that field. Stored metadata that is not a JSON object is never overwritten (422). Interactive session only (403 interactive_only for personal API tokens).
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Preferences to change */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminPreferencesRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminPreferences"];
|
||||
};
|
||||
};
|
||||
/** @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;
|
||||
};
|
||||
"/account/tokens": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -576,7 +697,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Current admin
|
||||
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role, the UI preferences (theme, "system" when not set, and locale, "" when not set) and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -5344,6 +5465,24 @@ export interface components {
|
||||
"cabana.AdminMarkdownPreviewResult": {
|
||||
html: string;
|
||||
};
|
||||
"cabana.AdminPreferences": {
|
||||
/**
|
||||
* @description Locale is an admin locale code from GET /lang meta.locales, or "" when
|
||||
* the admin follows the request locale.
|
||||
*/
|
||||
locale: string;
|
||||
/** @description Theme is system, light or dark; system (follow the browser) when unset. */
|
||||
theme: string;
|
||||
};
|
||||
"cabana.AdminPreferencesRequest": {
|
||||
/**
|
||||
* @description Locale is "" (follow the request locale) or a code from GET /lang
|
||||
* meta.locales.
|
||||
*/
|
||||
locale?: string;
|
||||
/** @description Theme is system, light or dark. */
|
||||
theme?: string;
|
||||
};
|
||||
"cabana.AdminProfile": {
|
||||
/** @description Avatar is present when the admin has a public avatar. */
|
||||
avatar?: components["schemas"]["cabana.AdminAvatar"];
|
||||
@@ -5353,6 +5492,8 @@ export interface components {
|
||||
is_superuser: boolean;
|
||||
last_name: string;
|
||||
login: string;
|
||||
/** @description Preferences are the admin's own UI preferences (theme and language). */
|
||||
preferences: components["schemas"]["cabana.AdminPreferences"];
|
||||
role?: components["schemas"]["cabana.AdminRoleSummary"];
|
||||
/** @description Subtitle is backend_users.metadata.subtitle; "" when not set. */
|
||||
subtitle: string;
|
||||
@@ -5477,6 +5618,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminMarkdownPreviewResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminPreferences": {
|
||||
data: components["schemas"]["cabana.AdminPreferences"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminProfile": {
|
||||
data: components["schemas"]["cabana.AdminProfile"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
|
||||
export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||
export type AdminPreferences = Schemas['cabana.AdminPreferences']
|
||||
/** An admin's avatar: the public image URL and its 250x250 thumbnail URL. */
|
||||
export type AdminAvatar = Schemas['cabana.AdminAvatar']
|
||||
/** Body of PUT /account: the only keys My account may change. */
|
||||
|
||||
@@ -28,6 +28,7 @@ export const profile = {
|
||||
subtitle: '',
|
||||
is_superuser: false,
|
||||
role: { id: 2, code: 'developer', name: 'Developer' },
|
||||
preferences: { theme: 'system', locale: '' },
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ Every signed-in administrator can edit their own profile on the My account scree
|
||||
- **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`.
|
||||
- **Avatar:** `POST <prefix>/api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE <prefix>/api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials.
|
||||
- **Preferences:** the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through `PUT <prefix>/api/v1/account/preferences` and read with `GET <prefix>/api/v1/account/preferences`; `GET .../auth/me` returns them as `preferences`. The body may hold only `theme` and `locale` (`cabana.AdminPreferencesRequest`); any other key, including `id`, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so `{}` answers the current preferences. `theme` must be `system`, `light` or `dark`; `locale` must be `""` (follow the request locale) or one of the admin locales `GET /lang` lists in `meta.locales`; another value or a non-string answers 422 on that field. They are stored under `admin_theme` and `admin_locale` in `backend_users.metadata`, next to the subtitle; the defaults (`system`, `""`) are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens.
|
||||
- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes.
|
||||
|
||||
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
|
||||
|
||||
@@ -79,6 +79,6 @@ The locale lives on the request context, not in a global. For every route, [surf
|
||||
|
||||
## Strings for the admin
|
||||
|
||||
The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it.
|
||||
The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. `phrasebook.Translator.Locales` lists the locales that define keys under a prefix; `Locales("backend::lang.")` is the set of admin languages an administrator can choose. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it.
|
||||
|
||||
The framework ships its validation messages (`lagoon::validate` for `lagoon.Validate`, and the full WinterCMS validator catalog `lagoon::validation` for `lagoon.ValidateRequest`) and admin strings (`backend::lang`) in English and Polish.
|
||||
|
||||
@@ -56,7 +56,7 @@ Use cases:
|
||||
- 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 for JWT sessions: 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). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
|
||||
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
|
||||
- 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. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
|
||||
- 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. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`. The administrator's UI preferences (GET and PUT `/account/preferences`, `cabana.AdminPreferences`) live in the same metadata column under `admin_theme` and `admin_locale`; saving them keeps every other key.
|
||||
- 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, resetting their passwords and managing personal API tokens (see CLI commands).
|
||||
@@ -71,6 +71,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| 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 JWT, 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` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). Logout refuses personal API tokens; `/auth/me` accepts any valid token. |
|
||||
| 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`. Interactive session only (personal API tokens are refused). |
|
||||
| GET and PUT `/account/preferences` | Read or save the signed-in administrator's own UI preferences (`cabana.AdminPreferences`: `theme` system, light or dark, `locale` an admin locale code or ""), stored under `admin_theme` and `admin_locale` in `backend_users.metadata` with every other key kept. PUT (`cabana.AdminPreferencesRequest`) saves each present key and keeps an absent one; any other key, another value or a non-string is a 422, and metadata that is not a JSON object is never overwritten (422). Interactive session only. |
|
||||
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Interactive session only. |
|
||||
| GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. |
|
||||
| DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. |
|
||||
@@ -274,9 +275,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `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.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag, role and `preferences`. |
|
||||
| `cabana.AdminPreferences` | An administrator's UI preferences: `theme` (system, light or dark; system when unset) and `locale` (an admin locale code, or "" to follow the request locale). |
|
||||
| `cabana.AdminPreferencesRequest` | Body of PUT `/account/preferences`: the optional `theme` and `locale`, the only accepted keys. |
|
||||
| `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` / `cabana.AdminAccountAvatarUpload` / `cabana.AdminAccountAvatarRemove` | Swag annotations of the My account routes. |
|
||||
| `cabana.AdminAccountUpdate` / `cabana.AdminAccountAvatarUpload` / `cabana.AdminAccountAvatarRemove` / `cabana.AdminAccountPreferences` / `cabana.AdminAccountPreferencesUpdate` | Swag annotations of the My account routes. |
|
||||
| `cabana.AdminAvatar` | The `avatar` of a `cabana.AdminProfile`: the public `url` and the 250x250 crop `thumb_url` (left out when the thumbnail cannot be made). |
|
||||
| `cabana.AvatarMaxBytes` | Largest avatar image the upload route accepts, 2 MiB; `http.body_limits.upload_bytes` still caps the request. |
|
||||
| `cabana.BackendUserAvatar` | Returns a backend user's avatar `attach.File` (the newest avatar row), or nil when there is none; `File.URL` and `File.Thumb` give its public URLs. |
|
||||
|
||||
@@ -153,6 +153,7 @@ func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser)
|
||||
LastName: user.LastName,
|
||||
Subtitle: user.Subtitle(),
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
Preferences: preferencesOf(user, s.translator()),
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
|
||||
208
modules/cabana/account_preferences.go
Normal file
208
modules/cabana/account_preferences.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Metadata keys of an admin's UI preferences in backend_users.metadata, next
|
||||
// to the subtitle and whatever keys a WinterCMS copy brought along.
|
||||
const (
|
||||
metadataAdminTheme = "admin_theme"
|
||||
metadataAdminLocale = "admin_locale"
|
||||
)
|
||||
|
||||
// Preference keys PUT /account/preferences accepts.
|
||||
const (
|
||||
preferenceTheme = "theme"
|
||||
preferenceLocale = "locale"
|
||||
)
|
||||
|
||||
// themeSystem follows the browser's prefers-color-scheme; it is the default
|
||||
// and is stored as an absent key.
|
||||
const themeSystem = "system"
|
||||
|
||||
// themeChoices are the values of the theme preference.
|
||||
var themeChoices = []string{themeSystem, "light", "dark"}
|
||||
|
||||
// preferenceKeys are the only keys PUT /account/preferences accepts.
|
||||
var preferenceKeys = []string{preferenceTheme, preferenceLocale}
|
||||
|
||||
// preferencesOf reads an admin's UI preferences from the stored metadata. A
|
||||
// theme outside themeChoices is system; a locale the translator has no admin
|
||||
// strings for is "", so a value that lost its support degrades to the
|
||||
// request locale.
|
||||
func preferencesOf(user BackendUser, tr *phrasebook.Translator) AdminPreferences {
|
||||
prefs := AdminPreferences{Theme: themeSystem}
|
||||
if theme := metadataString(user.Metadata, metadataAdminTheme); slices.Contains(themeChoices, theme) {
|
||||
prefs.Theme = theme
|
||||
}
|
||||
if locale := metadataString(user.Metadata, metadataAdminLocale); locale != "" && slices.Contains(tr.Locales(adminLangPrefix), locale) {
|
||||
prefs.Locale = locale
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// accountPreferencesGet serves GET /account/preferences: the signed-in
|
||||
// admin's own UI preferences.
|
||||
func (s *service) accountPreferencesGet(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil {
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account preferences without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var user BackendUser
|
||||
err = db.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && !user.IsActivated) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, preferencesOf(user, s.translator()), map[string]any{})
|
||||
}
|
||||
|
||||
// accountPreferencesUpdate serves PUT /account/preferences: the signed-in
|
||||
// admin saves their own theme and admin locale. The row is the principal's,
|
||||
// never a request value. Each present key is saved, an absent key keeps its
|
||||
// stored value. Only the metadata column is written: a UI preference is not
|
||||
// a profile edit, so updated_at stays.
|
||||
func (s *service) accountPreferencesUpdate(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
|
||||
}
|
||||
ctx, tr := r.Context(), s.translator()
|
||||
if details := refusedPreferenceKeys(ctx, tr, body); len(details) > 0 {
|
||||
writeCRUDError(w, &ValidationError{Details: details})
|
||||
return
|
||||
}
|
||||
updates, details := validPreferences(ctx, tr, body)
|
||||
if len(details) > 0 {
|
||||
writeCRUDError(w, &ValidationError{Details: details})
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(ctx, "cabana: account preferences without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var saved BackendUser
|
||||
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
user, err := lockAccount(ctx, tx, principal.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata := user.Metadata
|
||||
keys := make([]string, 0, len(updates))
|
||||
for key := range updates {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
metadata, err = setMetadataString(metadata, key, updates[key])
|
||||
if errors.Is(err, errMetadataNotObject) {
|
||||
message := backendMessage(ctx, tr, "myaccount.preferences_unavailable", "The preference cannot be saved because the stored profile metadata is not a JSON object.")
|
||||
details := map[string]any{}
|
||||
for field := range body {
|
||||
details[field] = []string{message}
|
||||
}
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if sameMetadata(user.Metadata, metadata) {
|
||||
saved = user
|
||||
return nil
|
||||
}
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{"metadata": metadata}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.Metadata = metadata
|
||||
saved = user
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, preferencesOf(saved, tr), map[string]any{})
|
||||
}
|
||||
|
||||
// refusedPreferenceKeys answers one detail per body key PUT
|
||||
// /account/preferences does not accept.
|
||||
func refusedPreferenceKeys(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(preferenceKeys, 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
|
||||
}
|
||||
|
||||
// validPreferences checks the present preference keys and returns the
|
||||
// metadata values to store: system and "" are stored as an absent key, so an
|
||||
// admin who never chose keeps NULL metadata.
|
||||
func validPreferences(ctx context.Context, tr *phrasebook.Translator, body map[string]any) (map[string]string, map[string]any) {
|
||||
updates := map[string]string{}
|
||||
details := map[string]any{}
|
||||
if raw, ok := body[preferenceTheme]; ok {
|
||||
theme, isString := raw.(string)
|
||||
switch {
|
||||
case !isString || !slices.Contains(themeChoices, theme):
|
||||
details[preferenceTheme] = []string{fieldMessage(ctx, tr, "in", preferenceTheme, nil)}
|
||||
case theme == themeSystem:
|
||||
updates[metadataAdminTheme] = ""
|
||||
default:
|
||||
updates[metadataAdminTheme] = theme
|
||||
}
|
||||
}
|
||||
if raw, ok := body[preferenceLocale]; ok {
|
||||
locale, isString := raw.(string)
|
||||
switch {
|
||||
case !isString || (locale != "" && !slices.Contains(tr.Locales(adminLangPrefix), locale)):
|
||||
details[preferenceLocale] = []string{fieldMessage(ctx, tr, "in", preferenceLocale, nil)}
|
||||
default:
|
||||
updates[metadataAdminLocale] = locale
|
||||
}
|
||||
}
|
||||
return updates, details
|
||||
}
|
||||
|
||||
// sameMetadata reports whether two stored metadata values are equal, NULL
|
||||
// equal only to NULL.
|
||||
func sameMetadata(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
75
modules/cabana/account_preferences_test.go
Normal file
75
modules/cabana/account_preferences_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// putPreferences sends PUT /account/preferences with a Bearer token.
|
||||
func putPreferences(t *testing.T, h http.Handler, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return postAuth(t, h, http.MethodPut, adminAPI("/account/preferences"), token, raw)
|
||||
}
|
||||
|
||||
// preferencesOf decodes an AdminPreferences envelope.
|
||||
func preferencesOf(t *testing.T, raw []byte) cabana.AdminPreferences {
|
||||
t.Helper()
|
||||
var body cabana.Envelope[cabana.AdminPreferences]
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("preferences json: %v body=%s", err, raw)
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
|
||||
// preferenceAdmin inserts and signs in a fresh admin on the account harness.
|
||||
func preferenceAdmin(t *testing.T, gdb *gorm.DB, h http.Handler, prefix string) (cabana.BackendUser, string) {
|
||||
t.Helper()
|
||||
login := prefix + "-" + accountStamp()
|
||||
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
return user, signIn(t, h, login)
|
||||
}
|
||||
|
||||
// TestAccountPreferencesSmoke saves a theme, reads it back from GET
|
||||
// /auth/me and GET /account/preferences, and keeps the stored subtitle.
|
||||
func TestAccountPreferencesSmoke(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h, _ := accountHandler(t, gdb, false, 1<<20)
|
||||
user, token := preferenceAdmin(t, gdb, h, "prefs-smoke")
|
||||
setMetadata(t, gdb, user.ID, `{"subtitle":"Rear admiral"}`)
|
||||
|
||||
res := putPreferences(t, h, token, map[string]any{"theme": "dark"})
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("put status=%d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if got := preferencesOf(t, res.Body.Bytes()); got.Theme != "dark" || got.Locale != "" {
|
||||
t.Fatalf("put preferences = %+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())
|
||||
}
|
||||
if got := profileOf(t, me.Body.Bytes()); got.Preferences.Theme != "dark" || got.Subtitle != "Rear admiral" {
|
||||
t.Fatalf("me profile = %+v", got)
|
||||
}
|
||||
get := getAuth(t, h, adminAPI("/account/preferences"), token)
|
||||
if get.Code != http.StatusOK {
|
||||
t.Fatalf("get status=%d body=%s", get.Code, get.Body.String())
|
||||
}
|
||||
if got := preferencesOf(t, get.Body.Bytes()); got.Theme != "dark" || got.Locale != "" {
|
||||
t.Fatalf("get preferences = %+v", got)
|
||||
}
|
||||
var meta map[string]string
|
||||
row := rawUser(t, gdb, user.ID)
|
||||
if err := json.Unmarshal([]byte(row.Metadata.String), &meta); err != nil || meta["subtitle"] != "Rear admiral" || meta["admin_theme"] != "dark" {
|
||||
t.Fatalf("metadata = %q (%v)", row.Metadata.String, err)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,28 @@ type AdminProfile struct {
|
||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||
// Avatar is present when the admin has a public avatar.
|
||||
Avatar *AdminAvatar `json:"avatar,omitempty"`
|
||||
// Preferences are the admin's own UI preferences (theme and language).
|
||||
Preferences AdminPreferences `json:"preferences"`
|
||||
}
|
||||
|
||||
// AdminPreferences are a signed-in admin's UI preferences, stored in
|
||||
// backend_users.metadata under admin_theme and admin_locale.
|
||||
type AdminPreferences struct {
|
||||
// Theme is system, light or dark; system (follow the browser) when unset.
|
||||
Theme string `json:"theme"`
|
||||
// Locale is an admin locale code from GET /lang meta.locales, or "" when
|
||||
// the admin follows the request locale.
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
// AdminPreferencesRequest is the body of PUT /account/preferences. Only these
|
||||
// keys are accepted; an absent key keeps its stored value.
|
||||
type AdminPreferencesRequest struct {
|
||||
// Theme is system, light or dark.
|
||||
Theme *string `json:"theme,omitempty"`
|
||||
// Locale is "" (follow the request locale) or a code from GET /lang
|
||||
// meta.locales.
|
||||
Locale *string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
// AdminAvatar is an admin's avatar: the public URL of the image and of its
|
||||
@@ -190,7 +212,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, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role, the UI preferences (theme, "system" when not set, and locale, "" when not set) and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
@@ -218,6 +240,36 @@ func AdminMe() {}
|
||||
// @Router /account [put]
|
||||
func AdminAccountUpdate() {}
|
||||
|
||||
// AdminAccountPreferences documents GET /account/preferences.
|
||||
//
|
||||
// @Summary My UI preferences
|
||||
// @Description The signed-in admin's own UI preferences: theme (system, light or dark; system when not set) and locale (an admin locale code, or "" to follow the request locale). Interactive session only (403 interactive_only for personal API tokens).
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} Envelope[AdminPreferences]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /account/preferences [get]
|
||||
func AdminAccountPreferences() {}
|
||||
|
||||
// AdminAccountPreferencesUpdate documents PUT /account/preferences.
|
||||
//
|
||||
// @Summary Update my UI preferences
|
||||
// @Description Saves the signed-in admin's own UI preferences in backend_users.metadata (admin_theme, admin_locale); every other metadata key is kept and the row is the token subject, never a request value. Only theme and locale are accepted: any other key is a 422 on that key and nothing is written. Each present key is saved and an absent key keeps its stored value, so {} answers the current preferences and writes nothing. theme must be system, light or dark; locale must be "" (follow the request locale) or a code from GET /lang meta.locales; another value or a non-string is a 422 on that field. Stored metadata that is not a JSON object is never overwritten (422). Interactive session only (403 interactive_only for personal API tokens).
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param body body AdminPreferencesRequest true "Preferences to change"
|
||||
// @Success 200 {object} Envelope[AdminPreferences]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /account/preferences [put]
|
||||
func AdminAccountPreferencesUpdate() {}
|
||||
|
||||
// AdminAccountTokens documents GET /account/tokens.
|
||||
//
|
||||
// @Summary List my API tokens
|
||||
|
||||
@@ -267,6 +267,8 @@ func (s *service) mount(r pact.Router) {
|
||||
// My account: the signed-in admin edits their own profile.
|
||||
// interactiveOnly refuses personal API tokens before requireAjax.
|
||||
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
|
||||
g.Get("/account/preferences", interactiveOnly(s.accountPreferencesGet))
|
||||
g.Put("/account/preferences", interactiveOnly(requireAjax(s.accountPreferencesUpdate)))
|
||||
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
|
||||
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
|
||||
g.Get("/account/tokens", interactiveOnly(s.accountTokensList))
|
||||
|
||||
@@ -713,6 +713,17 @@ func conformCases() []conformCase {
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"PUT /account/preferences", 200, "cabana.Envelope-cabana_AdminPreferences", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPut, "/account/preferences", map[string]any{"theme": "dark"}, true)
|
||||
var body cabana.Envelope[cabana.AdminPreferences]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Theme != "dark" {
|
||||
t.Fatalf("preferences body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminPreferences]](), nil},
|
||||
{"GET /account/preferences", 200, "cabana.Envelope-cabana_AdminPreferences", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/account/preferences", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminPreferences]](), nil},
|
||||
{"POST /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
@@ -116,6 +116,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||
"PUT /account",
|
||||
"PUT /account/preferences",
|
||||
"PUT /settings/{code}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
|
||||
@@ -66,15 +66,15 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// refresh, logout, account update, avatar upload and remove, account
|
||||
// refresh, logout, account update, preferences update, avatar upload and remove, account
|
||||
// token create and revoke, 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, plugin field
|
||||
// type upload, tree reorder save
|
||||
if unsafe != 33 {
|
||||
t.Fatalf("walked %d state-changing routes, want 33: %v", unsafe, router.order)
|
||||
if unsafe != 34 {
|
||||
t.Fatalf("walked %d state-changing routes, want 34: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -40,6 +40,8 @@ var phase09Routes = []adminRoute{
|
||||
{key: "POST /auth/logout", public: true},
|
||||
{key: "GET /auth/me"},
|
||||
{key: "PUT /account"},
|
||||
{key: "GET /account/preferences"},
|
||||
{key: "PUT /account/preferences"},
|
||||
{key: "POST /account/avatar"},
|
||||
{key: "DELETE /account/avatar"},
|
||||
{key: "GET /account/tokens"},
|
||||
|
||||
@@ -35,6 +35,8 @@ var tokenRouteClasses = map[string]tokenRouteClass{
|
||||
"GET /auth/me": classOpenAnyToken,
|
||||
|
||||
"PUT /account": classInteractive,
|
||||
"GET /account/preferences": classInteractive,
|
||||
"PUT /account/preferences": classInteractive,
|
||||
"POST /account/avatar": classInteractive,
|
||||
"DELETE /account/avatar": classInteractive,
|
||||
"GET /account/tokens": classInteractive,
|
||||
@@ -167,7 +169,8 @@ func TestTokenRouteGates(t *testing.T) {
|
||||
|
||||
// interactive_only: credential or raw g15_ Bearer → 403 interactive_only.
|
||||
interactivePaths := []string{
|
||||
"PUT /account", "POST /account/avatar", "DELETE /account/avatar",
|
||||
"PUT /account", "GET /account/preferences", "PUT /account/preferences",
|
||||
"POST /account/avatar", "DELETE /account/avatar",
|
||||
"GET /account/tokens", "POST /account/tokens", "GET /account/tokens/scopes",
|
||||
"DELETE /account/tokens/{id}", "POST /auth/refresh", "POST /auth/logout",
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ Use cases:
|
||||
- Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself.
|
||||
- Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased).
|
||||
- Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically.
|
||||
- Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, and `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from. Activation fails if a `backend::` string cannot be expressed as CLDR forms.
|
||||
- Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from, and `phrasebook.Translator.Locales` lists the locales that define keys under a prefix (the admin's supported languages are `Locales("backend::lang.")`). Activation fails if a `backend::` string cannot be expressed as CLDR forms.
|
||||
- Missing keys are logged once per key through `log/slog`, except in the production environment.
|
||||
|
||||
## Usage
|
||||
@@ -104,6 +104,7 @@ func Title(ctx context.Context, app *backpack.App) string {
|
||||
| `phrasebook.Translator.Forms` | Returns a key as CLDR plural forms for the admin SPA. |
|
||||
| `phrasebook.Translator.Bundle` | Returns all keys under a prefix as CLDR forms, merged over the fallback chain. |
|
||||
| `phrasebook.Translator.Resolved` | Returns the first locale in the fallback chain that has keys under a prefix. |
|
||||
| `phrasebook.Translator.Locales` | Returns the sorted locale codes that define at least one key under a prefix. |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ nav:
|
||||
expand: Expand menu
|
||||
user_menu: User menu
|
||||
view_site: View site
|
||||
theme: "Theme: :mode"
|
||||
theme_system: System
|
||||
theme_light: Light
|
||||
theme_dark: Dark
|
||||
language: Language
|
||||
page:
|
||||
not_found: Page not found.
|
||||
list:
|
||||
@@ -341,6 +346,8 @@ myaccount:
|
||||
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.
|
||||
preferences_unavailable: The preference cannot be saved because the stored profile metadata is not a JSON object.
|
||||
preferences_failed: Your preference could not be saved.
|
||||
saved: Account saved
|
||||
avatar: Avatar
|
||||
avatar_upload: Upload picture
|
||||
|
||||
@@ -21,6 +21,11 @@ nav:
|
||||
expand: Rozwiń menu
|
||||
user_menu: Menu użytkownika
|
||||
view_site: Zobacz stronę
|
||||
theme: "Motyw: :mode"
|
||||
theme_system: Systemowy
|
||||
theme_light: Jasny
|
||||
theme_dark: Ciemny
|
||||
language: Język
|
||||
page:
|
||||
not_found: Nie znaleziono strony.
|
||||
list:
|
||||
@@ -363,6 +368,8 @@ myaccount:
|
||||
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.
|
||||
preferences_unavailable: Nie można zapisać ustawienia, ponieważ zapisane metadane profilu nie są obiektem JSON.
|
||||
preferences_failed: Nie udało się zapisać ustawienia.
|
||||
saved: Zapisano konto
|
||||
avatar: Awatar
|
||||
avatar_upload: Wgraj zdjęcie
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -533,6 +534,26 @@ func (t *Translator) Resolved(locale, prefix string) string {
|
||||
return t.fallbackLocale()
|
||||
}
|
||||
|
||||
// Locales returns the sorted locale codes whose catalog defines at least one
|
||||
// key starting with prefix: the locales a Bundle under prefix exists in. A
|
||||
// nil translator or catalog returns nil.
|
||||
func (t *Translator) Locales(prefix string) []string {
|
||||
if t == nil || t.cat == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for locale, byKey := range t.cat.messages {
|
||||
for key := range byKey {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
out = append(out, locale)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Has reports whether any locale of the catalog defines key.
|
||||
func (t *Translator) Has(key string) bool {
|
||||
if t == nil || t.cat == nil {
|
||||
|
||||
Reference in New Issue
Block a user