feat(webui): add user account status page
All checks were successful
main / main (push) Successful in 1m53s
main / deploy (push) Has been skipped
main / publish (push) Has been skipped

This commit is contained in:
Julien Dessaux 2025-04-13 09:33:11 +02:00
parent a22b2953e4
commit 2bf1731343
Signed by: adyxax
GPG key ID: F92E51B86E07177E
10 changed files with 230 additions and 12 deletions

View file

@ -49,7 +49,7 @@ func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, erro
Id: accountId, Id: accountId,
Username: username, Username: username,
IsAdmin: isAdmin, IsAdmin: isAdmin,
PasswordReset: passwordReset, PasswordReset: &passwordReset,
}, nil }, nil
} }
@ -91,7 +91,7 @@ func (db *DB) InitAdminAccount() error {
func (db *DB) LoadAccounts() ([]model.Account, error) { func (db *DB) LoadAccounts() ([]model.Account, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT id, username, salt, password_hash, is_admin, created, last_login, settings FROM accounts;`) `SELECT id, username, salt, password_hash, is_admin, created, last_login, settings, password_reset FROM accounts;`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load accounts from database: %w", err) return nil, fmt.Errorf("failed to load accounts from database: %w", err)
} }
@ -111,7 +111,8 @@ func (db *DB) LoadAccounts() ([]model.Account, error) {
&account.IsAdmin, &account.IsAdmin,
&created, &created,
&lastLogin, &lastLogin,
&account.Settings) &account.Settings,
&account.PasswordReset)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load account from row: %w", err) return nil, fmt.Errorf("failed to load account from row: %w", err)
} }
@ -159,7 +160,7 @@ func (db *DB) LoadAccountById(id uuid.UUID) (*model.Account, error) {
lastLogin int64 lastLogin int64
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT username, salt, password_hash, is_admin, created, last_login, settings `SELECT username, salt, password_hash, is_admin, created, last_login, settings, password_reset
FROM accounts FROM accounts
WHERE id = ?;`, WHERE id = ?;`,
id, id,
@ -170,7 +171,7 @@ func (db *DB) LoadAccountById(id uuid.UUID) (*model.Account, error) {
&created, &created,
&lastLogin, &lastLogin,
&account.Settings, &account.Settings,
) &account.PasswordReset)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
@ -191,7 +192,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
lastLogin int64 lastLogin int64
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT id, salt, password_hash, is_admin, created, last_login, settings `SELECT id, salt, password_hash, is_admin, created, last_login, settings, password_reset
FROM accounts FROM accounts
WHERE username = ?;`, WHERE username = ?;`,
username, username,
@ -202,7 +203,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
&created, &created,
&lastLogin, &lastLogin,
&account.Settings, &account.Settings,
) &account.PasswordReset)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil

View file

@ -113,6 +113,31 @@ func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) {
return &state, nil return &state, nil
} }
func (db *DB) LoadStatePaths() (map[string]string, error) {
rows, err := db.Query(
`SELECT id, path FROM states;`)
if err != nil {
return nil, fmt.Errorf("failed to load states from database: %w", err)
}
defer rows.Close()
states := make(map[string]string)
for rows.Next() {
var (
id string
path string
)
err = rows.Scan(&id, &path)
if err != nil {
return nil, fmt.Errorf("failed to load state from row: %w", err)
}
states[id] = path
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to load states from rows: %w", err)
}
return states, nil
}
func (db *DB) LoadStates() ([]model.State, error) { func (db *DB) LoadStates() ([]model.State, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT created, id, lock, path, updated FROM states;`) `SELECT created, id, lock, path, updated FROM states;`)

View file

@ -52,7 +52,7 @@ func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) {
defer rows.Close() defer rows.Close()
versions := make([]model.Version, 0) versions := make([]model.Version, 0)
for rows.Next() { for rows.Next() {
var version model.Version version := model.Version{StateId: state.Id}
var created int64 var created int64
err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &version.Lock) err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &version.Lock)
if err != nil { if err != nil {
@ -66,3 +66,30 @@ func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) {
} }
return versions, nil return versions, nil
} }
func (db *DB) LoadVersionsByAccount(account *model.Account) ([]model.Version, error) {
rows, err := db.Query(
`SELECT created, data, id, lock, state_id
FROM versions
WHERE account_id = ?
ORDER BY id DESC;`, account.Id)
if err != nil {
return nil, fmt.Errorf("failed to load versions from database: %w", err)
}
defer rows.Close()
versions := make([]model.Version, 0)
for rows.Next() {
version := model.Version{AccountId: account.Id}
var created int64
err = rows.Scan(&created, &version.Data, &version.Id, &version.Lock, &version.StateId)
if err != nil {
return nil, fmt.Errorf("failed to load version from row: %w", err)
}
version.Created = time.Unix(created, 0)
versions = append(versions, version)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to load versions from rows: %w", err)
}
return versions, nil
}

View file

@ -20,7 +20,7 @@ type Account struct {
Created time.Time Created time.Time
LastLogin time.Time LastLogin time.Time
Settings json.RawMessage Settings json.RawMessage
PasswordReset uuid.UUID PasswordReset *uuid.UUID
} }
func (account *Account) CheckPassword(password string) bool { func (account *Account) CheckPassword(password string) bool {

66
pkg/webui/accountsId.go Normal file
View file

@ -0,0 +1,66 @@
package webui
import (
"html/template"
"net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
)
type AccountsIdPage struct {
Account *model.Account
IsAdmin string
Page *Page
Username string
StatePaths map[string]string
UsernameDuplicate bool
UsernameInvalid bool
Versions []model.Version
}
var accountsIdTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsId.html"))
func handleAccountsIdGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var accountId uuid.UUID
if err := accountId.Parse(r.PathValue("id")); err != nil {
errorResponse(w, r, http.StatusBadRequest, err)
return
}
account, err := db.LoadAccountById(accountId)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
if account == nil {
errorResponse(w, r, http.StatusNotFound, err)
return
}
statePaths, err := db.LoadStatePaths()
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
versions, err := db.LoadVersionsByAccount(account)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
isAdmin := ""
if account.IsAdmin {
isAdmin = "1"
}
render(w, accountsIdTemplates, http.StatusOK, AccountsIdPage{
Account: account,
IsAdmin: isAdmin,
Page: makePage(r, &Page{
Section: "accounts",
Title: account.Username,
}),
StatePaths: statePaths,
Versions: versions,
})
})
}

View file

@ -2,7 +2,7 @@
<h1>User Accounts</h1> <h1>User Accounts</h1>
<div class="flex-row" style="justify-content:space-between;"> <div class="flex-row" style="justify-content:space-between;">
<div style="min-width:240px;"> <div style="min-width:240px;">
<p>There are <span class=button>{{ len .Accounts }}</span> user accounts.</p> <p>There are <span class="button">{{ len .Accounts }}</span> user accounts.</p>
<p>Use this page to inspect user accounts or create a new one.</p> <p>Use this page to inspect user accounts or create a new one.</p>
</div> </div>
<form action="/accounts" enctype="multipart/form-data" method="post"> <form action="/accounts" enctype="multipart/form-data" method="post">
@ -16,6 +16,12 @@
required required
type="text" type="text"
value="{{ .Username }}"> value="{{ .Username }}">
<label for="is-admin">Is Admin</label>
<input {{ if .IsAdmin }} checked{{ end }}
id="is-admin"
name="is-admin"
type="checkbox"
value="{{ .IsAdmin }}" />
</div> </div>
{{ if .UsernameDuplicate }} {{ if .UsernameDuplicate }}
<span class="error">This username already exist.</span> <span class="error">This username already exist.</span>

View file

@ -0,0 +1,92 @@
{{ define "main" }}
<h1>User Account</h1>
{{ if ne .Account.PasswordReset nil }}
<h2>Password Reset</h2>
<article>
Direct the user to <a href="/account/{{ .Account.Id }}/reset/{{ .Account.PasswordReset }}">/account/{{ .Account.Id }}/reset/{{ .Account.PasswordReset }}</a> so that they can create their password.
</article>
{{ end }}
<h2>Status</h2>
<p>
The
account
<strong>{{ .Account.Username }}</strong>
was created on
<strong>{{ .Account.Created }}</strong>
and
{{ if eq .Account.Created .Account.LastLogin }}
<strong>never logged in</strong>.
{{ else }}
last logged in on
<strong>{{ .Account.LastLogin }}</strong>.
{{ end }}
</p>
{{ if .Account.IsAdmin }}
<p>This accounts has <strong>admin</strong> privileges on TfStated.</p>
{{ end }}
<h2>Operations</h2>
<form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post">
<div class="flex-row">
<fieldset>
<legend>Edit User Account</legend>
<div class="grid-2">
<label for="username" style="min-width:92px;">Username</label>
<input {{ if or .UsernameDuplicate .UsernameInvalid }}class="error"{{ end }}
id="username"
name="username"
type="text"
value="{{ .Username }}">
<label for="is-admin">Is Admin</label>
<input {{ if .Account.IsAdmin }} checked{{ end }}
id="is-admin"
name="is-admin"
type="checkbox"
value="{{ .IsAdmin }}" />
</div>
{{ if .UsernameDuplicate }}
<span class="error">This username already exist.</span>
{{ else if .UsernameInvalid }}
<span class="error">
<span class="tooltip">
Invalid username.
<span class="tooltip-text">
Username must start with a letter and be composed of only letters, numbers or underscores.
</span>
</span>
</span>
{{ end }}
<div style="align-self:stretch; display:flex; justify-content:flex-end;">
<button type="submit" value="edit">Edit User Account</button>
</div>
</fieldset>
<fieldset>
<legend>Danger Zone</legend>
<button type="submit" value="delete">Delete User Account</button>
<!--<button type="submit" value="lock">Lock User Account</button>-->
<button type="submit" value="reset-password">Reset Password</button>
</fieldset>
</div>
</form>
{{ if gt (len .Versions) 0 }}
<h2>Activity</h2>
<article>
<table style="width:100%;">
<thead>
<tr>
<th>State</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{ range .Versions }}
<tr>
<td><a href="/states/{{ .StateId }}">{{ index $.StatePaths .StateId.String }}</a></td>
<td><a href="/versions/{{ .Id }}">{{ .Created }}</a></td>
</tr>
{{ end }}
</tbody>
</table>
</article>
{{ end }}
<a href="/accounts">Go back to the user accounts list</a>
{{ end }}

View file

@ -2,7 +2,7 @@
<h1>States</h1> <h1>States</h1>
<div class="flex-row" style="justify-content:space-between;"> <div class="flex-row" style="justify-content:space-between;">
<div style="min-width:240px;"> <div style="min-width:240px;">
<p>TfStated is currently managing <span class=button>{{ len .States }}</span> states.</p> <p>TfStated is currently managing <span class="button">{{ len .States }}</span> states.</p>
<p>Use this page to inspect the existing states.</p> <p>Use this page to inspect the existing states.</p>
<p>You also have the option to upload a JSON state file in order to create a new state in TfStated. This is equivalent to using the <code>state push</code> command of OpenTofu/Terraform on a brand new state.</p> <p>You also have the option to upload a JSON state file in order to create a new state in TfStated. This is equivalent to using the <code>state push</code> command of OpenTofu/Terraform on a brand new state.</p>
</div> </div>

View file

@ -33,7 +33,7 @@
{{ range .Versions }} {{ range .Versions }}
<tr> <tr>
<td><a href="/versions/{{ .Id }}">{{ .Created }}</a></td> <td><a href="/versions/{{ .Id }}">{{ .Created }}</a></td>
<td><a href="/accounts/{{ .AccountId.String }}">{{ index $.Usernames .AccountId.String }}</a></td> <td><a href="/accounts/{{ .AccountId }}">{{ index $.Usernames .AccountId.String }}</a></td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>

View file

@ -14,6 +14,7 @@ func addRoutes(
requireLogin := loginMiddleware(db, requireSession) requireLogin := loginMiddleware(db, requireSession)
requireAdmin := adminMiddleware(db, requireLogin) requireAdmin := adminMiddleware(db, requireLogin)
mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db))) mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db)))
mux.Handle("GET /accounts/{id}", requireAdmin(handleAccountsIdGET(db)))
mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db))) mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db)))
mux.Handle("GET /healthz", handleHealthz()) mux.Handle("GET /healthz", handleHealthz())
mux.Handle("GET /login", requireSession(handleLoginGET())) mux.Handle("GET /login", requireSession(handleLoginGET()))