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