Files
ferrum/internal/api/settings_defaults.go
T
Anand 61ee806af6 Add realtime polling defaults, Gotify/SMTP notifications, UI-configurable OIDC, and expanded admin settings
- Default all queries to a 20s poll + refetch-on-focus (main.tsx) instead of
  a per-page opt-in, so every page/widget stays live without manual tuning.
- New internal/notify package: Gotify and SMTP (stdlib net/smtp, STARTTLS
  and implicit-TLS-on-465) notifications, each independently optional. Fires
  from the alert evaluator on new alert triggers; admin-configurable from
  Settings with a send-test-notification action per channel.
- OIDC/SSO moved from config.yaml-only to a DB-backed, admin-editable
  Settings card — swaps the live client with no restart. config.yaml is
  used to seed the database once on first boot after upgrading.
- New Security settings: session TTL, login lockout policy, and a real
  "require 2FA for admins" enforcement (requireTOTPEnrolled middleware)
  that blocks non-enrolled admins from everything but /profile and logout.
- New org-wide default preferences (theme/accent/look/landing page) for
  brand-new accounts, plus a personal landing-page picker and an
  email-me-alerts opt-in on Profile.
- Storage page: separate Local vs Shared/External storage tables and
  capacity donuts, fixing shared-storage totals that were being summed once
  per node that mounts them (e.g. a 2TB NFS share on 4 nodes read as 8TB).
- RankedBarChart: stop the longest bar's value label wrapping onto two
  lines (recharts auto-wraps LabelList when space is tight).
2026-09-03 22:00:41 +05:30

126 lines
3.9 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
)
// defaultPreferencesRow is the single (id=1) default_preferences row — the
// org-wide theme/accent/look/landing-page a brand-new user starts with,
// before they've ever saved a preference of their own. Admin-editable from
// Settings; existing users with a saved preference are unaffected.
type defaultPreferencesRow struct {
theme string
accent string
look string
landingPage string
}
func defaultDefaultPreferencesRow() defaultPreferencesRow {
return defaultPreferencesRow{theme: "system", accent: "oxide", look: "enterprise", landingPage: "/"}
}
func (s *Server) loadDefaultPreferencesRow(ctx context.Context) (defaultPreferencesRow, error) {
row := defaultDefaultPreferencesRow()
err := s.db.QueryRowContext(ctx, `SELECT theme, accent, look, landing_page FROM default_preferences WHERE id = 1`).
Scan(&row.theme, &row.accent, &row.look, &row.landingPage)
if err == sql.ErrNoRows {
return defaultDefaultPreferencesRow(), nil
}
if err != nil {
return defaultPreferencesRow{}, err
}
return row, nil
}
func (s *Server) saveDefaultPreferencesRow(ctx context.Context, row defaultPreferencesRow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO default_preferences (id, theme, accent, look, landing_page, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
theme = excluded.theme, accent = excluded.accent, look = excluded.look, landing_page = excluded.landing_page,
updated_at = excluded.updated_at`,
row.theme, row.accent, row.look, row.landingPage, time.Now().UTC().Format(time.RFC3339))
return err
}
type defaultPreferencesResponse struct {
Theme string `json:"theme"`
Accent string `json:"accent"`
Look string `json:"look"`
LandingPage string `json:"landingPage"`
}
func toDefaultPreferencesResponse(row defaultPreferencesRow) defaultPreferencesResponse {
return defaultPreferencesResponse{Theme: row.theme, Accent: row.accent, Look: row.look, LandingPage: row.landingPage}
}
func (s *Server) getDefaultPreferences(w http.ResponseWriter, r *http.Request) {
row, err := s.loadDefaultPreferencesRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, toDefaultPreferencesResponse(row))
}
type defaultPreferencesPatch struct {
Theme *string `json:"theme"`
Accent *string `json:"accent"`
Look *string `json:"look"`
LandingPage *string `json:"landingPage"`
}
func (s *Server) putDefaultPreferences(w http.ResponseWriter, r *http.Request) {
var patch defaultPreferencesPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
return
}
row, err := s.loadDefaultPreferencesRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if patch.Theme != nil {
if !validThemes[*patch.Theme] {
writeErrorMsg(w, http.StatusBadRequest, "theme must be one of: light, dark, system")
return
}
row.theme = *patch.Theme
}
if patch.Accent != nil {
if !validAccents[*patch.Accent] {
writeErrorMsg(w, http.StatusBadRequest, "accent must be one of: oxide, azure, verdant, violet, slate")
return
}
row.accent = *patch.Accent
}
if patch.Look != nil {
if !validLooks[*patch.Look] {
writeErrorMsg(w, http.StatusBadRequest, "unrecognized look")
return
}
row.look = *patch.Look
}
if patch.LandingPage != nil {
if !validLandingPages[*patch.LandingPage] {
writeErrorMsg(w, http.StatusBadRequest, "unrecognized landing page")
return
}
row.landingPage = *patch.LandingPage
}
if err := s.saveDefaultPreferencesRow(r.Context(), row); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "settings.defaults", "settings", "updated")
writeJSON(w, http.StatusOK, toDefaultPreferencesResponse(row))
}