From 809db2378d93ae185cf59a9e4f406ac047a0efff Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 28 Jul 2026 17:06:43 +0000 Subject: [PATCH 01/15] feat: add server-side search and pagination for the Groups page Add a paginated, server-side searchable groups endpoint and wire the organization Groups page to it, matching the organization Members page pattern (useSearchParams + usePaginatedQuery + useFilter with a presentational view rendering Filter + PaginationContainer). - database: new PaginatedOrganizationGroups query with COUNT(*) OVER(), deterministic ORDER BY LOWER(name), OFFSET/LIMIT. dbauthz wrapper does a single org-wide ActionRead check (no post-filter) to keep LIMIT/OFFSET and the count consistent, mirroring PaginatedOrganizationMembers. - codersdk: PaginatedGroupsResponse + GroupsPaginated client method. - searchquery: minimal Groups(q) parser for free-text search. - enterprise: paginatedGroups handler + /organizations/{organization}/paginated-groups route. - frontend: getOrganizationPaginatedGroups API, paginatedGroupsByOrganization query, GroupsFilter (search-only), GroupsPageView filter + pagination, updated stories. The legacy /groups endpoint and its post-filter are left untouched. Generated with Coder Agents. fix: hide Filter preset menu if no presets are provided fix: move "Create group" button to GroupsPageView to match OrganizationMembersPageView Merge remote-tracking branch 'origin/main' into aqandrew/devex-434-groups-search-pagination docs: regenerate enterprise API reference after merging main fix(site/src/pages/GroupsPage): keep GroupsPageView presentational Gate the Create group button on the canCreateGroup prop the container already passes, instead of re-deriving permissions via useGroupsSettings + organizationsPermissions inside the view. Calling useGroupsSettings coupled the presentational view to GroupsPageContext, which threw "useGroupsSettings should be used inside of GroupsPageContext" in Storybook. test: set correct pagination number labels for WithSearchAndPagination story refactor: replace GroupsParams struct with string fix: order PaginatedOrganizationGroups by name, then id style: add spaces around cast operators Merge remote-tracking branch 'origin/main' into aqandrew/devex-434-groups-search-pagination # Conflicts: # site/src/api/api.ts # site/src/api/queries/groups.ts # site/src/pages/GroupsPage/GroupsPage.tsx # site/src/pages/GroupsPage/GroupsPageView.stories.tsx # site/src/pages/GroupsPage/GroupsPageView.tsx Merge remote-tracking branch 'origin/aqandrew/devex-434-groups-search-pagination' into aqandrew/devex-434-groups-search-pagination fix(coderd/database): restore OFFSET in PaginatedOrganizationGroups The name+id tiebreaker commit (7f6e8005f5) accidentally dropped the OFFSET @offset_opt clause when it was collapsed onto the ORDER BY line, which disabled offset pagination and removed OffsetOpt from the generated params. Restore it alongside the id tiebreaker and regenerate. refactor(codersdk): add PaginatedGroupsRequest and rename OrganizationGroupsPaginated Replace UsersRequest with a dedicated PaginatedGroupsRequest (pagination + search only) so the paginated groups client no longer advertises key:value filters the endpoint rejects with a 400. Rename GroupsPaginated to OrganizationGroupsPaginated for parity with OrganizationMembersPaginated and the DB/frontend names. Addresses review comment (codersdk/groups.go). docs(codersdk): document org-wide read requirement on OrganizationGroupsPaginated The paginated endpoint requires organization-wide group read and is not a drop-in for Groups (GET /groups), which authorizes per-group. Document this on the published SDK method so consumers do not assume equivalent behavior. Addresses review comment (dbauthz.go). fix(coderd/searchquery): support multi-word group search Bare search terms were added as separate 'search' values, so a multi-word query like 'front end' tripped the duplicate-param check and returned a 400 instead of a substring match the SQL supports. Concatenate bare terms into a single search value. Addresses review comment (searchquery/search.go). fix(enterprise/coderd): drop dead sql.ErrNoRows check in paginatedGroups PaginatedOrganizationGroups is a :many query that returns an empty slice, never sql.ErrNoRows, so the errors.Is guard was dead code. The empty result is already handled by the len(groups) == 0 branch. Addresses review comment (groups.go). test(enterprise/coderd): assert paginated group member hydration test(coderd): strengthen paginated groups query and search coverage feat(site/src/pages/GroupsPage): show filter-aware empty state refactor(site/src/components/Filter): drop unused GroupsFilter error prop refactor(site/src/pages/GroupsPage): use Array.from map callback in story Merge branch 'main' into aqandrew/devex-434-groups-search-pagination --- coderd/apidoc/docs.go | 66 ++++++++ coderd/apidoc/swagger.json | 62 +++++++ coderd/database/dbauthz/dbauthz.go | 11 ++ coderd/database/dbauthz/dbauthz_test.go | 13 ++ coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 86 ++++++++++ coderd/database/queries/groups.sql | 31 ++++ coderd/searchquery/search.go | 24 +++ coderd/searchquery/search_test.go | 75 +++++++++ codersdk/groups.go | 48 ++++++ docs/reference/api/enterprise.md | 70 ++++++++ docs/reference/api/schemas.md | 45 +++++ enterprise/coderd/coderd.go | 8 + enterprise/coderd/groups.go | 83 ++++++++++ enterprise/coderd/groups_test.go | 191 ++++++++++++++++++++++ 17 files changed, 837 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b95e78772e2..91a0f2262e4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5699,6 +5699,58 @@ const docTemplate = `{ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": [ @@ -22342,6 +22394,20 @@ const docTemplate = `{ } } }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Group" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1a3f184e8d3..2938dcb9cf0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5050,6 +5050,54 @@ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": ["application/json"], @@ -20405,6 +20453,20 @@ } } }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Group" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5b8036958c5..33675a43892 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7023,6 +7023,17 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) } +func (q *querier) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + // Required to have permission to read all groups in the organization. This + // mirrors PaginatedOrganizationMembers: a single org-wide read check with no + // per-row post-filter, so that SQL LIMIT/OFFSET and COUNT(*) OVER() stay + // consistent across pages. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(arg.OrganizationID)); err != nil { + return nil, err + } + return q.db.PaginatedOrganizationGroups(ctx, arg) +} + func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { // Required to have permission to read all members in the organization if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ef0e45eaeb0..214f9359132 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2585,6 +2585,19 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(arg).Asserts(mem, policy.ActionRead) })) + s.Run("PaginatedOrganizationGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + arg := database.PaginatedOrganizationGroupsParams{OrganizationID: o.ID, LimitOpt: 0} + rows := []database.PaginatedOrganizationGroupsRow{{ + Group: g, + OrganizationName: o.Name, + OrganizationDisplayName: o.DisplayName, + Count: 1, + }} + dbm.EXPECT().PaginatedOrganizationGroups(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead).Returns(rows) + })) s.Run("PaginatedOrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { o := testutil.Fake(s.T(), faker, database.Organization{}) u := testutil.Fake(s.T(), faker, database.User{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index db91a9772b7..98a052076df 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4961,6 +4961,14 @@ func (m queryMetricsStore) OrganizationMembers(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + start := time.Now() + r0, r1 := m.s.PaginatedOrganizationGroups(ctx, arg) + m.queryLatencies.WithLabelValues("PaginatedOrganizationGroups").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PaginatedOrganizationGroups").Inc() + return r0, r1 +} + func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { start := time.Now() r0, r1 := m.s.PaginatedOrganizationMembers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index a038a74957f..1e324491452 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9355,6 +9355,21 @@ func (mr *MockStoreMockRecorder) PGLocks(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PGLocks", reflect.TypeOf((*MockStore)(nil).PGLocks), ctx) } +// PaginatedOrganizationGroups mocks base method. +func (m *MockStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaginatedOrganizationGroups", ctx, arg) + ret0, _ := ret[0].([]database.PaginatedOrganizationGroupsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaginatedOrganizationGroups indicates an expected call of PaginatedOrganizationGroups. +func (mr *MockStoreMockRecorder) PaginatedOrganizationGroups(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationGroups", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationGroups), ctx, arg) +} + // PaginatedOrganizationMembers mocks base method. func (m *MockStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f82ef6ea146..a729d5f3551 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1280,6 +1280,7 @@ type sqlcQuerier interface { // - Use just 'user_id' to get all orgs a user is a member of // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) + PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) // Under READ COMMITTED, concurrent pin operations for the same // owner may momentarily produce duplicate pin_order values because diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee88e6cdba8..7d6ac3c0c2e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15720,6 +15720,92 @@ func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingG return items, nil } +const paginatedOrganizationGroups = `-- name: PaginatedOrganizationGroups :many +SELECT + groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, groups.chat_spend_limit_micros, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND CASE + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + groups.organization_id = $1 + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN $2 :: text != '' THEN ( + groups.name ILIKE concat('%', $2, '%') + OR groups.display_name ILIKE concat('%', $2, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC, groups.id ASC OFFSET $3 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($4 :: int, 0) +` + +type PaginatedOrganizationGroupsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Search string `db:"search" json:"search"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type PaginatedOrganizationGroupsRow struct { + Group Group `db:"group" json:"group"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) { + rows, err := q.db.QueryContext(ctx, paginatedOrganizationGroups, + arg.OrganizationID, + arg.Search, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaginatedOrganizationGroupsRow + for rows.Next() { + var i PaginatedOrganizationGroupsRow + if err := rows.Scan( + &i.Group.ID, + &i.Group.Name, + &i.Group.OrganizationID, + &i.Group.AvatarURL, + &i.Group.QuotaAllowance, + &i.Group.DisplayName, + &i.Group.Source, + &i.Group.ChatSpendLimitMicros, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 39742d55350..b063f56beb4 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -89,6 +89,37 @@ WHERE LIMIT NULLIF(@limit_opt :: int, 0) ; +-- name: PaginatedOrganizationGroups :many +SELECT + sqlc.embed(groups), + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND CASE + WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + groups.organization_id = @organization_id + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN @search :: text != '' THEN ( + groups.name ILIKE concat('%', @search, '%') + OR groups.display_name ILIKE concat('%', @search, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC, groups.id ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); + -- name: InsertGroup :one INSERT INTO groups ( id, diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index f8e7dd64b6a..0e3787e6954 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -174,6 +174,30 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +func Groups(query string) (string, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + // Groups support free-text search only, so join bare terms into a + // single search value. Adding each term separately would make a + // multi-word query like "front end" look like a duplicate param and + // return a 400 instead of matching the name/display name substring. + if existing := values.Get("search"); existing != "" { + term = existing + " " + term + } + values.Set("search", term) + return nil + }) + if len(errors) > 0 { + return "", errors + } + + parser := httpapi.NewQueryParamParser() + search := parser.String(values, "", "search") + parser.ErrorExcessParams(values) + return search, parser.Errors +} + func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) { query = strings.TrimSpace(query) if query == "" { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 76deab73981..a8cc4db0a75 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1707,3 +1707,78 @@ func TestSearchChats(t *testing.T) { }) } } + +func TestSearchGroups(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + Query string + Expected string + ExpectedErrorContains string + }{ + { + Name: "Empty", + Query: "", + Expected: "", + }, + { + Name: "SingleWord", + Query: "alpha", + Expected: "alpha", + }, + { + // Groups support free-text search, so an unquoted multi-word query + // is joined into a single search value instead of being rejected as + // a duplicate param. + Name: "MultiWord", + Query: "front end", + Expected: "front end", + }, + { + Name: "CaseInsensitive", + Query: "AlPhA", + Expected: "alpha", + }, + { + Name: "MultiWordCaseInsensitive", + Query: "Front End", + Expected: "front end", + }, + { + Name: "TrimsSurroundingSpaces", + Query: " alpha ", + Expected: "alpha", + }, + { + // Structured key:value queries are not supported for groups; the + // unrecognized key surfaces as an invalid query param. + Name: "StructuredKeyValueRejected", + Query: "name:alpha", + ExpectedErrorContains: "is not a valid query param", + }, + { + Name: "ExtraColon", + Query: "a:b:c", + ExpectedErrorContains: "can only contain 1 ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + search, errs := searchquery.Groups(c.Query) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, search, "expected search value") + } + }) + } +} diff --git a/codersdk/groups.go b/codersdk/groups.go index a191b280e47..0055e809ee4 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -48,6 +48,29 @@ type GroupMembersResponse struct { Count int `json:"count"` } +type PaginatedGroupsResponse struct { + Groups []Group `json:"groups"` + Count int `json:"count"` +} + +// PaginatedGroupsRequest are the filters for a paginated groups request. +// Groups only support free-text search, so unlike UsersRequest it exposes no +// key:value filters that the endpoint would reject. +type PaginatedGroupsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +func (req PaginatedGroupsRequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + if req.SearchQuery != "" { + q.Set("q", req.SearchQuery) + } + r.URL.RawQuery = q.Encode() + } +} + func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } @@ -135,6 +158,31 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st return resp, json.NewDecoder(res.Body).Decode(&resp) } +// OrganizationGroupsPaginated lists filtered and paginated groups in an +// organization. Unlike Groups (GET /groups), which authorizes each group +// individually via its ACL, this endpoint requires organization-wide group +// read permission and does no per-group filtering. It is therefore not a +// drop-in replacement for Groups: callers without org-wide group read will +// receive an error rather than a filtered subset. +func (c *Client) OrganizationGroupsPaginated(ctx context.Context, orgID uuid.UUID, req PaginatedGroupsRequest) (PaginatedGroupsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/paginated-groups", orgID.String()), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return PaginatedGroupsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return PaginatedGroupsResponse{}, ReadBodyAsError(res) + } + var resp PaginatedGroupsResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + type GroupRequest struct { ExcludeMembers bool `json:"exclude_members"` } diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 88026e8599f..c3ef3bfe289 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2234,6 +2234,76 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get groups by organization (paginated) + +### Code samples + +```sh +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginated-groups \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/organizations/{organization}/paginated-groups` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|---------|----------|-------------------------| +| `organization` | path | string | true | Organization ID or name | +| `q` | query | string | false | Search query | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.PaginatedGroupsResponse](schemas.md#codersdkpaginatedgroupsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Serve provisioner daemon ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cae7eb143c5..b3eb265ef64 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9445,6 +9445,51 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | » `[any property]` | array of string | false | | | | `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. | +## codersdk.PaginatedGroupsResponse + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | + ## codersdk.PaginatedMembersResponse ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index d65dd1f6950..b1882f18158 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -530,6 +530,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) }) }) + r.Route("/organizations/{organization}/paginated-groups", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.templateRBACEnabledMW, + httpmw.ExtractOrganizationParam(api.Database), + ) + r.Get("/", api.paginatedGroups) + }) r.Route("/organizations/{organization}/ai/spend", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 95b238f41af..26142304c6e 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -547,6 +547,89 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { api.groups(rw, r) } +// @Summary Get groups by organization (paginated) +// @ID get-groups-by-organization-paginated +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID or name" +// @Param q query string false "Search query" +// @Param limit query int false "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.PaginatedGroupsResponse +// @Router /api/v2/organizations/{organization}/paginated-groups [get] +func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + + filterQuery := r.URL.Query().Get("q") + search, filterErrs := searchquery.Groups(filterQuery) + if len(filterErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid group search query.", + Validations: filterErrs, + }) + return + } + + paginationParams, ok := agpl.ParsePagination(rw, r) + if !ok { + return + } + + groups, err := api.Database.PaginatedOrganizationGroups(ctx, database.PaginatedOrganizationGroupsParams{ + OrganizationID: org.ID, + Search: search, + // #nosec G115 - Pagination offsets are small and fit in int32 + OffsetOpt: int32(paginationParams.Offset), + // #nosec G115 - Pagination limits are small and fit in int32 + LimitOpt: int32(paginationParams.Limit), + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + if len(groups) == 0 { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedGroupsResponse{ + Groups: []codersdk.Group{}, + Count: 0, + }) + return + } + + resp := codersdk.PaginatedGroupsResponse{ + Groups: make([]codersdk.Group, 0, len(groups)), + Count: int(groups[0].Count), + } + for _, group := range groups { + members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ + GroupID: group.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{ + GroupID: group.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp.Groups = append(resp.Groups, db2sdk.Group(database.GetGroupsRow{ + Group: group.Group, + OrganizationName: group.OrganizationName, + OrganizationDisplayName: group.OrganizationDisplayName, + }, members, int(memberCount))) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + // @Summary Get groups // @ID get-groups // @Security CoderSessionToken diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 59335e91c57..47ff9a447fa 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "sort" + "strings" "testing" "time" @@ -1234,3 +1235,193 @@ func TestGetGroupMembersPagination(t *testing.T) { } coderdtest.UsersPagination(ctx, t, client, setup, fetch) } + +func TestPaginatedGroups(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a deterministic set of groups. Names include mixed case, a pair + // that differs only by case, and one group with a distinct display name so + // ordering (LOWER(name)), the groups.id tiebreaker, and display-name search + // are all exercised. The org's implicit "Everyone" group also exists, so + // account for it in the expected counts. + type groupSpec struct { + name string + displayName string + } + specs := []groupSpec{ + {name: "alpha"}, + {name: "Bravo"}, + {name: "charlie"}, + {name: "Delta"}, + {name: "echo"}, + // "Dev" and "dev" collide once lowercased, forcing the groups.id + // tiebreaker to produce a deterministic order. + {name: "Dev"}, + {name: "dev"}, + {name: "zeta", displayName: "Frontend Squad"}, + } + for _, spec := range specs { + _, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: spec.name, + DisplayName: spec.displayName, + }) + require.NoError(t, err) + } + + // The org's implicit "Everyone" group is included in the paginated results. + totalGroups := len(specs) + 1 + + // Add a known member to the "alpha" group so member hydration can be + // asserted below. + _, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + alpha, err := userAdminClient.GroupByOrgAndName(ctx, user.OrganizationID, "alpha") + require.NoError(t, err) + _, err = userAdminClient.PatchGroup(ctx, alpha.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{member.ID.String()}, + }) + require.NoError(t, err) + + t.Run("AllGroups", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{}) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + require.Len(t, resp.Groups, totalGroups) + + // Verify deterministic ordering: lower(name) ascending, with ties + // broken by groups.id ascending. uuid string comparison matches + // Postgres' byte-wise uuid ordering. + for i := 1; i < len(resp.Groups); i++ { + prev, cur := resp.Groups[i-1], resp.Groups[i] + prevName, curName := strings.ToLower(prev.Name), strings.ToLower(cur.Name) + if prevName == curName { + require.Less(t, prev.ID.String(), cur.ID.String(), + "groups with equal lowercased names must be ordered by id") + } else { + require.Less(t, prevName, curName, + "groups must be ordered by lowercased name") + } + } + }) + + t.Run("MemberHydration", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // The handler enriches each page group with its members and total + // count. Assert both so removing that logic would fail the test. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "alpha", + }) + require.NoError(t, err) + require.Len(t, resp.Groups, 1) + require.Equal(t, "alpha", resp.Groups[0].Name) + require.Equal(t, 1, resp.Groups[0].TotalMemberCount) + require.Len(t, resp.Groups[0].Members, 1) + require.Equal(t, member.ID, resp.Groups[0].Members[0].ID) + }) + + t.Run("Search", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "alpha", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "alpha", resp.Groups[0].Name) + }) + + t.Run("SearchNoResults", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "does-not-exist", + }) + require.NoError(t, err) + require.Equal(t, 0, resp.Count) + require.Empty(t, resp.Groups) + }) + + t.Run("SearchSubstring", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // A substring of the name matches, not just a prefix. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "harl", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "charlie", resp.Groups[0].Name) + }) + + t.Run("SearchCaseInsensitive", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // An uppercase query matches both "Dev" and "dev" case-insensitively. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "DEV", + }) + require.NoError(t, err) + require.Equal(t, 2, resp.Count) + require.Len(t, resp.Groups, 2) + for _, g := range resp.Groups { + require.Equal(t, "dev", strings.ToLower(g.Name)) + } + // The case-only collision is ordered deterministically by id. + require.Less(t, resp.Groups[0].ID.String(), resp.Groups[1].ID.String()) + }) + + t.Run("SearchDisplayName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Search matches the display name, not just the name. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "squad", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "zeta", resp.Groups[0].Name) + }) + + t.Run("PageBoundaries", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Page through the results two at a time and ensure the union covers + // every group exactly once, with a stable Count on each page. + seen := make(map[string]struct{}) + for offset := 0; offset < totalGroups; offset += 2 { + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + Pagination: codersdk.Pagination{Limit: 2, Offset: offset}, + }) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + require.LessOrEqual(t, len(resp.Groups), 2) + for _, g := range resp.Groups { + _, dup := seen[g.Name] + require.False(t, dup, "group %q appeared on more than one page", g.Name) + seen[g.Name] = struct{}{} + } + } + require.Len(t, seen, totalGroups) + }) +} From 2b74effe2e002bfdb254a7e19b561286932ff15a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 28 Jul 2026 18:06:05 +0000 Subject: [PATCH 02/15] chore: make gen --- site/src/api/typesGenerated.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2d3f42b2b37..c81ddd3fe07 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6978,6 +6978,22 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// From codersdk/groups.go +/** + * PaginatedGroupsRequest are the filters for a paginated groups request. + * Groups only support free-text search, so unlike UsersRequest it exposes no + * key:value filters that the endpoint would reject. + */ +export interface PaginatedGroupsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/groups.go +export interface PaginatedGroupsResponse { + readonly groups: readonly Group[]; + readonly count: number; +} + // From codersdk/organizations.go export interface PaginatedMembersRequest { readonly limit?: number; From 98003573295e412f9d86870450f41a724925019c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 28 Jul 2026 18:51:15 +0000 Subject: [PATCH 03/15] test: remove ExtraColon test case --- coderd/searchquery/search_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index a8cc4db0a75..7412c248d51 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1756,11 +1756,6 @@ func TestSearchGroups(t *testing.T) { Query: "name:alpha", ExpectedErrorContains: "is not a valid query param", }, - { - Name: "ExtraColon", - Query: "a:b:c", - ExpectedErrorContains: "can only contain 1 ':'", - }, } for _, c := range testCases { From c0fe385e570874ae6589e046da3029f232feb28e Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 20:38:17 +0000 Subject: [PATCH 04/15] fix(coderd/database): always scope paginated groups to the organization Drop the nil-organization ELSE true branch so the query's data scope matches the dbauthz ResourceGroup.InOrg authorization check. The endpoint always passes a real organization ID, and cross-org listing is not a supported mode. --- coderd/database/queries.sql.go | 6 +----- coderd/database/queries/groups.sql | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index af15cfb02ee..f585c84e580 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15873,11 +15873,7 @@ INNER JOIN organizations ON groups.organization_id = organizations.id WHERE true - AND CASE - WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN - groups.organization_id = $1 - ELSE true - END + AND groups.organization_id = $1 -- Filter by group name or display name (substring, case-insensitive). AND CASE WHEN $2 :: text != '' THEN ( groups.name ILIKE concat('%', $2, '%') diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index b063f56beb4..b4126d1520e 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -101,11 +101,7 @@ INNER JOIN organizations ON groups.organization_id = organizations.id WHERE true - AND CASE - WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN - groups.organization_id = @organization_id - ELSE true - END + AND groups.organization_id = @organization_id -- Filter by group name or display name (substring, case-insensitive). AND CASE WHEN @search :: text != '' THEN ( groups.name ILIKE concat('%', @search, '%') From 484f2c19dda37cbe78c548e920390206db07dcb7 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 20:40:47 +0000 Subject: [PATCH 05/15] test(enterprise/coderd): assert paginated groups organization isolation Add a second organization with its own group and assert it never appears in the first org's paginated results and that Count reflects only the target org. This exercises the organization_id filter for exclusion, which a single-org test cannot. --- enterprise/coderd/groups_test.go | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 47ff9a447fa..b6f9ab083fd 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1241,7 +1241,8 @@ func TestPaginatedGroups(t *testing.T) { client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ - codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureMultipleOrganizations: 1, }, }}) userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) @@ -1424,4 +1425,42 @@ func TestPaginatedGroups(t *testing.T) { } require.Len(t, seen, totalGroups) }) + + t.Run("OrganizationIsolation", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // A second organization with its own group must never appear in the + // first org's results, and the first org's Count must exclude it. This + // exercises the organization_id filter for exclusion, which a + // single-org test cannot. + //nolint:gocritic // Only owners can create organizations. + otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other-org", + }) + require.NoError(t, err) + // Reuse a name that also exists in the first org to prove isolation is + // by organization, not by name. + otherGroup, err := client.CreateGroup(ctx, otherOrg.ID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + resp, err := client.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{}) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + for _, g := range resp.Groups { + require.Equal(t, user.OrganizationID, g.OrganizationID) + require.NotEqual(t, otherGroup.ID, g.ID) + } + + // The second org returns only its own groups: the created group plus + // that org's implicit "Everyone" group. + otherResp, err := client.OrganizationGroupsPaginated(ctx, otherOrg.ID, codersdk.PaginatedGroupsRequest{}) + require.NoError(t, err) + require.Equal(t, 2, otherResp.Count) + for _, g := range otherResp.Groups { + require.Equal(t, otherOrg.ID, g.OrganizationID) + } + }) } From 67c5778e6fe27d7fb7a2391b367b9a84e3acff6a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 21:00:25 +0000 Subject: [PATCH 06/15] refactor(coderd/database): rename query to GetGroupsByOrganizationIDPaginated Rename PaginatedOrganizationGroups for consistency with the GetX naming used elsewhere; PaginatedOrganizationMembers was the outlier the query was originally modeled on. --- coderd/database/dbauthz/dbauthz.go | 22 +-- coderd/database/dbauthz/dbauthz_test.go | 8 +- coderd/database/dbmetrics/querymetrics.go | 16 +-- coderd/database/dbmock/dbmock.go | 30 ++-- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 164 +++++++++++----------- coderd/database/queries/groups.sql | 2 +- enterprise/coderd/groups.go | 2 +- 8 files changed, 123 insertions(+), 123 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1acc8122317..636ac2046e3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3979,6 +3979,17 @@ func (q *querier) GetGroups(ctx context.Context, arg database.GetGroupsParams) ( return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroups)(ctx, arg) } +func (q *querier) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + // Required to have permission to read all groups in the organization. This + // mirrors PaginatedOrganizationMembers: a single org-wide read check with no + // per-row post-filter, so that SQL LIMIT/OFFSET and COUNT(*) OVER() stay + // consistent across pages. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(arg.OrganizationID)); err != nil { + return nil, err + } + return q.db.GetGroupsByOrganizationIDPaginated(ctx, arg) +} + func (q *querier) GetHealthSettings(ctx context.Context) (string, error) { // No authz checks return q.db.GetHealthSettings(ctx) @@ -7035,17 +7046,6 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) } -func (q *querier) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { - // Required to have permission to read all groups in the organization. This - // mirrors PaginatedOrganizationMembers: a single org-wide read check with no - // per-row post-filter, so that SQL LIMIT/OFFSET and COUNT(*) OVER() stay - // consistent across pages. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(arg.OrganizationID)); err != nil { - return nil, err - } - return q.db.PaginatedOrganizationGroups(ctx, arg) -} - func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { // Required to have permission to read all members in the organization if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 216faaa2fe4..7d97e18a744 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2586,17 +2586,17 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(arg).Asserts(mem, policy.ActionRead) })) - s.Run("PaginatedOrganizationGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("GetGroupsByOrganizationIDPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { o := testutil.Fake(s.T(), faker, database.Organization{}) g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) - arg := database.PaginatedOrganizationGroupsParams{OrganizationID: o.ID, LimitOpt: 0} - rows := []database.PaginatedOrganizationGroupsRow{{ + arg := database.GetGroupsByOrganizationIDPaginatedParams{OrganizationID: o.ID, LimitOpt: 0} + rows := []database.GetGroupsByOrganizationIDPaginatedRow{{ Group: g, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName, Count: 1, }} - dbm.EXPECT().PaginatedOrganizationGroups(gomock.Any(), arg).Return(rows, nil).AnyTimes() + dbm.EXPECT().GetGroupsByOrganizationIDPaginated(gomock.Any(), arg).Return(rows, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead).Returns(rows) })) s.Run("PaginatedOrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a4d5f4851a5..e887670ff5f 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2233,6 +2233,14 @@ func (m queryMetricsStore) GetGroups(ctx context.Context, arg database.GetGroups return r0, r1 } +func (m queryMetricsStore) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + start := time.Now() + r0, r1 := m.s.GetGroupsByOrganizationIDPaginated(ctx, arg) + m.queryLatencies.WithLabelValues("GetGroupsByOrganizationIDPaginated").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGroupsByOrganizationIDPaginated").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetHealthSettings(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetHealthSettings(ctx) @@ -4969,14 +4977,6 @@ func (m queryMetricsStore) OrganizationMembers(ctx context.Context, arg database return r0, r1 } -func (m queryMetricsStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { - start := time.Now() - r0, r1 := m.s.PaginatedOrganizationGroups(ctx, arg) - m.queryLatencies.WithLabelValues("PaginatedOrganizationGroups").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PaginatedOrganizationGroups").Inc() - return r0, r1 -} - func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { start := time.Now() r0, r1 := m.s.PaginatedOrganizationMembers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index dbc4db4e4d4..2990dfefdd6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4138,6 +4138,21 @@ func (mr *MockStoreMockRecorder) GetGroups(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroups", reflect.TypeOf((*MockStore)(nil).GetGroups), ctx, arg) } +// GetGroupsByOrganizationIDPaginated mocks base method. +func (m *MockStore) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByOrganizationIDPaginated", ctx, arg) + ret0, _ := ret[0].([]database.GetGroupsByOrganizationIDPaginatedRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupsByOrganizationIDPaginated indicates an expected call of GetGroupsByOrganizationIDPaginated. +func (mr *MockStoreMockRecorder) GetGroupsByOrganizationIDPaginated(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByOrganizationIDPaginated", reflect.TypeOf((*MockStore)(nil).GetGroupsByOrganizationIDPaginated), ctx, arg) +} + // GetHealthSettings mocks base method. func (m *MockStore) GetHealthSettings(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -9370,21 +9385,6 @@ func (mr *MockStoreMockRecorder) PGLocks(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PGLocks", reflect.TypeOf((*MockStore)(nil).PGLocks), ctx) } -// PaginatedOrganizationGroups mocks base method. -func (m *MockStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PaginatedOrganizationGroups", ctx, arg) - ret0, _ := ret[0].([]database.PaginatedOrganizationGroupsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// PaginatedOrganizationGroups indicates an expected call of PaginatedOrganizationGroups. -func (mr *MockStoreMockRecorder) PaginatedOrganizationGroups(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationGroups", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationGroups), ctx, arg) -} - // PaginatedOrganizationMembers mocks base method. func (m *MockStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1f7146b9989..1e5fc15f378 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -628,6 +628,7 @@ type sqlcQuerier interface { GetGroupMembersCountByGroupIDs(ctx context.Context, arg GetGroupMembersCountByGroupIDsParams) ([]GetGroupMembersCountByGroupIDsRow, error) // A limit of 0 means "no limit". GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) + GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) GetHealthSettings(ctx context.Context) (string, error) // Returns the highest group AI budget across the groups the user belongs to, // breaking ties by the earliest organization membership. Implements the @@ -1296,7 +1297,6 @@ type sqlcQuerier interface { // - Use just 'user_id' to get all orgs a user is a member of // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) - PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) // Under READ COMMITTED, concurrent pin operations for the same // owner may momentarily produce duplicate pin_order values because diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f585c84e580..63be11de43c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15727,6 +15727,88 @@ func (q *sqlQuerier) GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetG return items, nil } +const getGroupsByOrganizationIDPaginated = `-- name: GetGroupsByOrganizationIDPaginated :many +SELECT + groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, groups.chat_spend_limit_micros, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND groups.organization_id = $1 + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN $2 :: text != '' THEN ( + groups.name ILIKE concat('%', $2, '%') + OR groups.display_name ILIKE concat('%', $2, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC, groups.id ASC OFFSET $3 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($4 :: int, 0) +` + +type GetGroupsByOrganizationIDPaginatedParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Search string `db:"search" json:"search"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type GetGroupsByOrganizationIDPaginatedRow struct { + Group Group `db:"group" json:"group"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, getGroupsByOrganizationIDPaginated, + arg.OrganizationID, + arg.Search, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetGroupsByOrganizationIDPaginatedRow + for rows.Next() { + var i GetGroupsByOrganizationIDPaginatedRow + if err := rows.Scan( + &i.Group.ID, + &i.Group.Name, + &i.Group.OrganizationID, + &i.Group.AvatarURL, + &i.Group.QuotaAllowance, + &i.Group.DisplayName, + &i.Group.Source, + &i.Group.ChatSpendLimitMicros, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertAllUsersGroup = `-- name: InsertAllUsersGroup :one INSERT INTO groups ( id, @@ -15861,88 +15943,6 @@ func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingG return items, nil } -const paginatedOrganizationGroups = `-- name: PaginatedOrganizationGroups :many -SELECT - groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, groups.chat_spend_limit_micros, - organizations.name AS organization_name, - organizations.display_name AS organization_display_name, - COUNT(*) OVER() AS count -FROM - groups -INNER JOIN - organizations ON groups.organization_id = organizations.id -WHERE - true - AND groups.organization_id = $1 - -- Filter by group name or display name (substring, case-insensitive). - AND CASE WHEN $2 :: text != '' THEN ( - groups.name ILIKE concat('%', $2, '%') - OR groups.display_name ILIKE concat('%', $2, '%') - ) - ELSE true - END -ORDER BY - -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. - LOWER(groups.name) ASC, groups.id ASC OFFSET $3 -LIMIT - -- A null limit means "no limit", so 0 means return all - NULLIF($4 :: int, 0) -` - -type PaginatedOrganizationGroupsParams struct { - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Search string `db:"search" json:"search"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` -} - -type PaginatedOrganizationGroupsRow struct { - Group Group `db:"group" json:"group"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - Count int64 `db:"count" json:"count"` -} - -func (q *sqlQuerier) PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) { - rows, err := q.db.QueryContext(ctx, paginatedOrganizationGroups, - arg.OrganizationID, - arg.Search, - arg.OffsetOpt, - arg.LimitOpt, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []PaginatedOrganizationGroupsRow - for rows.Next() { - var i PaginatedOrganizationGroupsRow - if err := rows.Scan( - &i.Group.ID, - &i.Group.Name, - &i.Group.OrganizationID, - &i.Group.AvatarURL, - &i.Group.QuotaAllowance, - &i.Group.DisplayName, - &i.Group.Source, - &i.Group.ChatSpendLimitMicros, - &i.OrganizationName, - &i.OrganizationDisplayName, - &i.Count, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index b4126d1520e..1b070e13c57 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -89,7 +89,7 @@ WHERE LIMIT NULLIF(@limit_opt :: int, 0) ; --- name: PaginatedOrganizationGroups :many +-- name: GetGroupsByOrganizationIDPaginated :many SELECT sqlc.embed(groups), organizations.name AS organization_name, diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 26142304c6e..2e991adeb4a 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -577,7 +577,7 @@ func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { return } - groups, err := api.Database.PaginatedOrganizationGroups(ctx, database.PaginatedOrganizationGroupsParams{ + groups, err := api.Database.GetGroupsByOrganizationIDPaginated(ctx, database.GetGroupsByOrganizationIDPaginatedParams{ OrganizationID: org.ID, Search: search, // #nosec G115 - Pagination offsets are small and fit in int32 From 457bbb0c313f56120b4ecfcc3f3761682fbcefaf Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 21:02:54 +0000 Subject: [PATCH 07/15] docs(enterprise/coderd): document org-wide read on paginated groups endpoint Add a swagger @Description so callers not using the Go SDK can see that the paginated groups endpoint requires org-wide group read and is not a drop-in replacement for GET /groups. --- coderd/apidoc/docs.go | 1 + coderd/apidoc/swagger.json | 1 + docs/reference/api/enterprise.md | 6 ++++++ enterprise/coderd/groups.go | 5 +++++ 4 files changed, 13 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ad2997eba3a..10340b54bcd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5763,6 +5763,7 @@ const docTemplate = `{ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.", "produces": [ "application/json" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1f67a172546..322390e83bf 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5104,6 +5104,7 @@ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.", "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Get groups by organization (paginated)", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 19cde59d16d..9d36ae17e5f 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2330,6 +2330,12 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat `GET /api/v2/organizations/{organization}/paginated-groups` +Unlike "Get groups by organization" (GET /organizations/{organization}/groups), +which authorizes each group individually via its ACL, this endpoint requires +organization-wide group read permission and does no per-group filtering. It is +therefore not a drop-in replacement: callers without org-wide group read receive +an error rather than a filtered subset. + ### Parameters | Name | In | Type | Required | Description | diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 2e991adeb4a..a18db4dd10a 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -557,6 +557,11 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.PaginatedGroupsResponse +// @Description Unlike "Get groups by organization" (GET /organizations/{organization}/groups), +// @Description which authorizes each group individually via its ACL, this endpoint requires +// @Description organization-wide group read permission and does no per-group filtering. It is +// @Description therefore not a drop-in replacement: callers without org-wide group read receive +// @Description an error rather than a filtered subset. // @Router /api/v2/organizations/{organization}/paginated-groups [get] func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() From 790cdcf70995369cdb787729cbf13104305e5a60 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 22:17:51 +0000 Subject: [PATCH 08/15] feat(coderd/searchquery): support colon-containing group search via filter syntax Adopt the standard filter syntax for group search instead of the bespoke concatenation callback. Bare multi-word terms still become a free-text search, but a value containing a colon (legal in group display names) can now be searched by quoting it, e.g. search:"team: frontend". Unknown keys are still rejected, leaving room for real key:value filters later. Adds unit coverage for the search key, quoted colons, and bare-colon rejection, plus an end-to-end paginated-groups subtest. --- coderd/searchquery/search.go | 16 ++++++++-------- coderd/searchquery/search_test.go | 23 ++++++++++++++++++++++- enterprise/coderd/groups_test.go | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 0e3787e6954..d3bcdffb35e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -174,18 +174,18 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +// Groups parses a group search query using the standard filter syntax shared +// with the rest of the dashboard. Bare terms (including multi-word terms) +// become a free-text search over group name and display name. A value that +// contains a colon must be quoted or supplied via the explicit search key, +// e.g. search:"team: frontend", because an unquoted colon is otherwise treated +// as a key:value delimiter. Unknown keys are rejected, which keeps room for +// real key:value filters in the future. func Groups(query string) (string, []codersdk.ValidationError) { // Always lowercase for all searches. query = strings.ToLower(query) values, errors := searchTerms(query, func(term string, values url.Values) error { - // Groups support free-text search only, so join bare terms into a - // single search value. Adding each term separately would make a - // multi-word query like "front end" look like a duplicate param and - // return a 400 instead of matching the name/display name substring. - if existing := values.Get("search"); existing != "" { - term = existing + " " + term - } - values.Set("search", term) + values.Add("search", term) return nil }) if len(errors) > 0 { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index dfeb04a489e..a15e91ac166 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1772,11 +1772,32 @@ func TestSearchGroups(t *testing.T) { }, { // Structured key:value queries are not supported for groups; the - // unrecognized key surfaces as an invalid query param. + // unrecognized key surfaces as an invalid query param. Rejecting + // unknown keys leaves room for real key:value filters later. Name: "StructuredKeyValueRejected", Query: "name:alpha", ExpectedErrorContains: "is not a valid query param", }, + { + // The explicit search key is supported. + Name: "SearchKey", + Query: "search:alpha", + Expected: "alpha", + }, + { + // A colon-containing name is searchable when quoted via the search + // key, since group display names may legally contain colons. + Name: "QuotedColonValue", + Query: `search:"team: frontend"`, + Expected: "team: frontend", + }, + { + // An unquoted colon is treated as a key:value delimiter, so a bare + // colon term is rejected. Users must quote it (see QuotedColonValue). + Name: "BareColonRejected", + Query: "team: frontend", + ExpectedErrorContains: "cannot start or end with ':'", + }, } for _, c := range testCases { diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 348c80d847e..416caae1a0c 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1293,6 +1293,8 @@ func TestPaginatedGroups(t *testing.T) { {name: "Dev"}, {name: "dev"}, {name: "zeta", displayName: "Frontend Squad"}, + // A display name with a colon is searchable via a quoted search value. + {name: "team-fe", displayName: "Team: Frontend"}, } for _, spec := range specs { _, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -1428,6 +1430,22 @@ func TestPaginatedGroups(t *testing.T) { require.Equal(t, "zeta", resp.Groups[0].Name) }) + t.Run("SearchColonValue", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // A display name containing a colon is searchable when the value is + // quoted via the search key, since an unquoted colon is a key:value + // delimiter. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: `search:"team: frontend"`, + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "team-fe", resp.Groups[0].Name) + }) + t.Run("PageBoundaries", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) From d2a68f42723561928f89d8c1f518f114be7a8602 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 22:35:43 +0000 Subject: [PATCH 09/15] feat(coderd/database): support after_id keyset pagination for groups Add an optional after_id cursor to the paginated groups query so callers can page without duplicated or skipped rows when groups are inserted or deleted between requests. The cursor uses a row-value comparison on (LOWER(name), id) to match the query's ORDER BY, including the id tiebreaker for case-colliding names. after_id defaults to nil, so existing offset-based callers (the dashboard) are unaffected. Adds an AfterIDCursor subtest that pages via the cursor and asserts full, in-order, duplicate-free coverage. --- coderd/apidoc/docs.go | 7 +++++ coderd/apidoc/swagger.json | 7 +++++ coderd/database/queries.sql.go | 23 ++++++++++++---- coderd/database/queries/groups.sql | 11 ++++++++ docs/reference/api/enterprise.md | 13 +++++---- enterprise/coderd/groups.go | 2 ++ enterprise/coderd/groups_test.go | 44 ++++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 049b6ddd03e..0f0a153bc29 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5797,6 +5797,13 @@ const docTemplate = `{ "description": "Page offset", "name": "offset", "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e136794e98e..fa1842ad6a0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5134,6 +5134,13 @@ "description": "Page offset", "name": "offset", "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" } ], "responses": { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 624d3831b66..a0169e9d2ea 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15124,23 +15124,35 @@ INNER JOIN WHERE true AND groups.organization_id = $1 + -- Keyset pagination cursor. When @after_id is set, return only groups + -- ordered after it, matching the ORDER BY (LOWER(name), id) below. This + -- lets callers page without duplicated or skipped rows even if groups are + -- inserted or deleted between page requests. + AND CASE + WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + (LOWER(groups.name), groups.id) > ( + SELECT LOWER(name), id FROM groups WHERE id = $2 + ) + ELSE true + END -- Filter by group name or display name (substring, case-insensitive). - AND CASE WHEN $2 :: text != '' THEN ( - groups.name ILIKE concat('%', $2, '%') - OR groups.display_name ILIKE concat('%', $2, '%') + AND CASE WHEN $3 :: text != '' THEN ( + groups.name ILIKE concat('%', $3, '%') + OR groups.display_name ILIKE concat('%', $3, '%') ) ELSE true END ORDER BY -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. - LOWER(groups.name) ASC, groups.id ASC OFFSET $3 + LOWER(groups.name) ASC, groups.id ASC OFFSET $4 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($4 :: int, 0) + NULLIF($5 :: int, 0) ` type GetGroupsByOrganizationIDPaginatedParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` Search string `db:"search" json:"search"` OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` LimitOpt int32 `db:"limit_opt" json:"limit_opt"` @@ -15156,6 +15168,7 @@ type GetGroupsByOrganizationIDPaginatedRow struct { func (q *sqlQuerier) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) { rows, err := q.db.QueryContext(ctx, getGroupsByOrganizationIDPaginated, arg.OrganizationID, + arg.AfterID, arg.Search, arg.OffsetOpt, arg.LimitOpt, diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index f83c0d9806d..20e0448deff 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -104,6 +104,17 @@ INNER JOIN WHERE true AND groups.organization_id = @organization_id + -- Keyset pagination cursor. When @after_id is set, return only groups + -- ordered after it, matching the ORDER BY (LOWER(name), id) below. This + -- lets callers page without duplicated or skipped rows even if groups are + -- inserted or deleted between page requests. + AND CASE + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + (LOWER(groups.name), groups.id) > ( + SELECT LOWER(name), id FROM groups WHERE id = @after_id + ) + ELSE true + END -- Filter by group name or display name (substring, case-insensitive). AND CASE WHEN @search :: text != '' THEN ( groups.name ILIKE concat('%', @search, '%') diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 5a38ac88d00..68462fb6c05 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2338,12 +2338,13 @@ an error rather than a filtered subset. ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|---------|----------|-------------------------| -| `organization` | path | string | true | Organization ID or name | -| `q` | query | string | false | Search query | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|-------------------------| +| `organization` | path | string | true | Organization ID or name | +| `q` | query | string | false | Search query | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | +| `after_id` | query | string(uuid) | false | After ID | ### Example responses diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 365a33805fd..7377375f917 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -559,6 +559,7 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { // @Param q query string false "Search query" // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" +// @Param after_id query string false "After ID" format(uuid) // @Success 200 {object} codersdk.PaginatedGroupsResponse // @Description Unlike "Get groups by organization" (GET /organizations/{organization}/groups), // @Description which authorizes each group individually via its ACL, this endpoint requires @@ -588,6 +589,7 @@ func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { groups, err := api.Database.GetGroupsByOrganizationIDPaginated(ctx, database.GetGroupsByOrganizationIDPaginatedParams{ OrganizationID: org.ID, Search: search, + AfterID: paginationParams.AfterID, // #nosec G115 - Pagination offsets are small and fit in int32 OffsetOpt: int32(paginationParams.Offset), // #nosec G115 - Pagination limits are small and fit in int32 diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 416caae1a0c..647d4438b50 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1469,6 +1469,50 @@ func TestPaginatedGroups(t *testing.T) { require.Len(t, seen, totalGroups) }) + t.Run("AfterIDCursor", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Page through the results using after_id as a keyset cursor. The union + // must cover every group exactly once, in the same deterministic + // (LOWER(name), id) order, with no duplicates even across the + // "Dev"/"dev" case collision that relies on the id tiebreaker. + seen := make(map[uuid.UUID]struct{}) + var after uuid.UUID + var prevName string + var prevID uuid.UUID + havePrev := false + for { + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + Pagination: codersdk.Pagination{Limit: 2, AfterID: after}, + }) + require.NoError(t, err) + if len(resp.Groups) == 0 { + break + } + require.LessOrEqual(t, len(resp.Groups), 2) + for _, g := range resp.Groups { + _, dup := seen[g.ID] + require.False(t, dup, "group %q returned on more than one page", g.Name) + seen[g.ID] = struct{}{} + + name := strings.ToLower(g.Name) + if havePrev { + if name == prevName { + require.Less(t, prevID.String(), g.ID.String(), + "ties must advance by id") + } else { + require.Less(t, prevName, name, + "groups must stay ordered by lowercased name") + } + } + prevName, prevID, havePrev = name, g.ID, true + } + after = resp.Groups[len(resp.Groups)-1].ID + } + require.Len(t, seen, totalGroups) + }) + t.Run("OrganizationIsolation", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) From b2d9faeb6a4412463d444e1e597fdccf99d9dda8 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 22:48:29 +0000 Subject: [PATCH 10/15] feat(enterprise/coderd): stop hydrating member rosters in paginated groups Drop the per-group member roster fetch from the paginated groups endpoint (comment 7) and replace the per-group count queries with a single batched GetGroupMembersCountByGroupIDs call (comment 8), eliminating the N+1. Rosters can be large and contain member PII, and a caller authorized to read a group is not necessarily authorized to read its membership; callers page members via the group members endpoint instead. Each group still returns TotalMemberCount. Mirrors the existing pattern in coderd/workspaces.go. --- enterprise/coderd/groups.go | 47 ++++++++++++++++++++------------ enterprise/coderd/groups_test.go | 11 ++++---- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 7377375f917..25059c0bcef 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/searchquery" @@ -612,29 +613,39 @@ func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { Groups: make([]codersdk.Group, 0, len(groups)), Count: int(groups[0].Count), } - for _, group := range groups { - members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ - GroupID: group.Group.ID, - IncludeSystem: false, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{ - GroupID: group.Group.ID, - IncludeSystem: false, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } + // Fetch member counts for every group on the page in a single query to + // avoid an N+1 lookup. We intentionally do not hydrate the per-group member + // rosters here: they can be large, contain member PII, and a caller + // authorized to read a group is not necessarily authorized to read its + // membership. Callers that need the roster page it via the group members + // endpoint. Only the total member count is returned. + groupIDs := make([]uuid.UUID, len(groups)) + for i, group := range groups { + groupIDs[i] = group.Group.ID + } + // nolint:gocritic // Member counts are returned even without member read + // access, matching GetGroupMembersCountByGroupID. The endpoint already + // authorized org-wide group read. + countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{ + GroupIds: groupIDs, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + countByGroup := make(map[uuid.UUID]int64, len(countRows)) + for _, row := range countRows { + countByGroup[row.GroupID] = row.MemberCount + } + + for _, group := range groups { resp.Groups = append(resp.Groups, db2sdk.Group(database.GetGroupsRow{ Group: group.Group, OrganizationName: group.OrganizationName, OrganizationDisplayName: group.OrganizationDisplayName, - }, members, int(memberCount))) + }, nil, int(countByGroup[group.Group.ID]))) } httpapi.Write(ctx, rw, http.StatusOK, resp) diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 647d4438b50..de636bd7cf5 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1342,12 +1342,14 @@ func TestPaginatedGroups(t *testing.T) { } }) - t.Run("MemberHydration", func(t *testing.T) { + t.Run("MemberCount", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - // The handler enriches each page group with its members and total - // count. Assert both so removing that logic would fail the test. + // The list endpoint returns each group's total member count but does + // not hydrate the member roster; callers page members separately via + // the group members endpoint. Assert the count is populated and the + // roster is empty so re-adding roster hydration would fail the test. resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ SearchQuery: "alpha", }) @@ -1355,8 +1357,7 @@ func TestPaginatedGroups(t *testing.T) { require.Len(t, resp.Groups, 1) require.Equal(t, "alpha", resp.Groups[0].Name) require.Equal(t, 1, resp.Groups[0].TotalMemberCount) - require.Len(t, resp.Groups[0].Members, 1) - require.Equal(t, member.ID, resp.Groups[0].Members[0].ID) + require.Empty(t, resp.Groups[0].Members) }) t.Run("Search", func(t *testing.T) { From 19528758e860d9ddb3450fa5a8188f6078012c01 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 17:51:12 +0000 Subject: [PATCH 11/15] docs(enterprise/coderd): document q syntax and empty roster on paginated groups Expand the paginatedGroups swagger @Description to cover the shared filter syntax for q (free-text bare terms, search: as the only key, unknown keys 400, and colon values requiring quotes) and to note that the endpoint never returns member rosters (members is always empty; only total_member_count is populated; use the group members endpoint for the roster). Regenerated swagger and the API reference. --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- docs/reference/api/enterprise.md | 24 +++++++++++++++++------- enterprise/coderd/groups.go | 12 +++++++++++- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0f0a153bc29..f3c37825784 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5763,7 +5763,7 @@ const docTemplate = `{ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { - "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.", + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe ` + "`" + `q` + "`" + ` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. ` + "`" + `search:` + "`" + ` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. ` + "`" + `search:\"team: frontend\"` + "`" + `; an\nunquoted colon fails with ` + "`" + `Query element \"team:\" cannot start or end with ':'` + "`" + `.\n\nThis endpoint never returns member rosters: each group's ` + "`" + `members` + "`" + ` array is always\nempty and only ` + "`" + `total_member_count` + "`" + ` is populated. Callers that need the roster use\nthe group members endpoint (GET /groups/{group}/members).", "produces": [ "application/json" ], @@ -5782,7 +5782,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Search query", + "description": "Search query (see description for syntax and colon-quoting)", "name": "q", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fa1842ad6a0..9bae9128037 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5104,7 +5104,7 @@ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { - "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.", + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe `q` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. `search:` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. `search:\"team: frontend\"`; an\nunquoted colon fails with `Query element \"team:\" cannot start or end with ':'`.\n\nThis endpoint never returns member rosters: each group's `members` array is always\nempty and only `total_member_count` is populated. Callers that need the roster use\nthe group members endpoint (GET /groups/{group}/members).", "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Get groups by organization (paginated)", @@ -5119,7 +5119,7 @@ }, { "type": "string", - "description": "Search query", + "description": "Search query (see description for syntax and colon-quoting)", "name": "q", "in": "query" }, diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 68462fb6c05..d0f9dc6a9c8 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2336,15 +2336,25 @@ organization-wide group read permission and does no per-group filtering. It is therefore not a drop-in replacement: callers without org-wide group read receive an error rather than a filtered subset. +The `q` parameter uses the shared filter syntax. Bare terms (including multi-word) +perform a free-text search over group name and display name. `search:` is the only +accepted key and unknown keys return 400. Because group display names may contain +colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an +unquoted colon fails with `Query element "team:" cannot start or end with ':'`. + +This endpoint never returns member rosters: each group's `members` array is always +empty and only `total_member_count` is populated. Callers that need the roster use +the group members endpoint (GET /groups/{group}/members). + ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|--------------|----------|-------------------------| -| `organization` | path | string | true | Organization ID or name | -| `q` | query | string | false | Search query | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | -| `after_id` | query | string(uuid) | false | After ID | +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|-------------------------------------------------------------| +| `organization` | path | string | true | Organization ID or name | +| `q` | query | string | false | Search query (see description for syntax and colon-quoting) | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | +| `after_id` | query | string(uuid) | false | After ID | ### Example responses diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 25059c0bcef..16c8fbae20e 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -557,7 +557,7 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Enterprise // @Param organization path string true "Organization ID or name" -// @Param q query string false "Search query" +// @Param q query string false "Search query (see description for syntax and colon-quoting)" // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Param after_id query string false "After ID" format(uuid) @@ -567,6 +567,16 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { // @Description organization-wide group read permission and does no per-group filtering. It is // @Description therefore not a drop-in replacement: callers without org-wide group read receive // @Description an error rather than a filtered subset. +// @Description +// @Description The `q` parameter uses the shared filter syntax. Bare terms (including multi-word) +// @Description perform a free-text search over group name and display name. `search:` is the only +// @Description accepted key and unknown keys return 400. Because group display names may contain +// @Description colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an +// @Description unquoted colon fails with `Query element "team:" cannot start or end with ':'`. +// @Description +// @Description This endpoint never returns member rosters: each group's `members` array is always +// @Description empty and only `total_member_count` is populated. Callers that need the roster use +// @Description the group members endpoint (GET /groups/{group}/members). // @Router /api/v2/organizations/{organization}/paginated-groups [get] func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() From e6b97a432cc45749cc1bbde8790968b16fbae5ef Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 18:43:09 +0000 Subject: [PATCH 12/15] refactor(codersdk): use slim group summaries in paginated response The paginated groups endpoint does not return member rosters, but its response reused codersdk.Group, so generated Swagger examples and schemas.md advertised a members array that the endpoint never populates. Introduce codersdk.PaginatedGroup, a slim summary that omits members and keeps total_member_count, and convert rows via db2sdk.PaginatedGroup so the generated API examples and schemas match the actual contract. --- coderd/apidoc/docs.go | 43 ++++++++++++++++++++++-- coderd/apidoc/swagger.json | 43 ++++++++++++++++++++++-- coderd/database/db2sdk/db2sdk.go | 18 ++++++++++ codersdk/groups.go | 24 ++++++++++++-- docs/reference/api/enterprise.md | 22 ++----------- docs/reference/api/schemas.md | 56 ++++++++++++++++++++------------ enterprise/coderd/groups.go | 14 ++++---- enterprise/coderd/groups_test.go | 6 ++-- site/src/api/typesGenerated.ts | 27 ++++++++++++++- 9 files changed, 197 insertions(+), 56 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f3c37825784..0984131b589 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5763,7 +5763,7 @@ const docTemplate = `{ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { - "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe ` + "`" + `q` + "`" + ` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. ` + "`" + `search:` + "`" + ` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. ` + "`" + `search:\"team: frontend\"` + "`" + `; an\nunquoted colon fails with ` + "`" + `Query element \"team:\" cannot start or end with ':'` + "`" + `.\n\nThis endpoint never returns member rosters: each group's ` + "`" + `members` + "`" + ` array is always\nempty and only ` + "`" + `total_member_count` + "`" + ` is populated. Callers that need the roster use\nthe group members endpoint (GET /groups/{group}/members).", + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe ` + "`" + `q` + "`" + ` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. ` + "`" + `search:` + "`" + ` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. ` + "`" + `search:\"team: frontend\"` + "`" + `; an\nunquoted colon fails with ` + "`" + `Query element \"team:\" cannot start or end with ':'` + "`" + `.\n\nThis endpoint returns group summaries without the member roster: each group\ncarries only ` + "`" + `total_member_count` + "`" + ` and no ` + "`" + `members` + "`" + ` field. Callers that need the\nroster use the group members endpoint (GET /groups/{group}/members).", "produces": [ "application/json" ], @@ -22570,6 +22570,45 @@ const docTemplate = `{ } } }, + "codersdk.PaginatedGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "TotalMemberCount is the number of members in the group, shown even when\nthe caller cannot read individual members. The roster itself is not\nreturned by this endpoint.", + "type": "integer" + } + } + }, "codersdk.PaginatedGroupsResponse": { "type": "object", "properties": { @@ -22579,7 +22618,7 @@ const docTemplate = `{ "groups": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Group" + "$ref": "#/definitions/codersdk.PaginatedGroup" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9bae9128037..4bcc450731e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5104,7 +5104,7 @@ }, "/api/v2/organizations/{organization}/paginated-groups": { "get": { - "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe `q` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. `search:` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. `search:\"team: frontend\"`; an\nunquoted colon fails with `Query element \"team:\" cannot start or end with ':'`.\n\nThis endpoint never returns member rosters: each group's `members` array is always\nempty and only `total_member_count` is populated. Callers that need the roster use\nthe group members endpoint (GET /groups/{group}/members).", + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe `q` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. `search:` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. `search:\"team: frontend\"`; an\nunquoted colon fails with `Query element \"team:\" cannot start or end with ':'`.\n\nThis endpoint returns group summaries without the member roster: each group\ncarries only `total_member_count` and no `members` field. Callers that need the\nroster use the group members endpoint (GET /groups/{group}/members).", "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Get groups by organization (paginated)", @@ -20619,6 +20619,45 @@ } } }, + "codersdk.PaginatedGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "TotalMemberCount is the number of members in the group, shown even when\nthe caller cannot read individual members. The roster itself is not\nreturned by this endpoint.", + "type": "integer" + } + } + }, "codersdk.PaginatedGroupsResponse": { "type": "object", "properties": { @@ -20628,7 +20667,7 @@ "groups": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Group" + "$ref": "#/definitions/codersdk.PaginatedGroup" } } } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 445f0222424..67bff9308ad 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -377,6 +377,24 @@ func Group(row database.GetGroupsRow, members []database.GroupMember, totalMembe } } +// PaginatedGroup converts a group row into the slim summary returned by the +// paginated groups endpoint, which omits the member roster and carries only +// the total member count. +func PaginatedGroup(row database.GetGroupsRow, totalMemberCount int) codersdk.PaginatedGroup { + return codersdk.PaginatedGroup{ + ID: row.Group.ID, + Name: row.Group.Name, + DisplayName: row.Group.DisplayName, + OrganizationID: row.Group.OrganizationID, + AvatarURL: row.Group.AvatarURL, + TotalMemberCount: totalMemberCount, + QuotaAllowance: int(row.Group.QuotaAllowance), + Source: codersdk.GroupSource(row.Group.Source), + OrganizationName: row.OrganizationName, + OrganizationDisplayName: row.OrganizationDisplayName, + } +} + func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { // Use a stable sort, similarly to how we would sort in the query, note that // we don't sort in the query because order varies depending on the table diff --git a/codersdk/groups.go b/codersdk/groups.go index 4673a26a1e4..acd5c906d94 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -49,8 +49,28 @@ type GroupMembersResponse struct { } type PaginatedGroupsResponse struct { - Groups []Group `json:"groups"` - Count int `json:"count"` + Groups []PaginatedGroup `json:"groups"` + Count int `json:"count"` +} + +// PaginatedGroup is a group summary returned by the paginated groups endpoint. +// It deliberately omits the member roster (which the endpoint does not return) +// and exposes only the total member count. Fetch the roster via the group +// members endpoint. +type PaginatedGroup struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + // TotalMemberCount is the number of members in the group, shown even when + // the caller cannot read individual members. The roster itself is not + // returned by this endpoint. + TotalMemberCount int `json:"total_member_count"` + AvatarURL string `json:"avatar_url" format:"uri"` + QuotaAllowance int `json:"quota_allowance"` + Source GroupSource `json:"source"` + OrganizationName string `json:"organization_name"` + OrganizationDisplayName string `json:"organization_display_name"` } // PaginatedGroupsRequest are the filters for a paginated groups request. diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index d0f9dc6a9c8..92da32c8353 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2342,9 +2342,9 @@ accepted key and unknown keys return 400. Because group display names may contai colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an unquoted colon fails with `Query element "team:" cannot start or end with ':'`. -This endpoint never returns member rosters: each group's `members` array is always -empty and only `total_member_count` is populated. Callers that need the roster use -the group members endpoint (GET /groups/{group}/members). +This endpoint returns group summaries without the member roster: each group +carries only `total_member_count` and no `members` field. Callers that need the +roster use the group members endpoint (GET /groups/{group}/members). ### Parameters @@ -2368,22 +2368,6 @@ the group members endpoint (GET /groups/{group}/members). "avatar_url": "http://example.com", "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "members": [ - { - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "is_service_account": true, - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", - "username": "string" - } - ], "name": "string", "organization_display_name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 061d9850b14..4f9be023297 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9776,6 +9776,38 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen | » `[any property]` | array of string | false | | | | `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. | +## codersdk.PaginatedGroup + +```json +{ + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|----------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `organization_display_name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | +| `total_member_count` | integer | false | | Total member count is the number of members in the group, shown even when the caller cannot read individual members. The roster itself is not returned by this endpoint. | + ## codersdk.PaginatedGroupsResponse ```json @@ -9786,22 +9818,6 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen "avatar_url": "http://example.com", "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "members": [ - { - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "is_service_account": true, - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", - "username": "string" - } - ], "name": "string", "organization_display_name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -9816,10 +9832,10 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen ### Properties -| Name | Type | Required | Restrictions | Description | -|----------|-------------------------------------------|----------|--------------|-------------| -| `count` | integer | false | | | -| `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | +| Name | Type | Required | Restrictions | Description | +|----------|-------------------------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `groups` | array of [codersdk.PaginatedGroup](#codersdkpaginatedgroup) | false | | | ## codersdk.PaginatedMembersResponse diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 16c8fbae20e..839a55e3647 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -574,9 +574,9 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { // @Description colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an // @Description unquoted colon fails with `Query element "team:" cannot start or end with ':'`. // @Description -// @Description This endpoint never returns member rosters: each group's `members` array is always -// @Description empty and only `total_member_count` is populated. Callers that need the roster use -// @Description the group members endpoint (GET /groups/{group}/members). +// @Description This endpoint returns group summaries without the member roster: each group +// @Description carries only `total_member_count` and no `members` field. Callers that need the +// @Description roster use the group members endpoint (GET /groups/{group}/members). // @Router /api/v2/organizations/{organization}/paginated-groups [get] func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -613,14 +613,14 @@ func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { if len(groups) == 0 { httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedGroupsResponse{ - Groups: []codersdk.Group{}, + Groups: []codersdk.PaginatedGroup{}, Count: 0, }) return } resp := codersdk.PaginatedGroupsResponse{ - Groups: make([]codersdk.Group, 0, len(groups)), + Groups: make([]codersdk.PaginatedGroup, 0, len(groups)), Count: int(groups[0].Count), } @@ -651,11 +651,11 @@ func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { } for _, group := range groups { - resp.Groups = append(resp.Groups, db2sdk.Group(database.GetGroupsRow{ + resp.Groups = append(resp.Groups, db2sdk.PaginatedGroup(database.GetGroupsRow{ Group: group.Group, OrganizationName: group.OrganizationName, OrganizationDisplayName: group.OrganizationDisplayName, - }, nil, int(countByGroup[group.Group.ID]))) + }, int(countByGroup[group.Group.ID]))) } httpapi.Write(ctx, rw, http.StatusOK, resp) diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index de636bd7cf5..ae1dee58350 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1348,8 +1348,9 @@ func TestPaginatedGroups(t *testing.T) { // The list endpoint returns each group's total member count but does // not hydrate the member roster; callers page members separately via - // the group members endpoint. Assert the count is populated and the - // roster is empty so re-adding roster hydration would fail the test. + // the group members endpoint. Assert the count is populated. The + // roster is omitted entirely: the slim PaginatedGroup type has no + // Members field, so re-adding roster hydration would fail to compile. resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ SearchQuery: "alpha", }) @@ -1357,7 +1358,6 @@ func TestPaginatedGroups(t *testing.T) { require.Len(t, resp.Groups, 1) require.Equal(t, "alpha", resp.Groups[0].Name) require.Equal(t, 1, resp.Groups[0].TotalMemberCount) - require.Empty(t, resp.Groups[0].Members) }) t.Run("Search", func(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b368c0e0825..b3fcb9890d9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6922,6 +6922,31 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// From codersdk/groups.go +/** + * PaginatedGroup is a group summary returned by the paginated groups endpoint. + * It deliberately omits the member roster (which the endpoint does not return) + * and exposes only the total member count. Fetch the roster via the group + * members endpoint. + */ +export interface PaginatedGroup { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly organization_id: string; + /** + * TotalMemberCount is the number of members in the group, shown even when + * the caller cannot read individual members. The roster itself is not + * returned by this endpoint. + */ + readonly total_member_count: number; + readonly avatar_url: string; + readonly quota_allowance: number; + readonly source: GroupSource; + readonly organization_name: string; + readonly organization_display_name: string; +} + // From codersdk/groups.go /** * PaginatedGroupsRequest are the filters for a paginated groups request. @@ -6934,7 +6959,7 @@ export interface PaginatedGroupsRequest extends Pagination { // From codersdk/groups.go export interface PaginatedGroupsResponse { - readonly groups: readonly Group[]; + readonly groups: readonly PaginatedGroup[]; readonly count: number; } From c275054c58a1e57cf07688233eee4cc6b6d98065 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 15:20:33 -0700 Subject: [PATCH 13/15] feat(site): add server-side search and pagination for groups page (#27604) frontend-only changes from #27271; see that PR for summary of changes + implementation details > URL path: /organizations/:organization/groups > > The main purpose of this PR is to add a search field and pagination controls to the groups page. [...] > > I also moved the "Create group" button down from `GroupsPage` into `GroupsPageView`, to keep visually consistent with the "Add users" button on the members page. > > image > > See Storybook for what pagination looks like: > > image --- site/src/api/api.ts | 16 ++ site/src/api/queries/groups.ts | 60 ++++++ site/src/components/Filter/Filter.tsx | 18 +- site/src/components/Filter/GroupsFilter.tsx | 19 ++ site/src/pages/GroupsPage/GroupsPage.tsx | 34 ++-- .../GroupsPage/GroupsPageView.stories.tsx | 173 +++++++++++++++-- site/src/pages/GroupsPage/GroupsPageView.tsx | 178 +++++++++++++----- 7 files changed, 404 insertions(+), 94 deletions(-) create mode 100644 site/src/components/Filter/GroupsFilter.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 64a922b6151..c5eb729befc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2231,6 +2231,22 @@ class ApiMethods { }; }; + /** + * @param organization Can be the organization's ID or name + * @param options Pagination and search options + */ + getOrganizationPaginatedGroups = async ( + organization: string, + options?: TypesGen.PaginatedGroupsRequest, + ): Promise => { + const url = getURLWithSearchParams( + `/api/v2/organizations/${organization}/paginated-groups`, + options, + ); + const response = await this.axios.get(url); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 6e714f92c79..8360b268f34 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -9,6 +9,8 @@ import type { GroupMembersResponse, GroupRequest, OrganizationGroupsAISpend, + PaginatedGroupsRequest, + PaginatedGroupsResponse, PatchGroupRequest, UsersRequest, } from "#/api/typesGenerated"; @@ -93,6 +95,37 @@ export const groupMembersAISpend = ( } satisfies UseQueryOptions; }; +const getPaginatedGroupsByOrganizationQueryKey = ( + organization: string, + req?: PaginatedGroupsRequest, +) => { + // Nested under the org groups key so create/patch/delete invalidations, + // which target ["organization", org, "groups"], also cover this list. + const base = [...getGroupsByOrganizationQueryKey(organization), "paginated"]; + return req ? [...base, req] : base; +}; + +export function paginatedGroupsByOrganization( + organization: string, + searchParams: URLSearchParams, +): UsePaginatedQueryOptions { + return { + searchParams, + queryPayload: ({ limit, offset }) => { + return { + limit, + offset, + q: prepareQuery(searchParams.get("filter") ?? ""), + }; + }, + + queryKey: ({ payload }) => + getPaginatedGroupsByOrganizationQueryKey(organization, payload), + queryFn: ({ payload }) => + API.getOrganizationPaginatedGroups(organization, payload), + }; +} + const getRootGroupQueryKey = (organization: string, groupName: string) => [ "organization", organization, @@ -167,6 +200,33 @@ export function groupMembers( }; } +export const getGroupMemberAvatarsQueryKey = ( + organization: string, + groupName: string, + limit: number, +) => [...getGroupMembersQueryKey(organization, groupName), "avatars", limit]; + +/** Number of member avatars previewed per group row in list views. */ +export const GROUP_MEMBER_AVATAR_LIMIT = 5; + +/** + * A capped page of a group's members for avatar previews in list views. The + * paginated groups endpoint no longer returns rosters, so rows fetch a small + * preview lazily. Nests under the group members key so membership mutations + * invalidate it. + */ +export const groupMemberAvatars = ( + organization: string, + groupName: string, + limit: number, +): UseQueryOptions => { + return { + queryKey: getGroupMemberAvatarsQueryKey(organization, groupName, limit), + queryFn: ({ signal }) => + API.getGroupMembers(organization, groupName, { limit }, signal), + }; +}; + export type GroupsByUserId = Readonly>; export function groupsByUserId() { diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index d194d0f237a..3f8972af69e 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -210,14 +210,16 @@ export const Filter: FC = ({ ) : ( <> - filter.update(query)} - presets={presets} - learnMoreLink={learnMoreLink} - learnMoreLabel2={learnMoreLabel2} - learnMoreLink2={learnMoreLink2} - /> + {presets.length > 0 && ( + filter.update(query)} + presets={presets} + learnMoreLink={learnMoreLink} + learnMoreLabel2={learnMoreLabel2} + learnMoreLink2={learnMoreLink2} + /> + )}
; +} + +// GroupsFilter renders a search-only filter. Groups support free-text search +// against name and display name, so there are no presets or option menus. +export const GroupsFilter: FC = ({ filter }) => { + return ( + + ); +}; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index b1fd6d89f50..74caaab4897 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,22 +1,22 @@ -import { PlusIcon } from "lucide-react"; import { type FC, useEffect } from "react"; import { useQuery } from "react-query"; -import { Link as RouterLink } from "react-router"; +import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { - groupsByOrganization, organizationGroupsAISpend, + paginatedGroupsByOrganization, } from "#/api/queries/groups"; import { organizationsPermissions } from "#/api/queries/organizations"; -import { Button } from "#/components/Button/Button"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { useFilter } from "#/components/Filter/Filter"; import { Loader } from "#/components/Loader/Loader"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -27,17 +27,22 @@ const GroupsPage: FC = () => { const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility(); const { organization, showOrganizations } = useGroupsSettings(); const aibridgeVisible = Boolean(aibridge); - const groupsQuery = useQuery({ - ...groupsByOrganization(organization?.name ?? ""), - enabled: Boolean(organization), + const [searchParams, setSearchParams] = useSearchParams(); + const groupsQuery = usePaginatedQuery( + paginatedGroupsByOrganization(organization?.name ?? "", searchParams), + ); + const filter = useFilter({ + searchParams, + onSearchParamsChange: setSearchParams, + onUpdate: groupsQuery.goToFirstPage, }); - const groupIds = groupsQuery.data?.map((group) => group.id) ?? []; + const groupIds = groupsQuery.data?.groups.map((group) => group.id) ?? []; const groupsSpendQuery = useQuery({ ...organizationGroupsAISpend(organization?.name ?? "", groupIds), enabled: aibridgeVisible && Boolean(organization) && groupIds.length > 0, }); const groupsWithSpend = joinGroupsSpend( - groupsQuery.data, + groupsQuery.data?.groups, groupsSpendQuery.data, ); const permissionsQuery = useQuery({ @@ -111,15 +116,6 @@ const GroupsPage: FC = () => { {showOrganizations ? "organization" : "deployment"}. - - {groupsEnabled && permissions.createGroup && ( - - )}
{ canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} showAIBudget={aibridgeVisible} + filterProps={{ filter }} + groupsQuery={groupsQuery} /> ); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 454f184f620..1accbbe8caf 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,11 +1,42 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; -import { MockGroup } from "#/testHelpers/entities"; +import { + GROUP_MEMBER_AVATAR_LIMIT, + getGroupMemberAvatarsQueryKey, +} from "#/api/queries/groups"; +import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; +import type { UsersFilter } from "#/components/Filter/UsersFilter"; +import { + mockInitialRenderResult, + mockSuccessResult, +} from "#/components/PaginationWidget/PaginationContainer.mocks"; +import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; +import { + MockGroup, + MockOrganization, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView"; +type FilterProps = ComponentProps; + const meta: Meta = { title: "pages/OrganizationGroupsPage", component: GroupsPageView, + args: { + canCreateGroup: true, + groupsEnabled: true, + filterProps: getDefaultFilterProps({ + values: {}, + menus: {}, + }), + groupsQuery: { + ...mockSuccessResult, + totalRecords: 1, + } as UsePaginatedQueryResult, + }, }; export default meta; @@ -16,33 +47,123 @@ const mockGroupWithSpend: GroupWithSpend = { spend: undefined, }; +// AI-budget and pagination stories aren't about membership, so give their +// groups no members. Rows with a zero count skip the per-row avatar fetch. const aiGroup = (id: string, name: string): GroupWithSpend => ({ ...mockGroupWithSpend, id, name, display_name: name, + total_member_count: 0, +}); + +// Seeds the per-row member avatar preview query for a group so the row renders +// avatars deterministically without hitting the network. +const seedAvatars = ( + groupName: string, + users: ReadonlyArray, + totalCount: number, +) => ({ + key: getGroupMemberAvatarsQueryKey( + MockOrganization.name, + groupName, + GROUP_MEMBER_AVATAR_LIMIT, + ), + data: { users, count: totalCount }, }); +export const Default: Story = {}; + export const NotEnabled: Story = { args: { - groups: [{ ...mockGroupWithSpend }], - canCreateGroup: true, + groups: [mockGroupWithSpend], groupsEnabled: false, }, }; export const WithGroups: Story = { args: { - groups: [{ ...mockGroupWithSpend }], - canCreateGroup: true, - groupsEnabled: true, + groups: [mockGroupWithSpend], + }, + parameters: { + queries: [seedAvatars(MockGroup.name, [MockUserOwner, MockUserMember], 2)], + }, +}; + +// A group with more members than fit in the preview: the row shows the capped +// avatars plus a "+N" badge derived from total_member_count. +export const WithMemberAvatars: Story = { + args: { + groups: [ + { + ...mockGroupWithSpend, + id: "with-members", + name: "with-members", + display_name: "With members", + total_member_count: 8, + }, + ], + }, + parameters: { + queries: [ + seedAvatars( + "with-members", + Array.from({ length: GROUP_MEMBER_AVATAR_LIMIT }, (_, i) => ({ + ...MockUserOwner, + id: `preview-${i}`, + username: `member-${i}`, + })), + 8, + ), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("+3")).toBeInTheDocument(); + await expect(canvas.getByText("8 members")).toBeInTheDocument(); + }, +}; + +const totalRecords = 15; +const totalPages = 3; +const limit = totalRecords / totalPages; + +// Multiple pages of results with the search field in use. +export const WithSearchAndPagination: Story = { + args: { + groups: Array.from({ length: limit }, (_, i) => + aiGroup(`group-${i}`, `Group ${i}`), + ), + filterProps: getDefaultFilterProps({ + query: "group", + values: {}, + menus: {}, + used: true, + }), + groupsQuery: { + ...mockSuccessResult, + totalRecords, + totalPages, + limit, + hasNextPage: true, + } as UsePaginatedQueryResult, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByLabelText("Filter")).toHaveValue("group"); + }, +}; + +// Groups still loading: the pagination + table render their loading states. +export const Loading: Story = { + args: { + groups: undefined, + groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, }, }; export const WithAIBudgets: Story = { args: { - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, groups: [ { @@ -139,9 +260,8 @@ export const WithAIBudgets: Story = { export const WithAIBudgetsLoading: Story = { args: { groups: undefined, - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, + groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, }, }; @@ -185,8 +305,6 @@ export const WithAIBudgetsSpendError: Story = { export const WithAIBudgetsSpendUnavailable: Story = { args: { groups: [aiGroup("ai-unavailable", "Spend unavailable")], - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, }, play: async ({ canvasElement }) => { @@ -201,8 +319,6 @@ export const WithAIBudgetsSpendUnavailable: Story = { export const WithoutAIBudgetColumn: Story = { args: { groups: [aiGroup("ai-hidden", "No AI column")], - canCreateGroup: true, - groupsEnabled: true, showAIBudget: false, }, play: async ({ canvasElement }) => { @@ -214,8 +330,9 @@ export const WithoutAIBudgetColumn: Story = { export const WithDisplayGroup: Story = { args: { groups: [{ ...mockGroupWithSpend, name: "front-end" }], - canCreateGroup: true, - groupsEnabled: true, + }, + parameters: { + queries: [seedAvatars("front-end", [MockUserOwner, MockUserMember], 2)], }, }; @@ -223,14 +340,32 @@ export const EmptyGroup: Story = { args: { groups: [], canCreateGroup: false, - groupsEnabled: true, }, }; export const EmptyGroupWithPermission: Story = { args: { groups: [], - canCreateGroup: true, - groupsEnabled: true, + }, +}; + +// A search that matches nothing shows filter-aware copy, not the +// create-first-group empty state. +export const NoSearchResults: Story = { + args: { + groups: [], + filterProps: getDefaultFilterProps({ + query: "nomatch", + values: {}, + menus: {}, + used: true, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("No groups match your search"), + ).toBeInTheDocument(); + expect(canvas.queryByText("No groups yet")).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index b4b2eab5274..3369776c3e7 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,13 +1,25 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; import type { FC } from "react"; +import { useQuery } from "react-query"; import { Link as RouterLink, useNavigate } from "react-router"; -import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; +import { + GROUP_MEMBER_AVATAR_LIMIT, + groupMemberAvatars, +} from "#/api/queries/groups"; +import type { + OrganizationGroupsAISpend, + PaginatedGroup, +} from "#/api/typesGenerated"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; +import { EmptyState } from "#/components/EmptyState/EmptyState"; +import type { useFilter } from "#/components/Filter/Filter"; +import { GroupsFilter } from "#/components/Filter/GroupsFilter"; +import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { @@ -24,19 +36,23 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { docs } from "#/utils/docs"; import { SpendEstimateDocsLink } from "./AICostControl"; import { StatusIconTooltip } from "./StatusIconTooltip"; const EM_DASH = "\u2014"; -export type GroupWithSpend = Group & { +// Stable keys for the avatar loading skeletons (indexes would trip lint). +const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"]; + +export type GroupWithSpend = PaginatedGroup & { readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined; }; /** Attach each group's spend, when present, so rows get a single object. */ export const joinGroupsSpend = ( - groups: Group[] | undefined, + groups: readonly PaginatedGroup[] | undefined, groupsSpend: OrganizationGroupsAISpend | undefined, ): GroupWithSpend[] | undefined => { if (groups === undefined) { @@ -58,6 +74,10 @@ type GroupsPageViewProps = { canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; + filterProps: { filter: ReturnType }; + groupsQuery: PaginationResultInfo & { + isPlaceholderData: boolean; + }; }; export const GroupsPageView: FC = ({ @@ -66,6 +86,8 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + filterProps, + groupsQuery, }) => { if (!groupsEnabled) { return ( @@ -78,46 +100,63 @@ export const GroupsPageView: FC = ({ } return ( - - - - Name - - Users - - {showAIBudget && ( - -
- AI budget - {spendError ? ( - - ) : ( - - Estimated AI spend compared to the group's AI budget for - the active period. - - } - /> - )} -
-
- )} - -
-
- - - -
+
+
+ + {canCreateGroup && ( + + )} +
+ + + + + + Name + + Users + + {showAIBudget && ( + +
+ AI budget + {spendError ? ( + + ) : ( + + Estimated AI spend compared to the group's AI budget + for the active period. + + } + /> + )} +
+
+ )} + +
+
+ + + +
+
+
); }; @@ -125,17 +164,33 @@ interface GroupsTableBodyProps { groups: GroupWithSpend[] | undefined; canCreateGroup: boolean; showAIBudget: boolean; + filterUsed: boolean; } const GroupsTableBody: FC = ({ groups, canCreateGroup, showAIBudget, + filterUsed, }) => { if (groups === undefined) { return ; } if (groups.length === 0) { + // When a search returned no matches, don't nudge the user to create a + // first group; the org may already have groups that simply don't match. + if (filterUsed) { + return ( + + + + + + ); + } return ( = ({ group, showAIBudget }) => { const rowProps = useClickableTableRow({ onClick: () => navigate(group.name), }); - const memberAvatars = group.members.slice(0, 5); - const remainingAvatars = group.members.length - memberAvatars.length; + + // The list endpoint returns only total_member_count, so fetch a small + // avatar preview per visible row instead of a full roster. + const membersQuery = useQuery({ + ...groupMemberAvatars( + group.organization_name, + group.name, + GROUP_MEMBER_AVATAR_LIMIT, + ), + enabled: group.total_member_count > 0, + }); + const memberAvatars = membersQuery.data?.users ?? []; + const remainingAvatars = group.total_member_count - memberAvatars.length; + const skeletonCount = Math.min( + group.total_member_count, + GROUP_MEMBER_AVATAR_LIMIT, + ); return ( @@ -192,12 +262,24 @@ const GroupRow: FC = ({ group, showAIBudget }) => { /> } title={group.display_name || group.name} - subtitle={`${group.members.length} members`} + subtitle={`${group.total_member_count} members`} /> - {group.members.length > 0 ? ( + {group.total_member_count === 0 || membersQuery.isError ? ( + EM_DASH + ) : membersQuery.isLoading ? ( +
+ {AVATAR_SKELETON_KEYS.slice(0, skeletonCount).map((key) => ( + + ))} +
+ ) : (
{memberAvatars.map((member) => ( = ({ group, showAIBudget }) => { )}
- ) : ( - EM_DASH )}
From 3afa0edac49eef43b1edc48ac636f6058d32c4b6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 10 Aug 2026 16:23:20 +0000 Subject: [PATCH 14/15] Revert "feat(site): add server-side search and pagination for groups page (#27604)" This reverts commit c275054c58a1e57cf07688233eee4cc6b6d98065. --- site/src/api/api.ts | 16 -- site/src/api/queries/groups.ts | 60 ------ site/src/components/Filter/Filter.tsx | 18 +- site/src/components/Filter/GroupsFilter.tsx | 19 -- site/src/pages/GroupsPage/GroupsPage.tsx | 34 ++-- .../GroupsPage/GroupsPageView.stories.tsx | 173 ++--------------- site/src/pages/GroupsPage/GroupsPageView.tsx | 178 +++++------------- 7 files changed, 94 insertions(+), 404 deletions(-) delete mode 100644 site/src/components/Filter/GroupsFilter.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c5eb729befc..64a922b6151 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2231,22 +2231,6 @@ class ApiMethods { }; }; - /** - * @param organization Can be the organization's ID or name - * @param options Pagination and search options - */ - getOrganizationPaginatedGroups = async ( - organization: string, - options?: TypesGen.PaginatedGroupsRequest, - ): Promise => { - const url = getURLWithSearchParams( - `/api/v2/organizations/${organization}/paginated-groups`, - options, - ); - const response = await this.axios.get(url); - return response.data; - }; - /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 8360b268f34..6e714f92c79 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -9,8 +9,6 @@ import type { GroupMembersResponse, GroupRequest, OrganizationGroupsAISpend, - PaginatedGroupsRequest, - PaginatedGroupsResponse, PatchGroupRequest, UsersRequest, } from "#/api/typesGenerated"; @@ -95,37 +93,6 @@ export const groupMembersAISpend = ( } satisfies UseQueryOptions; }; -const getPaginatedGroupsByOrganizationQueryKey = ( - organization: string, - req?: PaginatedGroupsRequest, -) => { - // Nested under the org groups key so create/patch/delete invalidations, - // which target ["organization", org, "groups"], also cover this list. - const base = [...getGroupsByOrganizationQueryKey(organization), "paginated"]; - return req ? [...base, req] : base; -}; - -export function paginatedGroupsByOrganization( - organization: string, - searchParams: URLSearchParams, -): UsePaginatedQueryOptions { - return { - searchParams, - queryPayload: ({ limit, offset }) => { - return { - limit, - offset, - q: prepareQuery(searchParams.get("filter") ?? ""), - }; - }, - - queryKey: ({ payload }) => - getPaginatedGroupsByOrganizationQueryKey(organization, payload), - queryFn: ({ payload }) => - API.getOrganizationPaginatedGroups(organization, payload), - }; -} - const getRootGroupQueryKey = (organization: string, groupName: string) => [ "organization", organization, @@ -200,33 +167,6 @@ export function groupMembers( }; } -export const getGroupMemberAvatarsQueryKey = ( - organization: string, - groupName: string, - limit: number, -) => [...getGroupMembersQueryKey(organization, groupName), "avatars", limit]; - -/** Number of member avatars previewed per group row in list views. */ -export const GROUP_MEMBER_AVATAR_LIMIT = 5; - -/** - * A capped page of a group's members for avatar previews in list views. The - * paginated groups endpoint no longer returns rosters, so rows fetch a small - * preview lazily. Nests under the group members key so membership mutations - * invalidate it. - */ -export const groupMemberAvatars = ( - organization: string, - groupName: string, - limit: number, -): UseQueryOptions => { - return { - queryKey: getGroupMemberAvatarsQueryKey(organization, groupName, limit), - queryFn: ({ signal }) => - API.getGroupMembers(organization, groupName, { limit }, signal), - }; -}; - export type GroupsByUserId = Readonly>; export function groupsByUserId() { diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index 3f8972af69e..d194d0f237a 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -210,16 +210,14 @@ export const Filter: FC = ({ ) : ( <> - {presets.length > 0 && ( - filter.update(query)} - presets={presets} - learnMoreLink={learnMoreLink} - learnMoreLabel2={learnMoreLabel2} - learnMoreLink2={learnMoreLink2} - /> - )} + filter.update(query)} + presets={presets} + learnMoreLink={learnMoreLink} + learnMoreLabel2={learnMoreLabel2} + learnMoreLink2={learnMoreLink2} + />
; -} - -// GroupsFilter renders a search-only filter. Groups support free-text search -// against name and display name, so there are no presets or option menus. -export const GroupsFilter: FC = ({ filter }) => { - return ( - - ); -}; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 74caaab4897..b1fd6d89f50 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,22 +1,22 @@ +import { PlusIcon } from "lucide-react"; import { type FC, useEffect } from "react"; import { useQuery } from "react-query"; -import { useSearchParams } from "react-router"; +import { Link as RouterLink } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { + groupsByOrganization, organizationGroupsAISpend, - paginatedGroupsByOrganization, } from "#/api/queries/groups"; import { organizationsPermissions } from "#/api/queries/organizations"; +import { Button } from "#/components/Button/Button"; import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { useFilter } from "#/components/Filter/Filter"; import { Loader } from "#/components/Loader/Loader"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -27,22 +27,17 @@ const GroupsPage: FC = () => { const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility(); const { organization, showOrganizations } = useGroupsSettings(); const aibridgeVisible = Boolean(aibridge); - const [searchParams, setSearchParams] = useSearchParams(); - const groupsQuery = usePaginatedQuery( - paginatedGroupsByOrganization(organization?.name ?? "", searchParams), - ); - const filter = useFilter({ - searchParams, - onSearchParamsChange: setSearchParams, - onUpdate: groupsQuery.goToFirstPage, + const groupsQuery = useQuery({ + ...groupsByOrganization(organization?.name ?? ""), + enabled: Boolean(organization), }); - const groupIds = groupsQuery.data?.groups.map((group) => group.id) ?? []; + const groupIds = groupsQuery.data?.map((group) => group.id) ?? []; const groupsSpendQuery = useQuery({ ...organizationGroupsAISpend(organization?.name ?? "", groupIds), enabled: aibridgeVisible && Boolean(organization) && groupIds.length > 0, }); const groupsWithSpend = joinGroupsSpend( - groupsQuery.data?.groups, + groupsQuery.data, groupsSpendQuery.data, ); const permissionsQuery = useQuery({ @@ -116,6 +111,15 @@ const GroupsPage: FC = () => { {showOrganizations ? "organization" : "deployment"}. + + {groupsEnabled && permissions.createGroup && ( + + )}
{ canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} showAIBudget={aibridgeVisible} - filterProps={{ filter }} - groupsQuery={groupsQuery} /> ); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 1accbbe8caf..454f184f620 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,42 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; -import { - GROUP_MEMBER_AVATAR_LIMIT, - getGroupMemberAvatarsQueryKey, -} from "#/api/queries/groups"; -import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; -import type { UsersFilter } from "#/components/Filter/UsersFilter"; -import { - mockInitialRenderResult, - mockSuccessResult, -} from "#/components/PaginationWidget/PaginationContainer.mocks"; -import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; -import { - MockGroup, - MockOrganization, - MockUserMember, - MockUserOwner, -} from "#/testHelpers/entities"; +import { MockGroup } from "#/testHelpers/entities"; import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView"; -type FilterProps = ComponentProps; - const meta: Meta = { title: "pages/OrganizationGroupsPage", component: GroupsPageView, - args: { - canCreateGroup: true, - groupsEnabled: true, - filterProps: getDefaultFilterProps({ - values: {}, - menus: {}, - }), - groupsQuery: { - ...mockSuccessResult, - totalRecords: 1, - } as UsePaginatedQueryResult, - }, }; export default meta; @@ -47,123 +16,33 @@ const mockGroupWithSpend: GroupWithSpend = { spend: undefined, }; -// AI-budget and pagination stories aren't about membership, so give their -// groups no members. Rows with a zero count skip the per-row avatar fetch. const aiGroup = (id: string, name: string): GroupWithSpend => ({ ...mockGroupWithSpend, id, name, display_name: name, - total_member_count: 0, -}); - -// Seeds the per-row member avatar preview query for a group so the row renders -// avatars deterministically without hitting the network. -const seedAvatars = ( - groupName: string, - users: ReadonlyArray, - totalCount: number, -) => ({ - key: getGroupMemberAvatarsQueryKey( - MockOrganization.name, - groupName, - GROUP_MEMBER_AVATAR_LIMIT, - ), - data: { users, count: totalCount }, }); -export const Default: Story = {}; - export const NotEnabled: Story = { args: { - groups: [mockGroupWithSpend], + groups: [{ ...mockGroupWithSpend }], + canCreateGroup: true, groupsEnabled: false, }, }; export const WithGroups: Story = { args: { - groups: [mockGroupWithSpend], - }, - parameters: { - queries: [seedAvatars(MockGroup.name, [MockUserOwner, MockUserMember], 2)], - }, -}; - -// A group with more members than fit in the preview: the row shows the capped -// avatars plus a "+N" badge derived from total_member_count. -export const WithMemberAvatars: Story = { - args: { - groups: [ - { - ...mockGroupWithSpend, - id: "with-members", - name: "with-members", - display_name: "With members", - total_member_count: 8, - }, - ], - }, - parameters: { - queries: [ - seedAvatars( - "with-members", - Array.from({ length: GROUP_MEMBER_AVATAR_LIMIT }, (_, i) => ({ - ...MockUserOwner, - id: `preview-${i}`, - username: `member-${i}`, - })), - 8, - ), - ], - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(await canvas.findByText("+3")).toBeInTheDocument(); - await expect(canvas.getByText("8 members")).toBeInTheDocument(); - }, -}; - -const totalRecords = 15; -const totalPages = 3; -const limit = totalRecords / totalPages; - -// Multiple pages of results with the search field in use. -export const WithSearchAndPagination: Story = { - args: { - groups: Array.from({ length: limit }, (_, i) => - aiGroup(`group-${i}`, `Group ${i}`), - ), - filterProps: getDefaultFilterProps({ - query: "group", - values: {}, - menus: {}, - used: true, - }), - groupsQuery: { - ...mockSuccessResult, - totalRecords, - totalPages, - limit, - hasNextPage: true, - } as UsePaginatedQueryResult, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.getByLabelText("Filter")).toHaveValue("group"); - }, -}; - -// Groups still loading: the pagination + table render their loading states. -export const Loading: Story = { - args: { - groups: undefined, - groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, + groups: [{ ...mockGroupWithSpend }], + canCreateGroup: true, + groupsEnabled: true, }, }; export const WithAIBudgets: Story = { args: { + canCreateGroup: true, + groupsEnabled: true, showAIBudget: true, groups: [ { @@ -260,8 +139,9 @@ export const WithAIBudgets: Story = { export const WithAIBudgetsLoading: Story = { args: { groups: undefined, + canCreateGroup: true, + groupsEnabled: true, showAIBudget: true, - groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, }, }; @@ -305,6 +185,8 @@ export const WithAIBudgetsSpendError: Story = { export const WithAIBudgetsSpendUnavailable: Story = { args: { groups: [aiGroup("ai-unavailable", "Spend unavailable")], + canCreateGroup: true, + groupsEnabled: true, showAIBudget: true, }, play: async ({ canvasElement }) => { @@ -319,6 +201,8 @@ export const WithAIBudgetsSpendUnavailable: Story = { export const WithoutAIBudgetColumn: Story = { args: { groups: [aiGroup("ai-hidden", "No AI column")], + canCreateGroup: true, + groupsEnabled: true, showAIBudget: false, }, play: async ({ canvasElement }) => { @@ -330,9 +214,8 @@ export const WithoutAIBudgetColumn: Story = { export const WithDisplayGroup: Story = { args: { groups: [{ ...mockGroupWithSpend, name: "front-end" }], - }, - parameters: { - queries: [seedAvatars("front-end", [MockUserOwner, MockUserMember], 2)], + canCreateGroup: true, + groupsEnabled: true, }, }; @@ -340,32 +223,14 @@ export const EmptyGroup: Story = { args: { groups: [], canCreateGroup: false, + groupsEnabled: true, }, }; export const EmptyGroupWithPermission: Story = { args: { groups: [], - }, -}; - -// A search that matches nothing shows filter-aware copy, not the -// create-first-group empty state. -export const NoSearchResults: Story = { - args: { - groups: [], - filterProps: getDefaultFilterProps({ - query: "nomatch", - values: {}, - menus: {}, - used: true, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - canvas.getByText("No groups match your search"), - ).toBeInTheDocument(); - expect(canvas.queryByText("No groups yet")).not.toBeInTheDocument(); + canCreateGroup: true, + groupsEnabled: true, }, }; diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 3369776c3e7..b4b2eab5274 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,25 +1,13 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; import type { FC } from "react"; -import { useQuery } from "react-query"; import { Link as RouterLink, useNavigate } from "react-router"; -import { - GROUP_MEMBER_AVATAR_LIMIT, - groupMemberAvatars, -} from "#/api/queries/groups"; -import type { - OrganizationGroupsAISpend, - PaginatedGroup, -} from "#/api/typesGenerated"; +import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; -import type { useFilter } from "#/components/Filter/Filter"; -import { GroupsFilter } from "#/components/Filter/GroupsFilter"; -import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { @@ -36,23 +24,19 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; -import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { docs } from "#/utils/docs"; import { SpendEstimateDocsLink } from "./AICostControl"; import { StatusIconTooltip } from "./StatusIconTooltip"; const EM_DASH = "\u2014"; -// Stable keys for the avatar loading skeletons (indexes would trip lint). -const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"]; - -export type GroupWithSpend = PaginatedGroup & { +export type GroupWithSpend = Group & { readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined; }; /** Attach each group's spend, when present, so rows get a single object. */ export const joinGroupsSpend = ( - groups: readonly PaginatedGroup[] | undefined, + groups: Group[] | undefined, groupsSpend: OrganizationGroupsAISpend | undefined, ): GroupWithSpend[] | undefined => { if (groups === undefined) { @@ -74,10 +58,6 @@ type GroupsPageViewProps = { canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; - filterProps: { filter: ReturnType }; - groupsQuery: PaginationResultInfo & { - isPlaceholderData: boolean; - }; }; export const GroupsPageView: FC = ({ @@ -86,8 +66,6 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, - filterProps, - groupsQuery, }) => { if (!groupsEnabled) { return ( @@ -100,63 +78,46 @@ export const GroupsPageView: FC = ({ } return ( -
-
- - {canCreateGroup && ( - - )} -
- - - - - - Name - - Users - - {showAIBudget && ( - -
- AI budget - {spendError ? ( - - ) : ( - - Estimated AI spend compared to the group's AI budget - for the active period. - - } - /> - )} -
-
- )} - -
-
- - - -
-
-
+ + + + Name + + Users + + {showAIBudget && ( + +
+ AI budget + {spendError ? ( + + ) : ( + + Estimated AI spend compared to the group's AI budget for + the active period. + + } + /> + )} +
+
+ )} + +
+
+ + + +
); }; @@ -164,33 +125,17 @@ interface GroupsTableBodyProps { groups: GroupWithSpend[] | undefined; canCreateGroup: boolean; showAIBudget: boolean; - filterUsed: boolean; } const GroupsTableBody: FC = ({ groups, canCreateGroup, showAIBudget, - filterUsed, }) => { if (groups === undefined) { return ; } if (groups.length === 0) { - // When a search returned no matches, don't nudge the user to create a - // first group; the org may already have groups that simply don't match. - if (filterUsed) { - return ( - - - - - - ); - } return ( = ({ group, showAIBudget }) => { const rowProps = useClickableTableRow({ onClick: () => navigate(group.name), }); - - // The list endpoint returns only total_member_count, so fetch a small - // avatar preview per visible row instead of a full roster. - const membersQuery = useQuery({ - ...groupMemberAvatars( - group.organization_name, - group.name, - GROUP_MEMBER_AVATAR_LIMIT, - ), - enabled: group.total_member_count > 0, - }); - const memberAvatars = membersQuery.data?.users ?? []; - const remainingAvatars = group.total_member_count - memberAvatars.length; - const skeletonCount = Math.min( - group.total_member_count, - GROUP_MEMBER_AVATAR_LIMIT, - ); + const memberAvatars = group.members.slice(0, 5); + const remainingAvatars = group.members.length - memberAvatars.length; return ( @@ -262,24 +192,12 @@ const GroupRow: FC = ({ group, showAIBudget }) => { /> } title={group.display_name || group.name} - subtitle={`${group.total_member_count} members`} + subtitle={`${group.members.length} members`} /> - {group.total_member_count === 0 || membersQuery.isError ? ( - EM_DASH - ) : membersQuery.isLoading ? ( -
- {AVATAR_SKELETON_KEYS.slice(0, skeletonCount).map((key) => ( - - ))} -
- ) : ( + {group.members.length > 0 ? (
{memberAvatars.map((member) => ( = ({ group, showAIBudget }) => { )}
+ ) : ( + EM_DASH )}
From dbe2e2f535d049ad0bedd1d44b5292f4705bcf39 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 10 Aug 2026 19:12:17 +0000 Subject: [PATCH 15/15] fix(codersdk): decode paginated groups response with ReadBodyAsJSON OrganizationGroupsPaginated decoded its response with json.NewDecoder, which trips the gocritic ruleguard requiring codersdk.ReadBodyAsJSON so non-JSON bodies produce a structured error. Switch to ReadBodyAsJSON and drop the now-unused encoding/json import. --- codersdk/groups.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codersdk/groups.go b/codersdk/groups.go index acd5c906d94..11fac75f58c 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -2,7 +2,6 @@ package codersdk import ( "context" - "encoding/json" "fmt" "net/http" "net/url" @@ -200,7 +199,7 @@ func (c *Client) OrganizationGroupsPaginated(ctx context.Context, orgID uuid.UUI return PaginatedGroupsResponse{}, ReadBodyAsError(res) } var resp PaginatedGroupsResponse - return resp, json.NewDecoder(res.Body).Decode(&resp) + return resp, ReadBodyAsJSON(res, &resp) } type GroupRequest struct {