From e5f79902fd639bb47acf9ee92a9922e154dbfde4 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:34:11 +0100 Subject: [PATCH] http: GET /api/users (list) --- internal/server/http/api_users.go | 52 ++++++++++++++++++++++++++++++ internal/server/http/rbac_test.go | 4 --- internal/server/http/server.go | 1 + internal/server/http/users_test.go | 35 ++++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 internal/server/http/api_users.go create mode 100644 internal/server/http/users_test.go diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go new file mode 100644 index 0000000..9b0a3a8 --- /dev/null +++ b/internal/server/http/api_users.go @@ -0,0 +1,52 @@ +// api_users.go — JSON handlers for the user-management surface. +// +// All endpoints in this file are admin-only; gating happens at the +// route-mount site (server.go's admin band). +package http + +import ( + "encoding/json" + "log/slog" + stdhttp "net/http" +) + +type listUsersResponse struct { + Users []apiUser `json:"users"` +} + +type apiUser struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + Email *string `json:"email,omitempty"` + Disabled bool `json:"disabled"` + MustChangePassword bool `json:"must_change_password"` + CreatedAt string `json:"created_at"` + LastLoginAt *string `json:"last_login_at,omitempty"` +} + +func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { + users, err := s.deps.Store.ListUsers(r.Context()) + if err != nil { + slog.Error("api users: list", "err", err) + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]apiUser, len(users)) + for i, u := range users { + var lastLogin *string + if u.LastLoginAt != nil { + s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") + lastLogin = &s + } + out[i] = apiUser{ + ID: u.ID, Username: u.Username, Role: string(u.Role), + Email: u.Email, Disabled: u.DisabledAt != nil, + MustChangePassword: u.MustChangePassword, + CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + LastLoginAt: lastLogin, + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out}) +} diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 42f6b44..9bffee6 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -144,10 +144,6 @@ func TestLoginRejectsDisabledUser(t *testing.T) { func TestAdminBandRejectsOperator(t *testing.T) { t.Parallel() - // This test will start asserting 403 once Task B4 mounts /api/users - // inside the admin band and Task E1 lands the handler. Until then, - // the route 404s — we skip rather than red-flag the suite. - t.Skip("re-enable after B4 route grouping + E1 /api/users handler land") srv, urlBase := newTestServer(t, false) makeUser(t, srv, "admin1", store.RoleAdmin) opID := makeUser(t, srv, "op1", store.RoleOperator) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index a54b70a..921c0ac 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -251,6 +251,7 @@ func (s *Server) routes(r chi.Router) { r.Group(func(r chi.Router) { r.Use(s.requireRole(store.RoleAdmin)) + r.Get("/api/users", s.handleAPIUsersList) 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 new file mode 100644 index 0000000..b5da56b --- /dev/null +++ b/internal/server/http/users_test.go @@ -0,0 +1,35 @@ +package http + +import ( + "encoding/json" + "io" + stdhttp "net/http" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestAPIUsersList(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "op1", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/users", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Fatalf("status: got %d body=%s", res.StatusCode, body) + } + var got listUsersResponse + _ = json.NewDecoder(res.Body).Decode(&got) + if len(got.Users) != 2 { + t.Errorf("count: got %d want 2", len(got.Users)) + } +}