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