From 7f025eb0f8dc8b25133a9807a48b9f777e9b12ca Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Wed, 19 Mar 2025 00:48:58 +0100 Subject: [PATCH] feat(webui): add user account creation --- pkg/database/accounts.go | 35 ++++++++++++++++++++++++++ pkg/database/sql/000_init.sql | 9 ++++--- pkg/model/account.go | 17 +++++++------ pkg/webui/accounts.go | 47 ++++++++++++++++++++++++++++++++--- pkg/webui/html/accounts.html | 29 ++++++++++++++++++++- pkg/webui/login.go | 3 ++- pkg/webui/routes.go | 1 + 7 files changed, 124 insertions(+), 17 deletions(-) diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go index 216cf64..dc8b485 100644 --- a/pkg/database/accounts.go +++ b/pkg/database/accounts.go @@ -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 diff --git a/pkg/database/sql/000_init.sql b/pkg/database/sql/000_init.sql index 0e52c60..bc9f7ce 100644 --- a/pkg/database/sql/000_init.sql +++ b/pkg/database/sql/000_init.sql @@ -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); diff --git a/pkg/model/account.go b/pkg/model/account.go index 41d7868..fe7c0e8 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -12,14 +12,15 @@ import ( type AccountContextKey struct{} type Account struct { - Id uuid.UUID - Username string - Salt []byte - PasswordHash []byte - IsAdmin bool - Created time.Time - LastLogin time.Time - Settings json.RawMessage + Id uuid.UUID + Username string + Salt []byte + PasswordHash []byte + IsAdmin bool + Created time.Time + LastLogin time.Time + Settings json.RawMessage + PasswordReset uuid.UUID } func (account *Account) CheckPassword(password string) bool { diff --git a/pkg/webui/accounts.go b/pkg/webui/accounts.go index a6aa6ce..01ed609 100644 --- a/pkg/webui/accounts.go +++ b/pkg/webui/accounts.go @@ -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 + 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) + }) +} diff --git a/pkg/webui/html/accounts.html b/pkg/webui/html/accounts.html index f1ded93..c4acb95 100644 --- a/pkg/webui/html/accounts.html +++ b/pkg/webui/html/accounts.html @@ -28,7 +28,34 @@
-

TODO

+
+
+
+ + + {{ if .UsernameDuplicate }} + This username already exist + {{ else if .UsernameInvalid }} + Invalid username + {{ end }} +
+
+ +
+ +
+
diff --git a/pkg/webui/login.go b/pkg/webui/login.go index abf269b..3bf3c03 100644 --- a/pkg/webui/login.go +++ b/pkg/webui/login.go @@ -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"}, diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go index 2037df6..59f3d6e 100644 --- a/pkg/webui/routes.go +++ b/pkg/webui/routes.go @@ -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)))