feat(webui): bootstrap account settings management with light and dark mode
This commit is contained in:
parent
ab043d8617
commit
98c7d6f578
13 changed files with 136 additions and 14 deletions
|
@ -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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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
7
pkg/model/settings.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type SettingsContextKey struct{}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
LightMode bool `json:"light_mode"`
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
25
pkg/webui/html/settings.html
Normal file
25
pkg/webui/html/settings.html
Normal 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 }}
|
|
@ -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 == "/" {
|
||||||
|
|
|
@ -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))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
53
pkg/webui/settings.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue