Files
summercms/modules/cabana/account_preferences.go
Jakub Zych 047858ae84 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
2026-10-16 09:26:09 +02:00

209 lines
6.7 KiB
Go

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
}