// 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) 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, } _ = 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) }