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,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
PasswordReset: passwordReset,
|
||||
PasswordReset: &passwordReset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -91,7 +91,7 @@ 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;`)
|
||||
`SELECT id, username, salt, password_hash, is_admin, created, last_login, settings, password_reset FROM accounts;`)
|
||||
if err != nil {
|
||||
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,
|
||||
&created,
|
||||
&lastLogin,
|
||||
&account.Settings)
|
||||
&account.Settings,
|
||||
&account.PasswordReset)
|
||||
if err != nil {
|
||||
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
|
||||
)
|
||||
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
|
||||
WHERE id = ?;`,
|
||||
id,
|
||||
|
@ -170,7 +171,7 @@ func (db *DB) LoadAccountById(id uuid.UUID) (*model.Account, error) {
|
|||
&created,
|
||||
&lastLogin,
|
||||
&account.Settings,
|
||||
)
|
||||
&account.PasswordReset)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
|
@ -191,7 +192,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
|
|||
lastLogin int64
|
||||
)
|
||||
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
|
||||
WHERE username = ?;`,
|
||||
username,
|
||||
|
@ -202,7 +203,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
|
|||
&created,
|
||||
&lastLogin,
|
||||
&account.Settings,
|
||||
)
|
||||
&account.PasswordReset)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
|
|
|
@ -113,6 +113,31 @@ func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) {
|
|||
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) {
|
||||
rows, err := db.Query(
|
||||
`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()
|
||||
versions := make([]model.Version, 0)
|
||||
for rows.Next() {
|
||||
var version model.Version
|
||||
version := model.Version{StateId: state.Id}
|
||||
var created int64
|
||||
err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &version.Lock)
|
||||
if err != nil {
|
||||
|
@ -66,3 +66,30 @@ func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) {
|
|||
}
|
||||
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
|
||||
LastLogin time.Time
|
||||
Settings json.RawMessage
|
||||
PasswordReset uuid.UUID
|
||||
PasswordReset *uuid.UUID
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="flex-row" style="justify-content:space-between;">
|
||||
<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>
|
||||
</div>
|
||||
<form action="/accounts" enctype="multipart/form-data" method="post">
|
||||
|
@ -16,6 +16,12 @@
|
|||
required
|
||||
type="text"
|
||||
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>
|
||||
{{ if .UsernameDuplicate }}
|
||||
<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>
|
||||
<div class="flex-row" style="justify-content:space-between;">
|
||||
<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>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>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
{{ range .Versions }}
|
||||
<tr>
|
||||
<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>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
|
|
|
@ -14,6 +14,7 @@ func addRoutes(
|
|||
requireLogin := loginMiddleware(db, requireSession)
|
||||
requireAdmin := adminMiddleware(db, requireLogin)
|
||||
mux.Handle("GET /accounts", requireAdmin(handleAccountsGET(db)))
|
||||
mux.Handle("GET /accounts/{id}", requireAdmin(handleAccountsIdGET(db)))
|
||||
mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db)))
|
||||
mux.Handle("GET /healthz", handleHealthz())
|
||||
mux.Handle("GET /login", requireSession(handleLoginGET()))
|
||||
|
|
Loading…
Add table
Reference in a new issue