feat(quick-261016-0fm): admin language follows the stored preference

- principalFrom sets PreferredLocale from metadata admin_locale; personal
  API token principals clear it, so token clients keep the request locale
- the guarded admin group runs locale.from-principal after the backend
  guard, overriding the LocaleResolver or Accept-Language locale
- GET /lang honours a supported ?locale= and reports meta.locales
  (cabana.LangEnvelope, cabana.LangMeta)
- Docker test for the override, refusals and /lang; docs and OpenAPI
This commit is contained in:
Jakub Zych
2026-10-16 10:58:29 +02:00
parent 3d2f9d0cf6
commit f67e8915b8
15 changed files with 216 additions and 39 deletions

View File

@@ -1055,21 +1055,6 @@
],
"type": "object"
},
"cabana.Envelope-cabana_LangBundle": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.LangBundle"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_ListSchema": {
"properties": {
"data": {
@@ -1783,6 +1768,41 @@
},
"type": "object"
},
"cabana.LangEnvelope": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.LangBundle"
},
"meta": {
"$ref": "#/components/schemas/cabana.LangMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.LangMeta": {
"properties": {
"locale": {
"description": "Locale is the locale the bundle resolved to.",
"type": "string"
},
"locales": {
"description": "Locales are the supported admin locales, sorted.",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"locale",
"locales"
],
"type": "object"
},
"cabana.ListColumn": {
"properties": {
"currency": {
@@ -3642,13 +3662,23 @@
},
"/lang": {
"get": {
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
"description": "Every backend::lang key as CLDR plural forms for the request locale, over the fallback locale's keys. The locale query parameter selects one of the supported admin locales instead (the SPA sends the admin's chosen language); a value that is not one of them is ignored. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to and meta.locales lists the supported admin locales.",
"parameters": [
{
"description": "A supported admin locale (one of meta.locales); other values are ignored",
"in": "query",
"name": "locale",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_LangBundle"
"$ref": "#/components/schemas/cabana.LangEnvelope"
}
}
},

View File

@@ -886,11 +886,14 @@ export interface paths {
};
/**
* Admin UI strings
* @description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
* @description Every backend::lang key as CLDR plural forms for the request locale, over the fallback locale's keys. The locale query parameter selects one of the supported admin locales instead (the SPA sends the admin's chosen language); a value that is not one of them is ignored. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to and meta.locales lists the supported admin locales.
*/
get: {
parameters: {
query?: never;
query?: {
/** @description A supported admin locale (one of meta.locales); other values are ignored */
locale?: string;
};
header?: never;
path?: never;
cookie?: never;
@@ -903,7 +906,7 @@ export interface paths {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_LangBundle"];
"application/json": components["schemas"]["cabana.LangEnvelope"];
};
};
};
@@ -5662,10 +5665,6 @@ export interface components {
data: components["schemas"]["cabana.FormView"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_LangBundle": {
data: components["schemas"]["cabana.LangBundle"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_ListSchema": {
data: components["schemas"]["cabana.ListSchema"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -5969,6 +5968,16 @@ export interface components {
"cabana.LangBundle": {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.LangEnvelope": {
data: components["schemas"]["cabana.LangBundle"];
meta: components["schemas"]["cabana.LangMeta"];
};
"cabana.LangMeta": {
/** @description Locale is the locale the bundle resolved to. */
locale: string;
/** @description Locales are the supported admin locales, sorted. */
locales: string[];
};
"cabana.ListColumn": {
/**
* @description Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom

View File

@@ -827,6 +827,7 @@
}
},
"meta": {
"locale": "pl"
"locale": "pl",
"locales": ["en", "pl"]
}
}

View File

@@ -30,7 +30,7 @@ import relationSchemaJson from './widgets.relation-schema.json'
type S = components['schemas']
type Rows = S['cabana.ListEnvelope-array_cabana_AdminRecord']
export const langFixture: S['cabana.Envelope-cabana_LangBundle'] = langJson
export const langFixture: S['cabana.LangEnvelope'] = langJson
export const navigationFixture: S['cabana.Envelope-array_cabana_NavigationEntry'] = navigationJson
export const settingsFixture: {
list: S['cabana.Envelope-array_cabana_SettingsEntry']

View File

@@ -23,7 +23,7 @@ A plugin route under the admin prefix also fails the start-up: the SPA and the a
## How the SPA talks to the server
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle with CLDR plural forms, in the administrator's chosen language (sent as `?locale=`) or else the request locale.
Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.

View File

@@ -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. `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 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. The admin language follows the administrator's own preference first: the language chosen in the SPA's user menu is stored per administrator and, on every admin API route behind the guard, replaces the request locale (whatever `Accept-Language` or a `surf.LocaleResolver` picked) through `locale.from-principal`. Without a preference, and always for requests made with a personal API token, the request locale applies. `GET <prefix>/api/v1/lang` takes a `locale` query parameter naming one of those languages, ignores any other value, and lists them in `meta.locales`. 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.

View File

@@ -65,10 +65,12 @@ Use cases:
All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.controller` maps to the path `/{vendor}/{plugin}/{controller}`.
Guarded routes run surf's `locale.from-principal` after the backend guard: an interactive administrator who chose an admin language (the `locale` preference) gets schemas, navigation, settings and validation messages in it whatever the request's `Accept-Language` or a published `surf.LocaleResolver` chose. Requests made with a personal API token keep the request locale.
| Method and path | Purpose |
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. Refresh refuses personal API tokens (`interactive_only`). |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| GET `/lang` | The `backend::lang` string bundle for the request locale, or for the `locale` query parameter when it names a supported admin locale (any other value is ignored); `meta.locale` is the resolved locale and `meta.locales` the supported admin locales (`cabana.LangEnvelope`). 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. |
@@ -278,6 +280,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `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.LangMeta` | The `meta` of GET `/lang`: the resolved `locale` and the supported admin `locales`. |
| `cabana.LangEnvelope` | The GET `/lang` answer: the `cabana.LangBundle` and its `cabana.LangMeta`. |
| `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` / `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). |

View File

@@ -1,9 +1,12 @@
package cabana_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -73,3 +76,97 @@ func TestAccountPreferencesSmoke(t *testing.T) {
t.Fatalf("metadata = %q (%v)", row.Metadata.String, err)
}
}
// sendLocale sends a request with a Bearer token (when set), a JSON body
// (when set) and an Accept-Language header.
func sendLocale(t *testing.T, h http.Handler, method, path, token, acceptLanguage string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept-Language", acceptLanguage)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// langMeta decodes the meta object of GET /lang.
func langMeta(t *testing.T, raw []byte) cabana.LangMeta {
t.Helper()
var body cabana.LangEnvelope
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("lang json: %v body=%s", err, raw)
}
return body.Meta
}
// TestAdminLocalePreference: a stored admin language overrides the request
// locale of guarded admin routes; unsupported codes are refused; GET /lang
// honours only a supported ?locale= and lists meta.locales.
func TestAdminLocalePreference(t *testing.T) {
gdb := adminGorm(t)
h, _ := accountHandler(t, gdb, false, 1<<20)
_, token := preferenceAdmin(t, gdb, h, "prefs-locale")
invalidTheme := func(t *testing.T) string {
t.Helper()
rec := sendLocale(t, h, http.MethodPut, adminAPI("/account/preferences"), token, "en", map[string]any{"theme": "blue"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid theme status=%d body=%s", rec.Code, rec.Body.String())
}
return strings.Join(validationDetails(t, rec.Body.Bytes())["theme"], " ")
}
if got := invalidTheme(t); !strings.Contains(got, "selected") {
t.Fatalf("message before the preference = %q, want English", got)
}
res := putPreferences(t, h, token, map[string]any{"locale": "pl"})
if res.Code != http.StatusOK {
t.Fatalf("put locale status=%d body=%s", res.Code, res.Body.String())
}
if got := preferencesOf(t, res.Body.Bytes()); got.Locale != "pl" || got.Theme != "system" {
t.Fatalf("put preferences = %+v", got)
}
if got := invalidTheme(t); !strings.Contains(got, "Zaznaczony") {
t.Fatalf("message with the pl preference = %q, want Polish despite Accept-Language: en", got)
}
bad := putPreferences(t, h, token, map[string]any{"locale": "xx"})
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("locale xx status=%d body=%s", bad.Code, bad.Body.String())
}
if _, ok := validationDetails(t, bad.Body.Bytes())["locale"]; !ok {
t.Fatalf("locale xx details = %s", bad.Body.String())
}
pl := sendLocale(t, h, http.MethodGet, adminAPI("/lang?locale=pl"), "", "en", nil)
if pl.Code != http.StatusOK {
t.Fatalf("lang pl status=%d", pl.Code)
}
if meta := langMeta(t, pl.Body.Bytes()); meta.Locale != "pl" || !slices.Contains(meta.Locales, "en") || !slices.Contains(meta.Locales, "pl") {
t.Fatalf("lang pl meta = %+v", meta)
}
xx := sendLocale(t, h, http.MethodGet, adminAPI("/lang?locale=xx"), "", "en", nil)
if meta := langMeta(t, xx.Body.Bytes()); meta.Locale != "en" || strings.Contains(xx.Body.String(), "xx") {
t.Fatalf("lang xx meta = %+v", meta)
}
cleared := putPreferences(t, h, token, map[string]any{"locale": ""})
if got := preferencesOf(t, cleared.Body.Bytes()); cleared.Code != http.StatusOK || got.Locale != "" {
t.Fatalf("clear locale status=%d body=%s", cleared.Code, cleared.Body.String())
}
if got := invalidTheme(t); !strings.Contains(got, "selected") {
t.Fatalf("message after clearing = %q, want English", got)
}
}

View File

@@ -186,13 +186,28 @@ func AdminRefresh() {}
// forms (D-20).
type LangBundle map[string]MessageForms
// LangMeta is the meta object of GET /lang.
type LangMeta struct {
// Locale is the locale the bundle resolved to.
Locale string `json:"locale"`
// Locales are the supported admin locales, sorted.
Locales []string `json:"locales"`
}
// LangEnvelope is the GET /lang answer.
type LangEnvelope struct {
Data LangBundle `json:"data"`
Meta LangMeta `json:"meta"`
}
// AdminLang documents GET /lang.
//
// @Summary Admin UI strings
// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
// @Description Every backend::lang key as CLDR plural forms for the request locale, over the fallback locale's keys. The locale query parameter selects one of the supported admin locales instead (the SPA sends the admin's chosen language); a value that is not one of them is ignored. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to and meta.locales lists the supported admin locales.
// @Tags admin
// @Produce json
// @Success 200 {object} Envelope[LangBundle]
// @Param locale query string false "A supported admin locale (one of meta.locales); other values are ignored"
// @Success 200 {object} LangEnvelope
// @Router /lang [get]
func AdminLang() {}

View File

@@ -106,6 +106,9 @@ func principalFrom(user BackendUser) *bouncer.Principal {
Backend: true,
IsSuperuser: user.IsSuperuser,
PermissionGrants: parseGrants(user.Role.Permissions),
// The admin's chosen admin language (validated on write); empty
// keeps the request locale. Personal API tokens clear it.
PreferredLocale: metadataString(user.Metadata, metadataAdminLocale),
}
if user.TokensValidAfter != nil {
principal.TokensValidAfter = *user.TokensValidAfter

View File

@@ -262,7 +262,10 @@ func (s *service) mount(r pact.Router) {
r.GroupRaw(api, nil, func(g pact.Router) {
g.Get("/lang", s.langBundle)
})
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
// locale.from-principal runs after the guard: an interactive admin's
// stored admin language overrides whatever the LocaleResolver or
// Accept-Language chose for the request.
r.GroupRaw(api, []string{"backend", "locale.from-principal"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
// My account: the signed-in admin edits their own profile.
// interactiveOnly refuses personal API tokens before requireAjax.

View File

@@ -2,6 +2,7 @@ package cabana
import (
"net/http"
"slices"
)
// adminLangPrefix is the only namespace the public bundle serves (D-20).
@@ -9,16 +10,25 @@ const adminLangPrefix = "backend::lang."
// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR
// forms for the request locale, with the fallback locale's keys underneath.
// It is public (the login screen needs it) and never includes another
// namespace.
// A locale query parameter naming a supported admin locale (one of
// Translator.Locales(adminLangPrefix)) replaces the request locale; any other
// value is ignored. meta.locales lists the supported admin locales. It is
// public (the login screen needs it) and never includes another namespace.
func (s *service) langBundle(w http.ResponseWriter, r *http.Request) {
tr := s.translator()
locales := tr.Locales(adminLangPrefix)
if locales == nil {
locales = []string{}
}
locale := schemaLocale(r.Context(), tr)
if requested := r.URL.Query().Get("locale"); requested != "" && slices.Contains(locales, requested) {
locale = requested
}
data := map[string]map[string]string{}
if tr != nil {
locale = tr.Resolved(locale, adminLangPrefix)
data = tr.Bundle(locale, adminLangPrefix)
}
w.Header().Set("Cache-Control", "no-cache")
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale})
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale, "locales": locales})
}

View File

@@ -307,7 +307,8 @@ func TestPhase10Bundle(t *testing.T) {
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
Locale string `json:"locale"`
Locales []string `json:"locales"`
} `json:"meta"`
}
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))

View File

@@ -403,9 +403,9 @@ func conformCases() []conformCase {
{"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/auth/me", nil, true)
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang", nil, false)
}, into[cabana.Envelope[cabana.LangBundle]](), nil},
{"GET /lang", 200, "cabana.LangEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang?locale=pl", nil, false)
}, into[cabana.LangEnvelope](), nil},
{"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/navigation", nil, true)
}, into[cabana.Envelope[[]cabana.NavigationEntry]](), nil},

View File

@@ -117,6 +117,10 @@ func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bounce
return nil, nil, newScopeError()
}
principal.Scope = limit
// Personal API tokens keep the request locale: the stored admin
// language applies to interactive sessions only, so API-token clients
// see no behaviour change.
principal.PreferredLocale = ""
stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now)
logTokenAuth(r, "success", principal.ID, row.ID)
return principal, row, nil