ui: /settings/users/new + /setup-link page
Adds handleUIUserNewGet, handleUIUserNewPost, handleUIUserSetupLinkGet to ui_users.go; creates web/templates/pages/user_edit.html (multi-mode new/edit/setup-link); wires three routes in the admin band of server.go.
This commit is contained in:
@@ -265,6 +265,9 @@ func (s *Server) routes(r chi.Router) {
|
||||
if s.deps.UI != nil {
|
||||
r.Get("/settings", s.handleUISettings)
|
||||
r.Get("/settings/users", s.handleUIUsersList)
|
||||
r.Get("/settings/users/new", s.handleUIUserNewGet)
|
||||
r.Post("/settings/users/new", s.handleUIUserNewPost)
|
||||
r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet)
|
||||
r.Get("/settings/notifications", s.handleUINotificationsList)
|
||||
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
|
||||
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
|
||||
|
||||
@@ -15,8 +15,17 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
type usersPage struct {
|
||||
@@ -74,3 +83,166 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
slog.Error("ui users: render", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
type userFormPage struct {
|
||||
Mode string // "new" | "edit" | "setup-link"
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
Disabled bool
|
||||
HasSetup bool
|
||||
SetupURL string
|
||||
SetupExpAt time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "New user · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{Mode: "new", Role: "operator"}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
}
|
||||
|
||||
func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username")))
|
||||
email := strings.TrimSpace(r.PostForm.Get("email"))
|
||||
role, ok := validRole(r.PostForm.Get("role"))
|
||||
if uname == "" || !ok {
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "New user · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{
|
||||
Mode: "new", Username: uname, Email: email,
|
||||
Role: r.PostForm.Get("role"),
|
||||
Error: "Username is required and role must be admin/operator/viewer.",
|
||||
}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
return
|
||||
}
|
||||
if email != "" {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "New user · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{
|
||||
Mode: "new", Username: uname, Email: email,
|
||||
Role: r.PostForm.Get("role"),
|
||||
Error: "Email is not a valid address.",
|
||||
}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Same collision logic as the API.
|
||||
existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
|
||||
if err == nil {
|
||||
if existing.DisabledAt != nil {
|
||||
// Punt the admin to the edit page where Re-enable is one click.
|
||||
stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+
|
||||
"/edit?reenable=1", stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "New user · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{
|
||||
Mode: "new", Username: uname, Email: email,
|
||||
Role: r.PostForm.Get("role"),
|
||||
Error: "A user with that name already exists.",
|
||||
}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
return
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id := ulid.Make().String()
|
||||
now := time.Now().UTC()
|
||||
var emailPtr *string
|
||||
if email != "" {
|
||||
em := strings.ToLower(email)
|
||||
emailPtr = &em
|
||||
}
|
||||
if err := s.deps.Store.CreateUser(r.Context(), store.User{
|
||||
ID: id, Username: uname, PasswordHash: "",
|
||||
Role: role, Email: emailPtr, CreatedAt: now,
|
||||
MustChangePassword: true,
|
||||
}); err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rawToken, err := generateSetupToken()
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
|
||||
UserID: id, TokenHash: hashSetupToken(rawToken),
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
CreatedAt: now, CreatedBy: &u.ID,
|
||||
}); err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
||||
Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
|
||||
TS: now,
|
||||
})
|
||||
stdhttp.Redirect(w, r,
|
||||
"/settings/users/"+id+"/setup-link?token="+rawToken,
|
||||
stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
target, err := s.deps.Store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rawToken := r.URL.Query().Get("token")
|
||||
tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id)
|
||||
if err != nil || rawToken == "" {
|
||||
w.WriteHeader(stdhttp.StatusGone)
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "Link expired · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{
|
||||
Mode: "setup-link", ID: target.ID, Username: target.Username,
|
||||
Error: "expired",
|
||||
}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
return
|
||||
}
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "Setup link · restic-manager"
|
||||
view.Active = "settings"
|
||||
view.Page = userFormPage{
|
||||
Mode: "setup-link", ID: target.ID, Username: target.Username,
|
||||
Role: string(target.Role), HasSetup: true,
|
||||
SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
|
||||
SetupExpAt: tok.ExpiresAt,
|
||||
}
|
||||
_ = s.deps.UI.Render(w, "user_edit", view)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user