feat(webui): bootstrap account settings management with light and dark mode

This commit is contained in:
Julien Dessaux 2025-01-30 00:19:16 +01:00
parent ab043d8617
commit 98c7d6f578
Signed by: adyxax
GPG key ID: F92E51B86E07177E
13 changed files with 136 additions and 14 deletions

View file

@ -2,6 +2,7 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@ -31,12 +32,13 @@ func (db *DB) InitAdminAccount() error {
salt := helpers.GenerateSalt() salt := helpers.GenerateSalt()
hash := helpers.HashPassword(password.String(), salt) hash := helpers.HashPassword(password.String(), salt)
if _, err := tx.ExecContext(db.ctx, if _, err := tx.ExecContext(db.ctx,
`INSERT INTO accounts(username, salt, password_hash, is_admin) `INSERT INTO accounts(username, salt, password_hash, is_admin, settings)
VALUES ("admin", :salt, :hash, TRUE) VALUES ("admin", :salt, :hash, TRUE, :settings)
ON CONFLICT DO UPDATE SET password_hash = :hash ON CONFLICT DO UPDATE SET password_hash = :hash
WHERE username = "admin";`, WHERE username = "admin";`,
sql.Named("salt", salt), sql.Named("salt", salt),
sql.Named("hash", hash), sql.Named("hash", hash),
[]byte("{}"),
); err == nil { ); err == nil {
AdvertiseAdminPassword(password.String()) AdvertiseAdminPassword(password.String())
} else { } else {
@ -136,6 +138,18 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
return &account, nil 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 { func (db *DB) TouchAccount(account *model.Account) error {
now := time.Now().UTC() now := time.Now().UTC()
_, err := db.Exec(`UPDATE accounts SET last_login = ? WHERE id = ?`, now.Unix(), account.Id) _, err := db.Exec(`UPDATE accounts SET last_login = ? WHERE id = ?`, now.Unix(), account.Id)

View file

@ -10,7 +10,7 @@ CREATE TABLE accounts (
is_admin INTEGER NOT NULL DEFAULT FALSE, is_admin INTEGER NOT NULL DEFAULT FALSE,
created INTEGER NOT NULL DEFAULT (unixepoch()), created INTEGER NOT NULL DEFAULT (unixepoch()),
last_login INTEGER NOT NULL DEFAULT (unixepoch()), last_login INTEGER NOT NULL DEFAULT (unixepoch()),
settings TEXT settings BLOB NOT NULL
) STRICT; ) STRICT;
CREATE UNIQUE INDEX accounts_username on accounts(username); CREATE UNIQUE INDEX accounts_username on accounts(username);

View file

@ -2,6 +2,7 @@ package model
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json"
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/helpers" "git.adyxax.org/adyxax/tfstated/pkg/helpers"
@ -17,7 +18,7 @@ type Account struct {
IsAdmin bool IsAdmin bool
Created time.Time Created time.Time
LastLogin time.Time LastLogin time.Time
Settings any Settings json.RawMessage
} }
func (account *Account) CheckPassword(password string) bool { func (account *Account) CheckPassword(password string) bool {

7
pkg/model/settings.go Normal file
View file

@ -0,0 +1,7 @@
package model
type SettingsContextKey struct{}
type Settings struct {
LightMode bool `json:"light_mode"`
}

View file

@ -16,6 +16,10 @@
<i>home_storage</i> <i>home_storage</i>
<span>States</span> <span>States</span>
</a> </a>
<a href="/settings"{{ if eq .Page.Section "settings" }} class="fill"{{ end}}>
<i>settings</i>
<span>Settings</span>
</a>
<a href="/logout"> <a href="/logout">
<i>logout</i> <i>logout</i>
<span>Logout</span> <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"> <link href="https://cdn.jsdelivr.net/npm/beercss@3.8.0/dist/cdn/beer.min.css" rel="stylesheet">
<title>TFSTATED - {{ .Page.Title }}</title> <title>TFSTATED - {{ .Page.Title }}</title>
</head> </head>
<body class="dark"> <body class="{{ if .Page.LightMode }}light{{ else }}dark{{ end }}">
<nav class="left drawer l">{{ template "nav" . }}</nav> <nav class="left drawer l">{{ template "nav" . }}</nav>
<nav class="left m">{{ template "nav" . }}</nav> <nav class="left m">{{ template "nav" . }}</nav>
<nav class="bottom s">{{ template "nav" . }}</nav> <nav class="bottom s">{{ template "nav" . }}</nav>

View file

@ -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 }}

View file

@ -3,14 +3,23 @@ package webui
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"git.adyxax.org/adyxax/tfstated/pkg/model"
) )
type Page struct { type Page struct {
LightMode bool
Precedent string Precedent string
Section string Section string
Title 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 { func handleIndexGET() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {

View file

@ -2,8 +2,10 @@ package webui
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"regexp" "regexp"
@ -113,6 +115,11 @@ func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Han
return return
} }
ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account) 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)) next.ServeHTTP(w, r.WithContext(ctx))
})) }))
} }

View file

@ -16,6 +16,8 @@ func addRoutes(
mux.Handle("GET /login", requireSession(handleLoginGET())) mux.Handle("GET /login", requireSession(handleLoginGET()))
mux.Handle("POST /login", requireSession(handleLoginPOST(db))) mux.Handle("POST /login", requireSession(handleLoginPOST(db)))
mux.Handle("GET /logout", requireLogin(handleLogoutGET(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 /states", requireLogin(handleStatesGET(db)))
mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db))) mux.Handle("GET /state/{id}", requireLogin(handleStateGET(db)))
mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS)))) mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))

53
pkg/webui/settings.go Normal file
View file

@ -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,
})
})
}

View file

@ -13,7 +13,7 @@ var stateTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "ht
func handleStateGET(db *database.DB) http.Handler { func handleStateGET(db *database.DB) http.Handler {
type StatesData struct { type StatesData struct {
Page Page *Page
State *model.State State *model.State
Usernames map[int]string Usernames map[int]string
Versions []model.Version Versions []model.Version
@ -41,11 +41,11 @@ func handleStateGET(db *database.DB) http.Handler {
return return
} }
render(w, stateTemplate, http.StatusOK, StatesData{ render(w, stateTemplate, http.StatusOK, StatesData{
Page: Page{ Page: makePage(r, &Page{
Precedent: "/states", Precedent: "/states",
Section: "states", Section: "states",
Title: state.Path, Title: state.Path,
}, }),
State: state, State: state,
Usernames: usernames, Usernames: usernames,
Versions: versions, Versions: versions,

View file

@ -12,7 +12,7 @@ var statesTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "
func handleStatesGET(db *database.DB) http.Handler { func handleStatesGET(db *database.DB) http.Handler {
type StatesData struct { type StatesData struct {
Page Page *Page
States []model.State States []model.State
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -22,7 +22,7 @@ func handleStatesGET(db *database.DB) http.Handler {
return return
} }
render(w, statesTemplates, http.StatusOK, StatesData{ render(w, statesTemplates, http.StatusOK, StatesData{
Page: Page{Title: "States", Section: "states"}, Page: makePage(r, &Page{Title: "States", Section: "states"}),
States: states, States: states,
}) })
}) })

View file

@ -14,7 +14,7 @@ var versionTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "
func handleVersionGET(db *database.DB) http.Handler { func handleVersionGET(db *database.DB) http.Handler {
type VersionsData struct { type VersionsData struct {
Page Page *Page
Account *model.Account Account *model.Account
State *model.State State *model.State
Version *model.Version Version *model.Version
@ -44,11 +44,11 @@ func handleVersionGET(db *database.DB) http.Handler {
} }
versionData := string(version.Data[:]) versionData := string(version.Data[:])
render(w, versionTemplate, http.StatusOK, VersionsData{ render(w, versionTemplate, http.StatusOK, VersionsData{
Page: Page{ Page: makePage(r, &Page{
Precedent: fmt.Sprintf("/state/%d", state.Id), Precedent: fmt.Sprintf("/state/%d", state.Id),
Section: "versions", Section: "states",
Title: state.Path, Title: state.Path,
}, }),
Account: account, Account: account,
State: state, State: state,
Version: version, Version: version,