feat(webui): add user account creation

This commit is contained in:
Julien Dessaux 2025-03-19 00:48:58 +01:00
parent ef565022d8
commit 7f025eb0f8
Signed by: adyxax
GPG key ID: F92E51B86E07177E
7 changed files with 124 additions and 17 deletions

View file

@ -10,6 +10,7 @@ import (
"git.adyxax.org/adyxax/tfstated/pkg/helpers"
"git.adyxax.org/adyxax/tfstated/pkg/model"
"github.com/mattn/go-sqlite3"
"go.n16f.net/uuid"
)
@ -18,6 +19,40 @@ var AdvertiseAdminPassword = func(password string) {
slog.Info("Generated an initial admin password, please change it or delete the admin account after your first login", "password", password)
}
func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, error) {
var accountId uuid.UUID
if err := accountId.Generate(uuid.V7); err != nil {
return nil, fmt.Errorf("failed to generate account id: %w", err)
}
var passwordReset uuid.UUID
if err := passwordReset.Generate(uuid.V4); err != nil {
return nil, fmt.Errorf("failed to generate password reset uuid: %w", err)
}
_, err := db.Exec(`INSERT INTO accounts(id, username, is_Admin, settings, password_reset)
VALUES (?, ?, ?, ?, ?);`,
accountId,
username,
isAdmin,
[]byte("{}"),
passwordReset,
)
if err != nil {
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) {
if sqliteErr.Code == sqlite3.ErrNo(sqlite3.ErrConstraint) {
return nil, nil
}
}
return nil, fmt.Errorf("failed to insert new account: %w", err)
}
return &model.Account{
Id: accountId,
Username: username,
IsAdmin: isAdmin,
PasswordReset: passwordReset,
}, nil
}
func (db *DB) InitAdminAccount() error {
return db.WithTransaction(func(tx *sql.Tx) error {
var hasAdminAccount bool

View file

@ -5,12 +5,13 @@ CREATE TABLE schema_version (
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
salt BLOB NOT NULL,
password_hash BLOB NOT NULL,
is_admin INTEGER NOT NULL DEFAULT FALSE,
salt BLOB,
password_hash BLOB,
is_admin INTEGER NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_login INTEGER NOT NULL DEFAULT (unixepoch()),
settings BLOB NOT NULL
settings BLOB NOT NULL,
password_reset TEXT
) STRICT;
CREATE UNIQUE INDEX accounts_username on accounts(username);

View file

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

View file

@ -3,15 +3,20 @@ package webui
import (
"html/template"
"net/http"
"path"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
)
type AccountsPage struct {
ActiveTab int
Page *Page
Accounts []model.Account
ActiveTab int
IsAdmin string
Page *Page
Username string
UsernameDuplicate bool
UsernameInvalid bool
}
var accountsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accounts.html"))
@ -29,3 +34,39 @@ func handleAccountsGET(db *database.DB) http.Handler {
})
})
}
func handleAccountsPOST(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accounts, err := db.LoadAccounts()
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
accountUsername := r.FormValue("username")
isAdmin := r.FormValue("isAdmin")
page := AccountsPage{
ActiveTab: 1,
Page: makePage(r, &Page{Title: "New Account", Section: "accounts"}),
Accounts: accounts,
IsAdmin: isAdmin,
Username: accountUsername,
}
if ok := validUsername.MatchString(accountUsername); !ok {
page.UsernameInvalid = true
render(w, accountsTemplates, http.StatusBadRequest, page)
return
}
account, err := db.CreateAccount(accountUsername, isAdmin == "1")
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
if account == nil {
page.UsernameDuplicate = true
render(w, accountsTemplates, http.StatusBadRequest, page)
return
}
destination := path.Join("/accounts", account.Id.String())
http.Redirect(w, r, destination, http.StatusFound)
})
}

View file

@ -28,7 +28,34 @@
</table>
</div>
<div id="new" class="page padding{{ if eq .ActiveTab 1 }} active{{ end }}">
<p>TODO</p>
<form action="/accounts" enctype="multipart/form-data" method="post">
<fieldset>
<div class="field border label{{ if or .UsernameDuplicate .UsernameInvalid }} invalid{{ end }}">
<input autofocus
id="username"
name="username"
required
type="text"
value="{{ .Username }}">
<label for="username">Username</label>
{{ if .UsernameDuplicate }}
<span class="error">This username already exist</span>
{{ else if .UsernameInvalid }}
<span class="error">Invalid username</span>
{{ end }}
</div>
<div class="field label">
<label>
<input {{ if .IsAdmin }} checked{{ end }}
name="is-admin"
type="checkbox"
value="{{ .IsAdmin }}" />
<span>Is Admin</span>
</label>
</div>
<button class="small-round" type="submit" value="submit">Create User Account</button>
</fieldset>
</form>
</div>
</div>
</main>

View file

@ -15,6 +15,8 @@ import (
var loginTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/login.html"))
var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`)
type loginPage struct {
Page
Forbidden bool
@ -38,7 +40,6 @@ func handleLoginGET() http.Handler {
}
func handleLoginPOST(db *database.DB) http.Handler {
var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`)
renderForbidden := func(w http.ResponseWriter, username string) {
render(w, loginTemplate, http.StatusForbidden, loginPage{
Page: Page{Title: "Login", Section: "login"},

View file

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