feat(webui): add user accounts list, admin middleware and admin restricted menu entries

This commit is contained in:
Julien Dessaux 2025-02-26 00:07:35 +01:00
parent a83296b79a
commit 5c6ff8f901
Signed by: adyxax
GPG key ID: F92E51B86E07177E
9 changed files with 143 additions and 2 deletions

View file

@ -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;`)

View file

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

31
pkg/webui/accounts.go Normal file
View file

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

28
pkg/webui/admin.go Normal file
View file

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

View file

@ -0,0 +1,35 @@
{{ define "main" }}
<main class="responsive" id="main">
<div>
<div class="tabs">
<a data-ui="#explorer"{{ if eq .ActiveTab 0 }} class="active"{{ end }}>User Accounts</a>
<a data-ui="#new"{{ if eq .ActiveTab 1 }} class="active"{{ end }}>Create New User Account</a>
</div>
<div id="explorer" class="page padding{{ if eq .ActiveTab 0 }} active{{ end }}">
<table class="clickable-rows no-space">
<thead>
<tr>
<th>Username</th>
<th>Created</th>
<th>Last Login</th>
<th>Is Admin</th>
</tr>
</thead>
<tbody>
{{ range .Accounts }}
<tr>
<td><a href="/accounts/{{ .Id }}">{{ .Username }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .Created }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .LastLogin }}</a></td>
<td><a href="/accounts/{{ .Id }}">{{ .IsAdmin }}</a></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<div id="new" class="page padding{{ if eq .ActiveTab 1 }} active{{ end }}">
<p>TODO</p>
</div>
</div>
</main>
{{ end }}

View file

@ -20,6 +20,12 @@
<i>settings</i>
<span>Settings</span>
</a>
{{ if .Page.IsAdmin }}
<a href="/accounts"{{ if eq .Page.Section "accounts" }} class="fill"{{ end}}>
<i>person</i>
<span>User Accounts</span>
</a>
{{ end }}
<a href="/logout">
<i>logout</i>
<span>Logout</span>

View file

@ -2,7 +2,7 @@
<main class="responsive" id="main">
<p>
Created by
<a href="/users/{{ .Account.Id }}">{{ .Account.Username }}</a>
<a href="/accounts/{{ .Account.Id }}" class="link underline">{{ .Account.Username }}</a>
at {{ .Version.Created }}
</p>
<div>

View file

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

View file

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