feat(webui): add user account status page
This commit is contained in:
parent
a22b2953e4
commit
2bf1731343
10 changed files with 230 additions and 12 deletions
|
@ -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
|
||||||
|
|
|
@ -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;`)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
66
pkg/webui/accountsId.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
92
pkg/webui/html/accountsId.html
Normal file
92
pkg/webui/html/accountsId.html
Normal 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 }}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
Loading…
Add table
Reference in a new issue