feat(webui): add user account creation
This commit is contained in:
parent
ef565022d8
commit
7f025eb0f8
7 changed files with 124 additions and 17 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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)))
|
||||
|
|
Loading…
Add table
Reference in a new issue