From 98c7d6f5785182117b9fe6ebd6b892f860bc2024 Mon Sep 17 00:00:00 2001
From: Julien Dessaux
Date: Thu, 30 Jan 2025 00:19:16 +0100
Subject: 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 @@
home_storage
States
+
+ settings
+ Settings
+
logout
Logout
@@ -32,7 +36,7 @@
TFSTATED - {{ .Page.Title }}
-
+
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" }}
+
+
+
+{{ 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,
--
cgit v1.2.3