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/helpers"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"github.com/mattn/go-sqlite3"
"go.n16f.net/uuid" "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) 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 { func (db *DB) InitAdminAccount() error {
return db.WithTransaction(func(tx *sql.Tx) error { return db.WithTransaction(func(tx *sql.Tx) error {
var hasAdminAccount bool var hasAdminAccount bool

View file

@ -5,12 +5,13 @@ CREATE TABLE schema_version (
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
salt BLOB NOT NULL, salt BLOB,
password_hash BLOB NOT NULL, password_hash BLOB,
is_admin INTEGER NOT NULL DEFAULT FALSE, is_admin INTEGER NOT NULL,
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
) STRICT; ) STRICT;
CREATE UNIQUE INDEX accounts_username on accounts(username); CREATE UNIQUE INDEX accounts_username on accounts(username);

View file

@ -12,14 +12,15 @@ import (
type AccountContextKey struct{} type AccountContextKey struct{}
type Account struct { type Account struct {
Id uuid.UUID Id uuid.UUID
Username string Username string
Salt []byte Salt []byte
PasswordHash []byte PasswordHash []byte
IsAdmin bool IsAdmin bool
Created time.Time Created time.Time
LastLogin time.Time LastLogin time.Time
Settings json.RawMessage Settings json.RawMessage
PasswordReset uuid.UUID
} }
func (account *Account) CheckPassword(password string) bool { func (account *Account) CheckPassword(password string) bool {

View file

@ -3,15 +3,20 @@ package webui
import ( import (
"html/template" "html/template"
"net/http" "net/http"
"path"
"git.adyxax.org/adyxax/tfstated/pkg/database" "git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
) )
type AccountsPage struct { type AccountsPage struct {
ActiveTab int Accounts []model.Account
Page *Page ActiveTab int
Accounts []model.Account IsAdmin string
Page *Page
Username string
UsernameDuplicate bool
UsernameInvalid bool
} }
var accountsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accounts.html")) 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> </table>
</div> </div>
<div id="new" class="page padding{{ if eq .ActiveTab 1 }} active{{ end }}"> <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>
</div> </div>
</main> </main>

View file

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

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("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()))
mux.Handle("POST /login", requireSession(handleLoginPOST(db))) mux.Handle("POST /login", requireSession(handleLoginPOST(db)))