dfff6d1ef9
CI / Test (rest) (pull_request) Successful in 29s
CI / Lint (pull_request) Successful in 32s
CI / Test (server-http) (pull_request) Successful in 1m9s
CI / Test (store) (pull_request) Successful in 1m13s
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Build (linux/amd64) (pull_request) Successful in 21s
CI / Build (linux/arm64) (pull_request) Successful in 37s
494 lines
15 KiB
Go
494 lines
15 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"
|
|
"net/url"
|
|
"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
|
|
Sort string // "username" | "email" | "role" | "last_login_at"
|
|
Dir string // "asc" | "desc"
|
|
// SortHrefs is a fully-encoded /settings/users?…&sort=COL&dir=…
|
|
// for each sortable column. Built server-side because constructing
|
|
// the querystring inside <a href="…"> in html/template applies
|
|
// URL-attribute escaping to '=' (turning 'show_disabled=1' into
|
|
// 'show_disabled%3D1'), which silently drops every filter on click.
|
|
// Same shape as the audit page's SortHrefs.
|
|
SortHrefs map[string]string
|
|
}
|
|
|
|
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
|
|
}
|
|
q := r.URL.Query()
|
|
showDisabled := q.Get("show_disabled") == "1"
|
|
|
|
// Resolve sort against the allowlist. Default: username ASC.
|
|
resolvedSort := "username"
|
|
switch q.Get("sort") {
|
|
case "username", "email", "role", "last_login_at":
|
|
resolvedSort = q.Get("sort")
|
|
}
|
|
asc := q.Get("dir") != "desc"
|
|
if q.Get("sort") == "" {
|
|
// No explicit sort param → default ASC even though dir
|
|
// querystring might be missing (fresh page load).
|
|
asc = true
|
|
}
|
|
dirStr := "desc"
|
|
if asc {
|
|
dirStr = "asc"
|
|
}
|
|
|
|
users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{
|
|
OrderBy: resolvedSort, OrderAsc: asc,
|
|
})
|
|
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,
|
|
})
|
|
}
|
|
|
|
// Pre-build per-column hrefs so the template just emits them.
|
|
// Same pattern as ui_audit's SortHrefs — sidesteps html/template
|
|
// URL-attribute escaping turning '=' into '%3D'.
|
|
base := url.Values{}
|
|
if showDisabled {
|
|
base.Set("show_disabled", "1")
|
|
}
|
|
hrefs := make(map[string]string, 4)
|
|
for _, col := range []string{"username", "email", "role", "last_login_at"} {
|
|
v := url.Values{}
|
|
for k, vs := range base {
|
|
v[k] = vs
|
|
}
|
|
v.Set("sort", col)
|
|
newDir := "asc" // sensible default for unactive columns
|
|
if col == resolvedSort && asc {
|
|
newDir = "desc"
|
|
}
|
|
v.Set("dir", newDir)
|
|
hrefs[col] = "/settings/users?" + v.Encode()
|
|
}
|
|
|
|
view := s.baseView(r, u)
|
|
view.Title = "Users · restic-manager"
|
|
view.Active = "settings"
|
|
view.Page = usersPage{
|
|
Users: rows, ShowDisabled: showDisabled,
|
|
Sort: resolvedSort, Dir: dirStr,
|
|
SortHrefs: hrefs,
|
|
}
|
|
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
|
|
// Reenable is set when the admin landed here because they tried
|
|
// to add a username that already exists (disabled). Triggers a
|
|
// banner on the edit page explaining why and steering them at
|
|
// the Re-enable button. See handleUIUserNewPost's collision branch.
|
|
Reenable bool
|
|
}
|
|
|
|
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) handleUIUserEditGet(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
|
|
}
|
|
em := ""
|
|
if target.Email != nil {
|
|
em = *target.Email
|
|
}
|
|
view := s.baseView(r, u)
|
|
view.Title = "Edit user · restic-manager"
|
|
view.Active = "settings"
|
|
view.Page = userFormPage{
|
|
Mode: "edit", ID: target.ID, Username: target.Username,
|
|
Email: em, Role: string(target.Role),
|
|
Disabled: target.DisabledAt != nil,
|
|
Reenable: r.URL.Query().Get("reenable") == "1",
|
|
}
|
|
_ = s.deps.UI.Render(w, "user_edit", view)
|
|
}
|
|
|
|
func (s *Server) handleUIUserEditPost(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
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
target, err := s.deps.Store.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
role, ok := validRole(r.PostForm.Get("role"))
|
|
if !ok {
|
|
stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
email := strings.TrimSpace(r.PostForm.Get("email"))
|
|
if email != "" {
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
stdhttp.Error(w, "bad email", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if target.Role == store.RoleAdmin && role != store.RoleAdmin && target.DisabledAt == nil {
|
|
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
|
|
if n <= 1 {
|
|
stdhttp.Error(w, "cannot demote last admin", stdhttp.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
if err := s.deps.Store.SetUserRole(r.Context(), id, role); err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.deps.Store.SetUserEmail(r.Context(), id, email); 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.updated", TargetKind: ptr("user"), TargetID: &id,
|
|
TS: time.Now().UTC(),
|
|
})
|
|
stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUIUserDisablePost(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
|
|
}
|
|
if target.Role == store.RoleAdmin && target.DisabledAt == nil {
|
|
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
|
|
if n <= 1 {
|
|
stdhttp.Error(w, "cannot disable last admin", stdhttp.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
|
Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
|
|
TS: now,
|
|
})
|
|
stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUIUserEnablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.deps.Store.EnableUser(r.Context(), 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.enabled", TargetKind: ptr("user"), TargetID: &id,
|
|
TS: time.Now().UTC(),
|
|
})
|
|
stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUIUserRegenerateSetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
rawToken, err := generateSetupToken()
|
|
if err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
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.SetMustChangePassword(r.Context(), id, true)
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
|
Action: "user.setup_token.regenerated",
|
|
TargetKind: ptr("user"), TargetID: &id, TS: now,
|
|
})
|
|
stdhttp.Redirect(w, r,
|
|
"/settings/users/"+id+"/setup-link?token="+rawToken,
|
|
stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUIUserForceLogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
_, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
|
|
if 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.force_logout",
|
|
TargetKind: ptr("user"), TargetID: &id,
|
|
TS: time.Now().UTC(),
|
|
})
|
|
stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", 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)
|
|
}
|