From 98c7d6f5785182117b9fe6ebd6b892f860bc2024 Mon Sep 17 00:00:00 2001
From: Julien Dessaux <julien.dessaux@adyxax.org>
Date: Thu, 30 Jan 2025 00:19:16 +0100
Subject: [PATCH] feat(webui): bootstrap account settings management with light
 and dark mode

---
 pkg/database/accounts.go      | 18 ++++++++++--
 pkg/database/sql/000_init.sql |  2 +-
 pkg/model/account.go          |  3 +-
 pkg/model/settings.go         |  7 +++++
 pkg/webui/html/base.html      |  6 +++-
 pkg/webui/html/settings.html  | 25 +++++++++++++++++
 pkg/webui/index.go            |  9 ++++++
 pkg/webui/login.go            |  7 +++++
 pkg/webui/routes.go           |  2 ++
 pkg/webui/settings.go         | 53 +++++++++++++++++++++++++++++++++++
 pkg/webui/state.go            |  6 ++--
 pkg/webui/states.go           |  4 +--
 pkg/webui/version.go          |  8 +++---
 13 files changed, 136 insertions(+), 14 deletions(-)
 create mode 100644 pkg/model/settings.go
 create mode 100644 pkg/webui/html/settings.html
 create mode 100644 pkg/webui/settings.go

diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go
index 9adb32d..e6363f1 100644
--- a/pkg/database/accounts.go
+++ b/pkg/database/accounts.go
@@ -2,6 +2,7 @@ package database
 
 import (
 	"database/sql"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -31,12 +32,13 @@ func (db *DB) InitAdminAccount() error {
 			salt := helpers.GenerateSalt()
 			hash := helpers.HashPassword(password.String(), salt)
 			if _, err := tx.ExecContext(db.ctx,
-				`INSERT INTO accounts(username, salt, password_hash, is_admin)
-		       VALUES ("admin", :salt, :hash, TRUE)
+				`INSERT INTO accounts(username, salt, password_hash, is_admin, settings)
+		       VALUES ("admin", :salt, :hash, TRUE, :settings)
 		       ON CONFLICT DO UPDATE SET password_hash = :hash
 		         WHERE username = "admin";`,
 				sql.Named("salt", salt),
 				sql.Named("hash", hash),
+				[]byte("{}"),
 			); err == nil {
 				AdvertiseAdminPassword(password.String())
 			} else {
@@ -136,6 +138,18 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
 	return &account, nil
 }
 
+func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error {
+	data, err := json.Marshal(settings)
+	if err != nil {
+		return fmt.Errorf("failed to marshal settings for user %s: %w", account.Username, err)
+	}
+	_, err = db.Exec(`UPDATE accounts SET settings = ? WHERE id = ?`, data, account.Id)
+	if err != nil {
+		return fmt.Errorf("failed to update settings for user %s: %w", account.Username, err)
+	}
+	return nil
+}
+
 func (db *DB) TouchAccount(account *model.Account) error {
 	now := time.Now().UTC()
 	_, err := db.Exec(`UPDATE accounts SET last_login = ? WHERE id = ?`, now.Unix(), account.Id)
diff --git a/pkg/database/sql/000_init.sql b/pkg/database/sql/000_init.sql
index 0bcdee1..80e8c8c 100644
--- a/pkg/database/sql/000_init.sql
+++ b/pkg/database/sql/000_init.sql
@@ -10,7 +10,7 @@ CREATE TABLE accounts (
   is_admin INTEGER NOT NULL DEFAULT FALSE,
   created INTEGER NOT NULL DEFAULT (unixepoch()),
   last_login INTEGER NOT NULL DEFAULT (unixepoch()),
-  settings TEXT
+  settings BLOB NOT NULL
 ) STRICT;
 CREATE UNIQUE INDEX accounts_username on accounts(username);
 
diff --git a/pkg/model/account.go b/pkg/model/account.go
index 7a69685..5055943 100644
--- a/pkg/model/account.go
+++ b/pkg/model/account.go
@@ -2,6 +2,7 @@ package model
 
 import (
 	"crypto/subtle"
+	"encoding/json"
 	"time"
 
 	"git.adyxax.org/adyxax/tfstated/pkg/helpers"
@@ -17,7 +18,7 @@ type Account struct {
 	IsAdmin      bool
 	Created      time.Time
 	LastLogin    time.Time
-	Settings     any
+	Settings     json.RawMessage
 }
 
 func (account *Account) CheckPassword(password string) bool {
diff --git a/pkg/model/settings.go b/pkg/model/settings.go
new file mode 100644
index 0000000..9da655b
--- /dev/null
+++ b/pkg/model/settings.go
@@ -0,0 +1,7 @@
+package model
+
+type SettingsContextKey struct{}
+
+type Settings struct {
+	LightMode bool `json:"light_mode"`
+}
diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html
index c0138ac..4ec6565 100644
--- a/pkg/webui/html/base.html
+++ b/pkg/webui/html/base.html
@@ -16,6 +16,10 @@
   <i>home_storage</i>
   <span>States</span>
 </a>
+<a href="/settings"{{ if eq .Page.Section "settings" }} class="fill"{{ end}}>
+  <i>settings</i>
+  <span>Settings</span>
+</a>
 <a href="/logout">
   <i>logout</i>
   <span>Logout</span>
@@ -32,7 +36,7 @@
     <link href="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.css" rel="stylesheet">
     <title>TFSTATED - {{ .Page.Title }}</title>
   </head>
-  <body class="dark">
+  <body class="{{ if .Page.LightMode }}light{{ else }}dark{{ end }}">
     <nav class="left drawer l">{{ template "nav" . }}</nav>
     <nav class="left m">{{ template "nav" . }}</nav>
     <nav class="bottom s">{{ template "nav" . }}</nav>
diff --git a/pkg/webui/html/settings.html b/pkg/webui/html/settings.html
new file mode 100644
index 0000000..4040b9b
--- /dev/null
+++ b/pkg/webui/html/settings.html
@@ -0,0 +1,25 @@
+{{ define "main" }}
+<main class="responsive">
+  <form action="/settings" method="post">
+    <fieldset>
+      <div class="field middle-align">
+        <nav>
+          <div class="max">
+            <h6>Dark Mode</h6>
+          </div>
+          <label class="switch icon">
+            <input {{ if not .Settings.LightMode }} checked{{ end }}
+                   name="dark-mode"
+                   type="checkbox"
+                   value="1" />
+            <span>
+              <i>dark_mode</i>
+            </span>
+          </label>
+        </nav>
+      </div>
+      <button class="small-round" type="submit" value="login">Save</button>
+    </fieldset>
+  </form>
+</main>
+{{ end }}
diff --git a/pkg/webui/index.go b/pkg/webui/index.go
index 89e5ad6..1168098 100644
--- a/pkg/webui/index.go
+++ b/pkg/webui/index.go
@@ -3,14 +3,23 @@ package webui
 import (
 	"fmt"
 	"net/http"
+
+	"git.adyxax.org/adyxax/tfstated/pkg/model"
 )
 
 type Page struct {
+	LightMode bool
 	Precedent string
 	Section   string
 	Title     string
 }
 
+func makePage(r *http.Request, page *Page) *Page {
+	settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
+	page.LightMode = settings.LightMode
+	return page
+}
+
 func handleIndexGET() http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if r.URL.Path == "/" {
diff --git a/pkg/webui/login.go b/pkg/webui/login.go
index 6f688c1..18864b2 100644
--- a/pkg/webui/login.go
+++ b/pkg/webui/login.go
@@ -2,8 +2,10 @@ package webui
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"html/template"
+	"log/slog"
 	"net/http"
 	"regexp"
 
@@ -113,6 +115,11 @@ func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Han
 				return
 			}
 			ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account)
+			var settings model.Settings
+			if err := json.Unmarshal(account.Settings, &settings); err != nil {
+				slog.Error("failed to unmarshal account settings", "err", err, "accountId", account.Id)
+			}
+			ctx = context.WithValue(ctx, model.SettingsContextKey{}, &settings)
 			next.ServeHTTP(w, r.WithContext(ctx))
 		}))
 	}
diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go
index 84017dd..e84f33a 100644
--- a/pkg/webui/routes.go
+++ b/pkg/webui/routes.go
@@ -16,6 +16,8 @@ func addRoutes(
 	mux.Handle("GET /login", requireSession(handleLoginGET()))
 	mux.Handle("POST /login", requireSession(handleLoginPOST(db)))
 	mux.Handle("GET /logout", requireLogin(handleLogoutGET(db)))
+	mux.Handle("GET /settings", requireLogin(handleSettingsGET(db)))
+	mux.Handle("POST /settings", requireLogin(handleSettingsPOST(db)))
 	mux.Handle("GET /states", requireLogin(handleStatesGET(db)))
 	mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db)))
 	mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
diff --git a/pkg/webui/settings.go b/pkg/webui/settings.go
new file mode 100644
index 0000000..eb0910f
--- /dev/null
+++ b/pkg/webui/settings.go
@@ -0,0 +1,53 @@
+package webui
+
+import (
+	"html/template"
+	"net/http"
+
+	"git.adyxax.org/adyxax/tfstated/pkg/database"
+	"git.adyxax.org/adyxax/tfstated/pkg/model"
+)
+
+type SettingsPage struct {
+	Page     *Page
+	Settings *model.Settings
+}
+
+var settingsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/settings.html"))
+
+func handleSettingsGET(db *database.DB) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
+		render(w, settingsTemplates, http.StatusOK, SettingsPage{
+			Page:     makePage(r, &Page{Title: "Settings", Section: "settings"}),
+			Settings: settings,
+		})
+	})
+}
+
+func handleSettingsPOST(db *database.DB) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if err := r.ParseForm(); err != nil {
+			errorResponse(w, http.StatusBadRequest, err)
+			return
+		}
+		darkMode := r.FormValue("dark-mode")
+		settings := model.Settings{
+			LightMode: darkMode != "1",
+		}
+		account := r.Context().Value(model.AccountContextKey{}).(*model.Account)
+		err := db.SaveAccountSettings(account, &settings)
+		if err != nil {
+			errorResponse(w, http.StatusInternalServerError, err)
+			return
+		}
+		render(w, settingsTemplates, http.StatusOK, SettingsPage{
+			Page: &Page{
+				LightMode: settings.LightMode,
+				Title:     "Settings",
+				Section:   "settings",
+			},
+			Settings: &settings,
+		})
+	})
+}
diff --git a/pkg/webui/state.go b/pkg/webui/state.go
index c7a6aaf..141d2d8 100644
--- a/pkg/webui/state.go
+++ b/pkg/webui/state.go
@@ -13,7 +13,7 @@ var stateTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "ht
 
 func handleStateGET(db *database.DB) http.Handler {
 	type StatesData struct {
-		Page
+		Page      *Page
 		State     *model.State
 		Usernames map[int]string
 		Versions  []model.Version
@@ -41,11 +41,11 @@ func handleStateGET(db *database.DB) http.Handler {
 			return
 		}
 		render(w, stateTemplate, http.StatusOK, StatesData{
-			Page: Page{
+			Page: makePage(r, &Page{
 				Precedent: "/states",
 				Section:   "states",
 				Title:     state.Path,
-			},
+			}),
 			State:     state,
 			Usernames: usernames,
 			Versions:  versions,
diff --git a/pkg/webui/states.go b/pkg/webui/states.go
index d99d310..a0d16ca 100644
--- a/pkg/webui/states.go
+++ b/pkg/webui/states.go
@@ -12,7 +12,7 @@ var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "
 
 func handleStatesGET(db *database.DB) http.Handler {
 	type StatesData struct {
-		Page
+		Page   *Page
 		States []model.State
 	}
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -22,7 +22,7 @@ func handleStatesGET(db *database.DB) http.Handler {
 			return
 		}
 		render(w, statesTemplates, http.StatusOK, StatesData{
-			Page:   Page{Title: "States", Section: "states"},
+			Page:   makePage(r, &Page{Title: "States", Section: "states"}),
 			States: states,
 		})
 	})
diff --git a/pkg/webui/version.go b/pkg/webui/version.go
index 04c3e6d..2aa1422 100644
--- a/pkg/webui/version.go
+++ b/pkg/webui/version.go
@@ -14,7 +14,7 @@ var versionTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "
 
 func handleVersionGET(db *database.DB) http.Handler {
 	type VersionsData struct {
-		Page
+		Page        *Page
 		Account     *model.Account
 		State       *model.State
 		Version     *model.Version
@@ -44,11 +44,11 @@ func handleVersionGET(db *database.DB) http.Handler {
 		}
 		versionData := string(version.Data[:])
 		render(w, versionTemplate, http.StatusOK, VersionsData{
-			Page: Page{
+			Page: makePage(r, &Page{
 				Precedent: fmt.Sprintf("/state/%d", state.Id),
-				Section:   "versions",
+				Section:   "states",
 				Title:     state.Path,
-			},
+			}),
 			Account:     account,
 			State:       state,
 			Version:     version,