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

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