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

Closes #19
This commit is contained in:
Julien Dessaux 2025-05-05 00:34:08 +02:00
parent 373f567773
commit 8d75b75af7
Signed by: adyxax
GPG key ID: F92E51B86E07177E
6 changed files with 57 additions and 20 deletions

View file

@ -30,11 +30,10 @@ func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, erro
} }
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO accounts(id, username, is_Admin, settings, password_reset) `INSERT INTO accounts(id, username, is_Admin, settings, password_reset)
VALUES (?, ?, ?, jsonb(?), ?);`, VALUES (?, ?, ?, jsonb('{}'), ?);`,
accountId, accountId,
username, username,
isAdmin, isAdmin,
[]byte("{}"),
passwordReset, passwordReset,
) )
if err != nil { if err != nil {
@ -73,13 +72,12 @@ func (db *DB) InitAdminAccount() error {
hash := helpers.HashPassword(password.String(), salt) hash := helpers.HashPassword(password.String(), salt)
if _, err := tx.ExecContext(db.ctx, if _, err := tx.ExecContext(db.ctx,
`INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings) `INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings)
VALUES (:id, "admin", :salt, :hash, TRUE, jsonb(:settings)) VALUES (:id, "admin", :salt, :hash, TRUE, jsonb('{}'))
ON CONFLICT DO UPDATE SET password_hash = :hash ON CONFLICT DO UPDATE SET password_hash = :hash, is_admin = TRUE
WHERE username = "admin";`, WHERE username = "admin";`,
sql.Named("id", accountId), sql.Named("id", accountId),
sql.Named("hash", hash), sql.Named("hash", hash),
sql.Named("salt", salt), sql.Named("salt", salt),
sql.Named("settings", []byte("{}")),
); err == nil { ); err == nil {
AdvertiseAdminPassword(password.String()) AdvertiseAdminPassword(password.String())
} else { } else {
@ -93,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, `SELECT id, username, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset FROM accounts;`) json_extract(settings, '$'), password_reset, deleted 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)
} }
@ -115,7 +113,8 @@ func (db *DB) LoadAccounts() ([]model.Account, error) {
&created, &created,
&lastLogin, &lastLogin,
&settings, &settings,
&account.PasswordReset) &account.PasswordReset,
&account.Deleted)
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)
} }
@ -171,7 +170,7 @@ func (db *DB) LoadAccountById(id *uuid.UUID) (*model.Account, error) {
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT username, salt, password_hash, is_admin, created, last_login, `SELECT username, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset json_extract(settings, '$'), password_reset, deleted
FROM accounts FROM accounts
WHERE id = ?;`, WHERE id = ?;`,
id, id,
@ -182,7 +181,8 @@ func (db *DB) LoadAccountById(id *uuid.UUID) (*model.Account, error) {
&created, &created,
&lastLogin, &lastLogin,
&settings, &settings,
&account.PasswordReset) &account.PasswordReset,
&account.Deleted)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
@ -208,7 +208,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT id, salt, password_hash, is_admin, created, last_login, `SELECT id, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset json_extract(settings, '$'), password_reset, deleted
FROM accounts FROM accounts
WHERE username = ?;`, WHERE username = ?;`,
username, username,
@ -219,7 +219,8 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
&created, &created,
&lastLogin, &lastLogin,
&settings, &settings,
&account.PasswordReset) &account.PasswordReset,
&account.Deleted)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
@ -243,13 +244,15 @@ func (db *DB) SaveAccount(account *model.Account) (bool, error) {
salt = ?, salt = ?,
password_hash = ?, password_hash = ?,
is_admin = ?, is_admin = ?,
password_reset = ? password_reset = ?,
deleted = ?
WHERE id = ?`, WHERE id = ?`,
account.Username, account.Username,
account.Salt, account.Salt,
account.PasswordHash, account.PasswordHash,
account.IsAdmin, account.IsAdmin,
account.PasswordReset, account.PasswordReset,
account.Deleted,
account.Id) account.Id)
if err != nil { if err != nil {
var sqliteErr sqlite3.Error var sqliteErr sqlite3.Error

View file

@ -11,9 +11,11 @@ CREATE TABLE accounts (
created INTEGER NOT NULL DEFAULT (unixepoch()), created INTEGER NOT NULL DEFAULT (unixepoch()),
last_login INTEGER NOT NULL DEFAULT (unixepoch()), last_login INTEGER NOT NULL DEFAULT (unixepoch()),
settings BLOB NOT NULL, settings BLOB NOT NULL,
password_reset TEXT password_reset TEXT,
deleted INTEGER NOT NULL DEFAULT 0
) STRICT; ) STRICT;
CREATE UNIQUE INDEX accounts_username ON accounts(username); CREATE UNIQUE INDEX accounts_username ON accounts(username);
CREATE INDEX accounts_deleted ON accounts(deleted);
CREATE TABLE sessions ( CREATE TABLE sessions (
id BLOB PRIMARY KEY, id BLOB PRIMARY KEY,

View file

@ -21,6 +21,7 @@ type Account struct {
LastLogin time.Time `json:"last_login"` LastLogin time.Time `json:"last_login"`
Settings *Settings `json:"settings"` Settings *Settings `json:"settings"`
PasswordReset *uuid.UUID `json:"password_reset"` PasswordReset *uuid.UUID `json:"password_reset"`
Deleted bool `json:"deleted"`
} }
func (account *Account) CheckPassword(password string) bool { func (account *Account) CheckPassword(password string) bool {
@ -28,6 +29,13 @@ func (account *Account) CheckPassword(password string) bool {
return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1 return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1
} }
func (account *Account) MarkForDeletion() {
account.Salt = nil
account.PasswordHash = nil
account.PasswordReset = nil
account.Deleted = true
}
func (account *Account) ResetPassword() error { func (account *Account) ResetPassword() error {
var passwordReset uuid.UUID var passwordReset uuid.UUID
if err := passwordReset.Generate(uuid.V4); err != nil { if err := passwordReset.Generate(uuid.V4); err != nil {

View file

@ -91,8 +91,25 @@ func handleAccountsIdPOST(db *database.DB) http.Handler {
action := r.FormValue("action") action := r.FormValue("action")
switch action { switch action {
case "delete": case "delete":
errorResponse(w, r, http.StatusNotImplemented, nil) if !page.Account.Deleted {
return page.Account.MarkForDeletion()
success, err := db.SaveAccount(page.Account)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: %w", err))
return
}
if !success {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: this cannot happen"))
return
}
if err := db.DeleteSessions(page.Account); err != nil {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to delete sessions: %w", err))
return
}
}
case "edit": case "edit":
page.Username = r.FormValue("username") page.Username = r.FormValue("username")
isAdmin := r.FormValue("is-admin") isAdmin := r.FormValue("is-admin")
@ -119,8 +136,13 @@ func handleAccountsIdPOST(db *database.DB) http.Handler {
return return
} }
case "reset-password": case "reset-password":
if page.Account.Deleted {
errorResponse(w, r, http.StatusBadRequest,
fmt.Errorf("You cannot reset the password for this account because it is marked for deletion."))
return
}
if err := page.Account.ResetPassword(); err != nil { if err := page.Account.ResetPassword(); err != nil {
errorResponse(w, r, http.StatusNotImplemented, errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to reset password: %w", err)) fmt.Errorf("failed to reset password: %w", err))
return return
} }
@ -137,7 +159,7 @@ func handleAccountsIdPOST(db *database.DB) http.Handler {
} }
if err := db.DeleteSessions(page.Account); err != nil { if err := db.DeleteSessions(page.Account); err != nil {
errorResponse(w, r, http.StatusInternalServerError, errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: %w", err)) fmt.Errorf("failed to delete sessions: %w", err))
return return
} }
default: default:

View file

@ -21,10 +21,12 @@
<strong>{{ .Account.LastLogin }}</strong>. <strong>{{ .Account.LastLogin }}</strong>.
{{ end }} {{ end }}
</p> </p>
{{ if .Account.IsAdmin }} {{ if .Account.Deleted }}
<p>This accounts is <strong>marked for deletion</strong>!</p>
{{ else if .Account.IsAdmin }}
<p>This accounts has <strong>admin</strong> privileges on TfStated.</p> <p>This accounts has <strong>admin</strong> privileges on TfStated.</p>
{{ end }} {{ end }}
{{ if .Page.Session.Data.Account.IsAdmin }} {{ if and (not .Account.Deleted) .Page.Session.Data.Account.IsAdmin }}
<h2>Operations</h2> <h2>Operations</h2>
<div class="flex-row"> <div class="flex-row">
<form action="/accounts/{{ .Account.Id }}" method="post"> <form action="/accounts/{{ .Account.Id }}" method="post">

View file

@ -72,7 +72,7 @@ func handleLoginPOST(db *database.DB) http.Handler {
fmt.Errorf("failed to load account by username %s: %w", username, err)) fmt.Errorf("failed to load account by username %s: %w", username, err))
return return
} }
if account == nil || !account.CheckPassword(password) { if account == nil || account.Deleted || !account.CheckPassword(password) {
renderForbidden(w, r, username) renderForbidden(w, r, username)
return return
} }