http: GET /api/users (list)

This commit is contained in:
2026-05-05 09:34:11 +01:00
parent 81f2852eb1
commit e5f79902fd
4 changed files with 88 additions and 4 deletions
+52
View File
@@ -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})
}
-4
View File
@@ -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)
+1
View File
@@ -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 {
+35
View File
@@ -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))
}
}