Compare commits

...
Author SHA1 Message Date
hanzo-dev 2c0098418b fix(admin): default /v1/admin/users pagination (p=1, pageSize=200) — the 0-of-222 bug
IAM's user list returns ZERO rows AND total 0 when p/pageSize are unset. The
operator directory (frontend getList('admin/users')) omits pageSize, so
/v1/admin/users returned {data:[],data2:0} — '0 of 222 users'. Default the first
page + shared admin page size when the client omits them; an explicit client
p/pageSize still wins. Adds TestUsers_DefaultsPagination (asserts p=1/pageSize=200
forwarded + the real total surfaces).
2026-07-22 19:04:02 -07:00
hanzo-dev 7f35dbf645 feat(admin): /v1/admin/me returns isWhiteLabel + scopeOrgs for the WL-tenant subtree cockpit
The operator console (admin.<brand>) admits fleet SuperAdmins AND enabled
white-label tenant admins; the subtree cockpit needs to know which. Expose two
additive, read-only fields on the existing GuardScoped /v1/admin/me: isWhiteLabel
(admitted && !super ⇒ subtree operator, mutually exclusive with isSuperAdmin) and
scopeOrgs (sc.Orgs — the visible subtree window, empty for a SuperAdmin). The
faceting layer threads scopeOrgs as its scope ceiling. No behavior change to any
money path; the subtree bounding + fail-closed guard are unchanged.
2026-07-22 18:40:35 -07:00
3 changed files with 68 additions and 0 deletions
+14
View File
@@ -160,6 +160,11 @@ func me(s *cloud.Service[core.State], c *zip.Ctx) error {
Email: strings.TrimSpace(c.UserEmail()),
DisplayName: name,
IsSuperAdmin: sc.Super,
// Admitted (the gate already 200'd) but not fleet-super ⇒ a subtree-scoped
// white-label tenant operator; ScopeOrgs is its visible subtree (empty for
// a SuperAdmin, who sees the whole fleet).
IsWhiteLabel: !sc.Super,
ScopeOrgs: sc.Orgs,
})
}
@@ -210,11 +215,20 @@ func users(s *cloud.Service[core.State], c *zip.Ctx) error {
} else if owner := strings.TrimSpace(c.Query("org")); owner != "" {
q.Set("owner", owner)
}
// Default pagination when the client omits it. IAM's user list returns ZERO
// rows AND total 0 when p/pageSize are unset — which surfaced as the admin
// directory showing "0 of 222". Default to the first page at the shared admin
// page size so the directory populates and the REAL total is reported; an
// explicit client p/pageSize still wins (the UI paginates from there).
if p := strings.TrimSpace(c.Query("p")); p != "" {
q.Set("p", p)
} else {
q.Set("p", "1")
}
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
q.Set("pageSize", ps)
} else {
q.Set("pageSize", "200")
}
if term := strings.TrimSpace(c.Query("q")); term != "" {
// IAM's list uses field/value contains-matching for the free-text filter.
+46
View File
@@ -180,6 +180,52 @@ func TestGate_AllowsSuperAdmin(t *testing.T) {
}
}
// TestUsers_DefaultsPagination locks the "0 of 222" fix: IAM's user list returns
// ZERO rows AND total 0 when p/pageSize are unset, so the /v1/admin/users handler
// MUST supply a default first page + page size when the client (the operator
// directory) omits them. Proves the handler forwards p=1 & pageSize=200 and that
// the real total reaches the client.
func TestUsers_DefaultsPagination(t *testing.T) {
var gotP, gotPageSize string
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/get-users") {
gotP = r.URL.Query().Get("p")
gotPageSize = r.URL.Query().Get("pageSize")
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"status":"ok","msg":"","data":[
{"owner":"hanzo","name":"alice","email":"alice@hanzo.ai","displayName":"Alice"}
],"data2":222}`)
return
}
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
}))
defer iamSrv.Close()
do := mount(t, iamSrv.URL, "http://127.0.0.1:0", "http://127.0.0.1:0")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z", "X-User-Email": "z@hanzo.ai"}
resp, body := do("GET", "/v1/admin/users", admin) // NOTE: no ?pageSize — the bug path.
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /v1/admin/users: got %d (body=%s)", resp.StatusCode, body)
}
if gotPageSize != "200" {
t.Fatalf("users list must default pageSize=200 when the client omits it, got %q", gotPageSize)
}
if gotP != "1" {
t.Fatalf("users list must default p=1 when the client omits it, got %q", gotP)
}
var env struct {
Data []operatorUser `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode users envelope: %v (body=%s)", err, body)
}
if env.Data2 != 222 || len(env.Data) == 0 {
t.Fatalf("users must surface the REAL directory (got %d rows, total %d), not 0-of-222", len(env.Data), env.Data2)
}
}
// fakeIAM stands in for the IAM management surface. It records whether the
// caller's credential was replayed and returns /v1 envelopes.
type fakeIAM struct {
+8
View File
@@ -19,6 +19,14 @@ type adminMe struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsSuperAdmin bool `json:"isSuperAdmin"`
// IsWhiteLabel: admitted but not fleet-super — a subtree-scoped (white-label)
// tenant operator. Mutually exclusive with IsSuperAdmin; drives the subtree
// cockpit vs the fleet god-view in the operator console.
IsWhiteLabel bool `json:"isWhiteLabel"`
// ScopeOrgs: the operator's visible tenant window — empty/nil for a SuperAdmin
// (all orgs), else the scoped subtree (sc.Orgs). The faceting layer threads
// this as its scope ceiling so a tenant can never facet outside its subtree.
ScopeOrgs []string `json:"scopeOrgs,omitempty"`
}
// overviewData is the fleet overview tiles (OverviewData / GET /v1/admin/overview).