From 6ccc6c8c5efe2cb832acb7df35c105564efa662f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:01:33 +0100 Subject: [PATCH] ui: /settings/users edit form + disable/enable/regenerate/force-logout --- internal/server/http/server.go | 6 + internal/server/http/ui_users.go | 181 +++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index f6fea31..23329c8 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -267,6 +267,12 @@ func (s *Server) routes(r chi.Router) { 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}/edit", s.handleUIUserEditGet) + r.Post("/settings/users/{id}/edit", s.handleUIUserEditPost) + r.Post("/settings/users/{id}/disable", s.handleUIUserDisablePost) + r.Post("/settings/users/{id}/enable", s.handleUIUserEnablePost) + r.Post("/settings/users/{id}/regenerate-setup", s.handleUIUserRegenerateSetupPost) + r.Post("/settings/users/{id}/force-logout", s.handleUIUserForceLogoutPost) r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet) r.Get("/settings/notifications", s.handleUINotificationsList) r.Get("/settings/notifications/new", s.handleUINotificationNewGet) diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index fe685cd..a95495c 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -210,6 +210,187 @@ func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Reques 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 {