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/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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"},
|
||||||
|
|
|
@ -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)))
|
||||||
|
|
Loading…
Add table
Reference in a new issue