- 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
209 lines
6.7 KiB
Go
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
|
|
}
|