diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 9b38e93..00c7c23 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -270,3 +270,56 @@ func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request }) w.WriteHeader(stdhttp.StatusOK) } + +func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + if u.Role == store.RoleAdmin && u.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") + return + } + } + now := time.Now().UTC() + if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + // Kick existing sessions so the user is bounced immediately. + _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + w.WriteHeader(stdhttp.StatusOK) +} + +func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + if err := s.deps.Store.EnableUser(r.Context(), id); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 3ab89cc..5d79eb9 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -255,6 +255,8 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/users", s.handleAPIUserCreate) r.Get("/api/users/{id}", s.handleAPIUserGet) r.Patch("/api/users/{id}", s.handleAPIUserPatch) + r.Post("/api/users/{id}/disable", s.handleAPIUserDisable) + r.Post("/api/users/{id}/enable", s.handleAPIUserEnable) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index 376fcfb..3f91b2e 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -174,3 +174,45 @@ func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserDisable(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "admin2", store.RoleAdmin) // satisfy last-admin guard + target := makeUser(t, srv, "victim", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/disable", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + u, _ := srv.deps.Store.GetUserByID(t.Context(), target) + if u.DisabledAt == nil { + t.Error("disabled_at not set") + } +} + +func TestAPIUserDisableRejectsLastAdmin(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+adminID+"/disable", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +}