From 5c6ff8f9018f86917796f5244615b9433b9ecc94 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Wed, 26 Feb 2025 00:07:35 +0100 Subject: [PATCH] feat(webui): add user accounts list, admin middleware and admin restricted menu entries --- pkg/database/accounts.go | 36 ++++++++++++++++++++++++++++++++++++ pkg/database/states.go | 2 +- pkg/webui/accounts.go | 31 +++++++++++++++++++++++++++++++ pkg/webui/admin.go | 28 ++++++++++++++++++++++++++++ pkg/webui/html/accounts.html | 35 +++++++++++++++++++++++++++++++++++ pkg/webui/html/base.html | 6 ++++++ pkg/webui/html/versions.html | 2 +- pkg/webui/index.go | 3 +++ pkg/webui/routes.go | 2 ++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 pkg/webui/accounts.go create mode 100644 pkg/webui/admin.go create mode 100644 pkg/webui/html/accounts.html diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go index 2c1dc6d..dcd7159 100644 --- a/pkg/database/accounts.go +++ b/pkg/database/accounts.go @@ -54,6 +54,42 @@ func (db *DB) InitAdminAccount() error { }) } +func (db *DB) LoadAccounts() ([]model.Account, error) { + rows, err := db.Query( + `SELECT id, username, salt, password_hash, is_admin, created, last_login, settings FROM accounts;`) + if err != nil { + return nil, fmt.Errorf("failed to load accounts from database: %w", err) + } + defer rows.Close() + accounts := make([]model.Account, 0) + for rows.Next() { + var ( + account model.Account + created int64 + lastLogin int64 + ) + err = rows.Scan( + &account.Id, + &account.Username, + &account.Salt, + &account.PasswordHash, + &account.IsAdmin, + &created, + &lastLogin, + &account.Settings) + if err != nil { + return nil, fmt.Errorf("failed to load account from row: %w", err) + } + account.Created = time.Unix(created, 0) + account.LastLogin = time.Unix(lastLogin, 0) + accounts = append(accounts, account) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to load accounts from rows: %w", err) + } + return accounts, nil +} + func (db *DB) LoadAccountUsernames() (map[string]string, error) { rows, err := db.Query( `SELECT id, username FROM accounts;`) diff --git a/pkg/database/states.go b/pkg/database/states.go index d637b14..e213d2c 100644 --- a/pkg/database/states.go +++ b/pkg/database/states.go @@ -122,8 +122,8 @@ func (db *DB) LoadStates() ([]model.State, error) { defer rows.Close() states := make([]model.State, 0) for rows.Next() { - var state model.State var ( + state model.State created int64 updated int64 ) diff --git a/pkg/webui/accounts.go b/pkg/webui/accounts.go new file mode 100644 index 0000000..7ca9d39 --- /dev/null +++ b/pkg/webui/accounts.go @@ -0,0 +1,31 @@ +package webui + +import ( + "html/template" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +type AccountsPage struct { + ActiveTab int + Page *Page + Accounts []model.Account +} + +var accountsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accounts.html")) + +func handleAccountsGET(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accounts, err := db.LoadAccounts() + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + render(w, accountsTemplates, http.StatusOK, AccountsPage{ + Page: makePage(r, &Page{Title: "User Accounts", Section: "accounts"}), + Accounts: accounts, + }) + }) +} diff --git a/pkg/webui/admin.go b/pkg/webui/admin.go new file mode 100644 index 0000000..54682d7 --- /dev/null +++ b/pkg/webui/admin.go @@ -0,0 +1,28 @@ +package webui + +import ( + "fmt" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +func adminMiddleware(db *database.DB, requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account := r.Context().Value(model.AccountContextKey{}) + if account == nil { + // this could happen if the account was deleted in the short + // time between retrieving the session and here + http.Redirect(w, r, "/login", http.StatusFound) + return + } + if !account.(*model.Account).IsAdmin { + errorResponse(w, http.StatusForbidden, fmt.Errorf("Only administrators can perform this request.")) + return + } + next.ServeHTTP(w, r) + })) + } +} diff --git a/pkg/webui/html/accounts.html b/pkg/webui/html/accounts.html new file mode 100644 index 0000000..f1ded93 --- /dev/null +++ b/pkg/webui/html/accounts.html @@ -0,0 +1,35 @@ +{{ define "main" }} +
+
+ +
+ + + + + + + + + + + {{ range .Accounts }} + + + + + + + {{ end }} + +
UsernameCreatedLast LoginIs Admin
{{ .Username }}{{ .Created }}{{ .LastLogin }}{{ .IsAdmin }}
+
+
+

TODO

+
+
+
+{{ end }} diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html index 4ec6565..0d19cf5 100644 --- a/pkg/webui/html/base.html +++ b/pkg/webui/html/base.html @@ -20,6 +20,12 @@ settings Settings +{{ if .Page.IsAdmin }} + + person + User Accounts + +{{ end }} logout Logout diff --git a/pkg/webui/html/versions.html b/pkg/webui/html/versions.html index ea65647..8adab56 100644 --- a/pkg/webui/html/versions.html +++ b/pkg/webui/html/versions.html @@ -2,7 +2,7 @@

Created by - {{ .Account.Username }} + {{ .Account.Username }} at {{ .Version.Created }}

diff --git a/pkg/webui/index.go b/pkg/webui/index.go index 1168098..28a4964 100644 --- a/pkg/webui/index.go +++ b/pkg/webui/index.go @@ -8,6 +8,7 @@ import ( ) type Page struct { + IsAdmin bool LightMode bool Precedent string Section string @@ -15,6 +16,8 @@ type Page struct { } func makePage(r *http.Request, page *Page) *Page { + account := r.Context().Value(model.AccountContextKey{}).(*model.Account) + page.IsAdmin = account.IsAdmin settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings) page.LightMode = settings.LightMode return page diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go index 1fce700..2037df6 100644 --- a/pkg/webui/routes.go +++ b/pkg/webui/routes.go @@ -12,6 +12,8 @@ func addRoutes( ) { requireSession := sessionsMiddleware(db) requireLogin := loginMiddleware(db, requireSession) + requireAdmin := adminMiddleware(db, requireLogin) + mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db))) mux.Handle("GET /healthz", handleHealthz()) mux.Handle("GET /login", requireSession(handleLoginGET())) mux.Handle("POST /login", requireSession(handleLoginPOST(db)))