Files
restic-manager/internal/server/http/ui_users.go
T
steve 04a413eb55 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.
2026-05-05 10:57:25 +01:00

249 lines
7.0 KiB
Go

// ui_users.go — Settings → Users HTML handlers (admin-only).
//
// Routes (wired in server.go's admin band):
//
// GET /settings/users → handleUIUsersList (this task)
// GET /settings/users/new → F2
// POST /settings/users/new → F2
// GET /settings/users/{id}/edit → F3
// POST /settings/users/{id}/edit → F3
// GET /settings/users/{id}/setup-link → F2
// POST /settings/users/{id}/disable → F3
// POST /settings/users/{id}/enable → F3
// POST /settings/users/{id}/regenerate-setup → F3
// POST /settings/users/{id}/force-logout → F3
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 {
Users []userRow
ShowDisabled bool
}
type userRow struct {
ID string
Username string
Email string
Role string
LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never"
Disabled bool
MustChangePassword bool
}
func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
showDisabled := r.URL.Query().Get("show_disabled") == "1"
users, err := s.deps.Store.ListUsers(r.Context())
if err != nil {
slog.Error("ui users: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
rows := make([]userRow, 0, len(users))
for _, ux := range users {
if !showDisabled && ux.DisabledAt != nil {
continue
}
em := ""
if ux.Email != nil {
em = *ux.Email
}
ll := "never"
if ux.LastLoginAt != nil {
ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05")
}
rows = append(rows, userRow{
ID: ux.ID, Username: ux.Username, Email: em,
Role: string(ux.Role), LastLoginAt: ll,
Disabled: ux.DisabledAt != nil,
MustChangePassword: ux.MustChangePassword,
})
}
view := s.baseView(r, u)
view.Title = "Users · restic-manager"
view.Active = "settings"
view.Page = usersPage{Users: rows, ShowDisabled: showDisabled}
if err := s.deps.UI.Render(w, "users", view); err != nil {
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)
}