diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 08447abb22c50..5ee74385f4eb8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10343,10 +10343,16 @@ const docTemplate = `{ "name": { "type": "string" }, + "organization_display_name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" }, + "organization_name": { + "type": "string" + }, "quota_allowance": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 11126e672609a..26aea27d1b4f4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9287,10 +9287,16 @@ "name": { "type": "string" }, + "organization_display_name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" }, + "organization_name": { + "type": "string" + }, "quota_allowance": { "type": "integer" }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 1d513e75aff47..541004b2bff28 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -208,17 +208,19 @@ func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []c }) } -func Group(group database.Group, members []database.GroupMember, totalMemberCount int) codersdk.Group { +func Group(row database.GetGroupsRow, members []database.GroupMember, totalMemberCount int) codersdk.Group { return codersdk.Group{ - ID: group.ID, - Name: group.Name, - DisplayName: group.DisplayName, - OrganizationID: group.OrganizationID, - AvatarURL: group.AvatarURL, - Members: ReducedUsersFromGroupMembers(members), - TotalMemberCount: totalMemberCount, - QuotaAllowance: int(group.QuotaAllowance), - Source: codersdk.GroupSource(group.Source), + ID: row.Group.ID, + Name: row.Group.Name, + DisplayName: row.Group.DisplayName, + OrganizationID: row.Group.OrganizationID, + AvatarURL: row.Group.AvatarURL, + Members: ReducedUsersFromGroupMembers(members), + TotalMemberCount: totalMemberCount, + QuotaAllowance: int(row.Group.QuotaAllowance), + Source: codersdk.GroupSource(row.Group.Source), + OrganizationName: row.OrganizationName, + OrganizationDisplayName: row.OrganizationDisplayName, } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c040df06196ec..17debdb986e34 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1503,7 +1503,7 @@ func (q *querier) GetGroupMembersCountByGroupID(ctx context.Context, groupID uui return memberCount, nil } -func (q *querier) GetGroups(ctx context.Context, arg database.GetGroupsParams) ([]database.Group, error) { +func (q *querier) GetGroups(ctx context.Context, arg database.GetGroupsParams) ([]database.GetGroupsRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err == nil { // Optimize this query for system users as it is used in telemetry. // Calling authz on all groups in a deployment for telemetry jobs is diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a270e4d2cb0d1..e7ad525b6bedb 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -607,7 +607,10 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(database.GetGroupsParams{ OrganizationID: o.ID, }).Asserts(rbac.ResourceSystem, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead). - Returns([]database.Group{a, b}). + Returns([]database.GetGroupsRow{ + {Group: a, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, + {Group: b, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, + }). // Fail the system check shortcut FailSystemObjectChecks() })) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 42fdd2b93f63e..efae3150eadef 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2609,7 +2609,7 @@ func (q *FakeQuerier) GetGroupMembersCountByGroupID(ctx context.Context, groupID return int64(len(users)), nil } -func (q *FakeQuerier) GetGroups(_ context.Context, arg database.GetGroupsParams) ([]database.Group, error) { +func (q *FakeQuerier) GetGroups(_ context.Context, arg database.GetGroupsParams) ([]database.GetGroupsRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err @@ -2634,7 +2634,8 @@ func (q *FakeQuerier) GetGroups(_ context.Context, arg database.GetGroupsParams) } } - filtered := make([]database.Group, 0) + orgDetailsCache := make(map[uuid.UUID]struct{ name, displayName string }) + filtered := make([]database.GetGroupsRow, 0) for _, group := range q.groups { if arg.OrganizationID != uuid.Nil && group.OrganizationID != arg.OrganizationID { continue @@ -2645,7 +2646,24 @@ func (q *FakeQuerier) GetGroups(_ context.Context, arg database.GetGroupsParams) continue } - filtered = append(filtered, group) + orgDetails, ok := orgDetailsCache[group.ID] + if !ok { + for _, org := range q.organizations { + if group.OrganizationID == org.ID { + orgDetails = struct{ name, displayName string }{ + name: org.Name, displayName: org.DisplayName, + } + break + } + } + orgDetailsCache[group.ID] = orgDetails + } + + filtered = append(filtered, database.GetGroupsRow{ + Group: group, + OrganizationName: orgDetails.name, + OrganizationDisplayName: orgDetails.displayName, + }) } return filtered, nil diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 2215a45a6fc4b..23298032f8bb9 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -669,7 +669,7 @@ func (m metricsStore) GetGroupMembersCountByGroupID(ctx context.Context, groupID return r0, r1 } -func (m metricsStore) GetGroups(ctx context.Context, arg database.GetGroupsParams) ([]database.Group, error) { +func (m metricsStore) GetGroups(ctx context.Context, arg database.GetGroupsParams) ([]database.GetGroupsRow, error) { start := time.Now() r0, r1 := m.s.GetGroups(ctx, arg) m.queryLatencies.WithLabelValues("GetGroups").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 66f15a5e8c99d..765338a085a20 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1330,10 +1330,10 @@ func (mr *MockStoreMockRecorder) GetGroupMembersCountByGroupID(arg0, arg1 any) * } // GetGroups mocks base method. -func (m *MockStore) GetGroups(arg0 context.Context, arg1 database.GetGroupsParams) ([]database.Group, error) { +func (m *MockStore) GetGroups(arg0 context.Context, arg1 database.GetGroupsParams) ([]database.GetGroupsRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroups", arg0, arg1) - ret0, _ := ret[0].([]database.Group) + ret0, _ := ret[0].([]database.GetGroupsRow) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index ac6d7e656d4d6..816fc4c9214b0 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -183,6 +183,10 @@ func (g Group) RBACObject() rbac.Object { }) } +func (g GetGroupsRow) RBACObject() rbac.Object { + return g.Group.RBACObject() +} + func (gm GroupMember) RBACObject() rbac.Object { return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String()) } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5e662815a7780..f4559e2cef9e3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -152,7 +152,7 @@ type sqlcQuerier interface { // count even if the caller does not have read access to ResourceGroupMember. // They only need ResourceGroup read access. GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error) - GetGroups(ctx context.Context, arg GetGroupsParams) ([]Group, error) + GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) GetHealthSettings(ctx context.Context) (string, error) GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7a25b7f82533b..5ca7b4d1c6894 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1562,32 +1562,36 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg const getGroups = `-- name: GetGroups :many SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name, source + groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name FROM - groups + 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 - AND CASE - -- Filter to only include groups a user is a member of - WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - EXISTS ( - SELECT - 1 - FROM - -- this view handles the 'everyone' group in orgs. - group_members_expanded - WHERE - group_members_expanded.group_id = groups.id - AND - group_members_expanded.user_id = $2 - ) - ELSE true - END + true + AND CASE + WHEN $1:: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + groups.organization_id = $1 + ELSE true + END + AND CASE + -- Filter to only include groups a user is a member of + WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + EXISTS ( + SELECT + 1 + FROM + -- this view handles the 'everyone' group in orgs. + group_members_expanded + WHERE + group_members_expanded.group_id = groups.id + AND + group_members_expanded.user_id = $2 + ) + ELSE true + END ` type GetGroupsParams struct { @@ -1595,23 +1599,31 @@ type GetGroupsParams struct { HasMemberID uuid.UUID `db:"has_member_id" json:"has_member_id"` } -func (q *sqlQuerier) GetGroups(ctx context.Context, arg GetGroupsParams) ([]Group, error) { +type GetGroupsRow 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"` +} + +func (q *sqlQuerier) GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) { rows, err := q.db.QueryContext(ctx, getGroups, arg.OrganizationID, arg.HasMemberID) if err != nil { return nil, err } defer rows.Close() - var items []Group + var items []GetGroupsRow for rows.Next() { - var i Group + var i GetGroupsRow if err := rows.Scan( - &i.ID, - &i.Name, - &i.OrganizationID, - &i.AvatarURL, - &i.QuotaAllowance, - &i.DisplayName, - &i.Source, + &i.Group.ID, + &i.Group.Name, + &i.Group.OrganizationID, + &i.Group.AvatarURL, + &i.Group.QuotaAllowance, + &i.Group.DisplayName, + &i.Group.Source, + &i.OrganizationName, + &i.OrganizationDisplayName, ); err != nil { return nil, err } @@ -1703,15 +1715,15 @@ INSERT INTO groups ( id, name, organization_id, - source + source ) SELECT - gen_random_uuid(), - group_name, - $1, - $2 + gen_random_uuid(), + group_name, + $1, + $2 FROM - UNNEST($3 :: text[]) AS group_name + UNNEST($3 :: text[]) AS group_name ON CONFLICT DO NOTHING RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 5c90fee38fa88..1752ccd112ea7 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -22,32 +22,36 @@ LIMIT -- name: GetGroups :many SELECT - * + sqlc.embed(groups), + organizations.name AS organization_name, + organizations.display_name AS organization_display_name FROM - groups + 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 - AND CASE - -- Filter to only include groups a user is a member of - WHEN @has_member_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - EXISTS ( - SELECT - 1 - FROM - -- this view handles the 'everyone' group in orgs. - group_members_expanded - WHERE - group_members_expanded.group_id = groups.id - AND - group_members_expanded.user_id = @has_member_id - ) - ELSE true - END + true + AND CASE + WHEN @organization_id:: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + groups.organization_id = @organization_id + ELSE true + END + AND CASE + -- Filter to only include groups a user is a member of + WHEN @has_member_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + EXISTS ( + SELECT + 1 + FROM + -- this view handles the 'everyone' group in orgs. + group_members_expanded + WHERE + group_members_expanded.group_id = groups.id + AND + group_members_expanded.user_id = @has_member_id + ) + ELSE true + END ; -- name: InsertGroup :one @@ -70,15 +74,15 @@ INSERT INTO groups ( id, name, organization_id, - source + source ) SELECT - gen_random_uuid(), - group_name, - @organization_id, - @source + gen_random_uuid(), + group_name, + @organization_id, + @source FROM - UNNEST(@group_names :: text[]) AS group_name + UNNEST(@group_names :: text[]) AS group_name -- If the name conflicts, do nothing. ON CONFLICT DO NOTHING RETURNING *; @@ -113,5 +117,3 @@ DELETE FROM groups WHERE id = $1; - - diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 8644e26ec9972..5c5b8c9cc8949 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -491,7 +491,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo } ownerGroupNames := []string{} for _, group := range ownerGroups { - ownerGroupNames = append(ownerGroupNames, group.Name) + ownerGroupNames = append(ownerGroupNames, group.Group.Name) } err = s.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspace.ID), []byte{}) if err != nil { diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index c6d35cd29b948..7078ef07334f9 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -373,7 +373,7 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } snapshot.Groups = make([]Group, 0, len(groups)) for _, group := range groups { - snapshot.Groups = append(snapshot.Groups, ConvertGroup(group)) + snapshot.Groups = append(snapshot.Groups, ConvertGroup(group.Group)) } return nil }) diff --git a/codersdk/groups.go b/codersdk/groups.go index 8484250c13646..9b0e487822063 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -34,10 +34,12 @@ type Group struct { // How many members are in this group. Shows the total count, // even if the user is not authorized to read group member details. // May be greater than `len(Group.Members)`. - TotalMemberCount int `json:"total_member_count"` - AvatarURL string `json:"avatar_url"` - QuotaAllowance int `json:"quota_allowance"` - Source GroupSource `json:"source"` + TotalMemberCount int `json:"total_member_count"` + AvatarURL string `json:"avatar_url"` + QuotaAllowance int `json:"quota_allowance"` + Source GroupSource `json:"source"` + OrganizationName string `json:"organization_name"` + OrganizationDisplayName string `json:"organization_display_name"` } func (g Group) IsEveryone() bool { diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 18ae4e7de61d5..684329814edc1 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -219,7 +219,9 @@ curl -X GET http://coder-server:8080/api/v2/groups?organization=string&has_membe } ], "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 @@ -237,29 +239,31 @@ curl -X GET http://coder-server:8080/api/v2/groups?organization=string&has_membe Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------- | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» display_name` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string(uri) | false | | | -| `»» created_at` | string(date-time) | true | | | -| `»» email` | string(email) | true | | | -| `»» id` | string(uuid) | true | | | -| `»» last_seen_at` | string(date-time) | false | | | -| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»» name` | string | false | | | -| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» theme_preference` | string | false | | | -| `»» updated_at` | string(date-time) | false | | | -| `»» username` | string | true | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» quota_allowance` | integer | false | | | -| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | -| `» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | +| Name | Type | Required | Restrictions | Description | +| ----------------------------- | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» display_name` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string(uri) | false | | | +| `»» created_at` | string(date-time) | true | | | +| `»» email` | string(email) | true | | | +| `»» id` | string(uuid) | true | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» name` | string | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» theme_preference` | string | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» username` | string | true | | | +| `» name` | string | false | | | +| `» organization_display_name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_name` | string | false | | | +| `» quota_allowance` | integer | false | | | +| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | +| `» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | #### Enumerated Values @@ -322,7 +326,9 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ } ], "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 @@ -381,7 +387,9 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ } ], "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 @@ -455,7 +463,9 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ } ], "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 @@ -1214,7 +1224,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups } ], "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 @@ -1232,29 +1244,31 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------- | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» display_name` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string(uri) | false | | | -| `»» created_at` | string(date-time) | true | | | -| `»» email` | string(email) | true | | | -| `»» id` | string(uuid) | true | | | -| `»» last_seen_at` | string(date-time) | false | | | -| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»» name` | string | false | | | -| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» theme_preference` | string | false | | | -| `»» updated_at` | string(date-time) | false | | | -| `»» username` | string | true | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» quota_allowance` | integer | false | | | -| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | -| `» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | +| Name | Type | Required | Restrictions | Description | +| ----------------------------- | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» display_name` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string(uri) | false | | | +| `»» created_at` | string(date-time) | true | | | +| `»» email` | string(email) | true | | | +| `»» id` | string(uuid) | true | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» name` | string | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» theme_preference` | string | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» username` | string | true | | | +| `» name` | string | false | | | +| `» organization_display_name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_name` | string | false | | | +| `» quota_allowance` | integer | false | | | +| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | +| `» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | #### Enumerated Values @@ -1330,7 +1344,9 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups } ], "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 @@ -1390,7 +1406,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ } ], "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 @@ -2136,7 +2154,9 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ } ], "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 @@ -2171,31 +2191,33 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ----------------------- | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» groups` | array | false | | | -| `»» avatar_url` | string | false | | | -| `»» display_name` | string | false | | | -| `»» id` | string(uuid) | false | | | -| `»» members` | array | false | | | -| `»»» avatar_url` | string(uri) | false | | | -| `»»» created_at` | string(date-time) | true | | | -| `»»» email` | string(email) | true | | | -| `»»» id` | string(uuid) | true | | | -| `»»» last_seen_at` | string(date-time) | false | | | -| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»»» name` | string | false | | | -| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»»» theme_preference` | string | false | | | -| `»»» updated_at` | string(date-time) | false | | | -| `»»» username` | string | true | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» quota_allowance` | integer | false | | | -| `»» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | -| `»» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | -| `» users` | array | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------ | ------------------------------------------------------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» groups` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» display_name` | string | false | | | +| `»» id` | string(uuid) | false | | | +| `»» members` | array | false | | | +| `»»» avatar_url` | string(uri) | false | | | +| `»»» created_at` | string(date-time) | true | | | +| `»»» email` | string(email) | true | | | +| `»»» id` | string(uuid) | true | | | +| `»»» last_seen_at` | string(date-time) | false | | | +| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»»» name` | string | false | | | +| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»»» theme_preference` | string | false | | | +| `»»» updated_at` | string(date-time) | false | | | +| `»»» username` | string | true | | | +| `»» name` | string | false | | | +| `»» organization_display_name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» organization_name` | string | false | | | +| `»» quota_allowance` | integer | false | | | +| `»» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | +| `»» total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | +| `» users` | array | false | | | #### Enumerated Values diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ee229a8cc4914..e195a00167951 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -244,7 +244,9 @@ } ], "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 @@ -2847,7 +2849,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o } ], "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 @@ -2856,17 +2860,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------- | ----------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `avatar_url` | string | false | | | -| `display_name` | string | false | | | -| `id` | string | false | | | -| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `quota_allowance` | integer | false | | | -| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | -| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | +| Name | Type | Required | Restrictions | Description | +| --------------------------- | ----------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | 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 | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | ## codersdk.GroupSource diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index a6f8c56624eae..4f58fb429b147 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -78,7 +78,11 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) var emptyMembers []database.GroupMember aReq.New = group.Auditable(emptyMembers) - httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Group(group, nil, 0)) + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Group(database.GetGroupsRow{ + Group: group, + OrganizationName: org.Name, + OrganizationDisplayName: org.DisplayName, + }, nil, 0)) } // @Summary Update group by name @@ -275,6 +279,11 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } + org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID) + if err != nil { + httpapi.InternalServerError(rw, err) + } + patchedMembers, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) @@ -289,7 +298,11 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(group, patchedMembers, int(memberCount))) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(database.GetGroupsRow{ + Group: group, + OrganizationName: org.Name, + OrganizationDisplayName: org.DisplayName, + }, patchedMembers, int(memberCount))) } // @Summary Delete group by name @@ -368,6 +381,11 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) + org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID) + if err != nil { + httpapi.InternalServerError(rw, err) + } + users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) @@ -380,7 +398,11 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(group, users, int(memberCount))) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(database.GetGroupsRow{ + Group: group, + OrganizationName: org.Name, + OrganizationDisplayName: org.DisplayName, + }, users, int(memberCount))) } // @Summary Get groups by organization @@ -456,12 +478,12 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { resp := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { - members, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) + members, err := api.Database.GetGroupMembersByGroupID(ctx, group.Group.ID) if err != nil { httpapi.InternalServerError(rw, err) return } - memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, group.ID) + memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, group.Group.ID) if err != nil { httpapi.InternalServerError(rw, err) return diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index e9dc5ea638fff..d795e57c61a71 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -61,12 +61,12 @@ func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Req sdkGroups := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { // nolint:gocritic - members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), group.ID) + members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), group.Group.ID) if err != nil { httpapi.InternalServerError(rw, err) return } - memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, group.ID) + memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, group.Group.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -147,8 +147,12 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { return } groups = append(groups, codersdk.TemplateGroup{ - Group: db2sdk.Group(group.Group, members, int(memberCount)), - Role: convertToTemplateRole(group.Actions), + Group: db2sdk.Group(database.GetGroupsRow{ + Group: group.Group, + OrganizationName: template.OrganizationName, + OrganizationDisplayName: template.OrganizationDisplayName, + }, members, int(memberCount)), + Role: convertToTemplateRole(group.Actions), }) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4c4abdc5b75c9..e22f121837f43 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1603,14 +1603,20 @@ class ApiMethods { return response.data; }; + getGroups = async (): Promise => { + const response = await this.axios.get("/api/v2/groups"); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ - getGroups = async (organization: string): Promise => { + getGroupsByOrganization = async ( + organization: string, + ): Promise => { const response = await this.axios.get( `/api/v2/organizations/${organization}/groups`, ); - return response.data; }; diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 7202b8ffd19dd..96e04a9fb3782 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -8,16 +8,25 @@ import type { QueryClient, UseQueryOptions } from "react-query"; type GroupSortOrder = "asc" | "desc"; -const getGroupsQueryKey = (organization: string) => [ +const groupsQueryKey = ["groups"]; + +export const groups = () => { + return { + queryKey: groupsQueryKey, + queryFn: () => API.getGroups(), + } satisfies UseQueryOptions; +}; + +const getGroupsByOrganizationQueryKey = (organization: string) => [ "organization", organization, "groups", ]; -export const groups = (organization: string) => { +export const groupsByOrganization = (organization: string) => { return { - queryKey: getGroupsQueryKey(organization), - queryFn: () => API.getGroups(organization), + queryKey: getGroupsByOrganizationQueryKey(organization), + queryFn: () => API.getGroupsByOrganization(organization), } satisfies UseQueryOptions; }; @@ -37,9 +46,9 @@ export const group = (organization: string, groupName: string) => { export type GroupsByUserId = Readonly>; -export function groupsByUserId(organization: string) { +export function groupsByUserId() { return { - ...groups(organization), + ...groups(), select: (allGroups) => { // Sorting here means that nothing has to be sorted for the individual // user arrays later @@ -63,14 +72,13 @@ export function groupsByUserId(organization: string) { } satisfies UseQueryOptions; } -export function groupsForUser(organization: string, userId: string) { +export function groupsForUser(userId: string) { return { - ...groups(organization), + ...groups(), select: (allGroups) => { - const groupsForUser = allGroups.filter((group) => { - const groupMemberIds = group.members.map((member) => member.id); - return groupMemberIds.includes(userId); - }); + const groupsForUser = allGroups.filter((group) => + group.members.some((member) => member.id === userId), + ); return sortGroupsByName(groupsForUser, "asc"); }, @@ -106,7 +114,10 @@ export const createGroup = (queryClient: QueryClient, organization: string) => { mutationFn: (request: CreateGroupRequest) => API.createGroup(organization, request), onSuccess: async () => { - await queryClient.invalidateQueries(getGroupsQueryKey(organization)); + await queryClient.invalidateQueries(groupsQueryKey); + await queryClient.invalidateQueries( + getGroupsByOrganizationQueryKey(organization), + ); }, }; }; @@ -155,12 +166,15 @@ export const invalidateGroup = ( groupId: string, ) => Promise.all([ - queryClient.invalidateQueries(getGroupsQueryKey(organization)), + queryClient.invalidateQueries(groupsQueryKey), + queryClient.invalidateQueries( + getGroupsByOrganizationQueryKey(organization), + ), queryClient.invalidateQueries(getGroupQueryKey(organization, groupId)), ]); -export function sortGroupsByName( - groups: readonly Group[], +export function sortGroupsByName( + groups: readonly T[], order: GroupSortOrder, ) { return [...groups].sort((g1, g2) => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 262e58d77c76c..4b2988adf6257 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -623,6 +623,8 @@ export interface Group { readonly avatar_url: string; readonly quota_allowance: number; readonly source: GroupSource; + readonly organization_name: string; + readonly organization_display_name: string; } // From codersdk/groups.go diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index ca637b0eb185f..6313b8e450c9e 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,5 +1,5 @@ import { getErrorMessage } from "api/errors"; -import { groups } from "api/queries/groups"; +import { groupsByOrganization } from "api/queries/groups"; import { displayError } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; @@ -12,7 +12,7 @@ import GroupsPageView from "./GroupsPageView"; export const GroupsPage: FC = () => { const { permissions } = useAuthenticated(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); - const groupsQuery = useQuery(groups("default")); + const groupsQuery = useQuery(groupsByOrganization("default")); useEffect(() => { if (groupsQuery.error) { diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx index 3c3b3d0b44cdb..4777f289e73eb 100644 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx @@ -1,7 +1,7 @@ import GroupAdd from "@mui/icons-material/GroupAddOutlined"; import Button from "@mui/material/Button"; import { getErrorMessage } from "api/errors"; -import { groups } from "api/queries/groups"; +import { groupsByOrganization } from "api/queries/groups"; import { organizationPermissions } from "api/queries/organizations"; import type { Organization } from "api/typesGenerated"; import { EmptyState } from "components/EmptyState/EmptyState"; @@ -21,11 +21,9 @@ import GroupsPageView from "./GroupsPageView"; export const GroupsPage: FC = () => { const feats = useFeatureVisibility(); const { organization: organizationName } = useParams() as { - organization?: string; + organization: string; }; - const groupsQuery = useQuery( - organizationName ? groups(organizationName) : { enabled: false }, - ); + const groupsQuery = useQuery(groupsByOrganization(organizationName)); const { organizations } = useOrganizationSettings(); const organization = organizations?.find((o) => o.name === organizationName); const permissionsQuery = useQuery(organizationPermissions(organization?.id)); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 2a30b8b2a2ee8..34b0ef29b12e3 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -17,8 +17,7 @@ export const AccountPage: FC = () => { const hasGroupsFeature = entitlements.features.user_role_management.enabled; const groupsQuery = useQuery({ - // TODO: This should probably list all groups, not just default org groups - ...groupsForUser("default", me.id), + ...groupsForUser(me.id), enabled: hasGroupsFeature, }); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx index 3a198836ab7a4..a11c4b2a108bb 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { Group } from "api/typesGenerated"; import { MockGroup as MockGroup1, MockUser, @@ -7,7 +6,7 @@ import { } from "testHelpers/entities"; import { AccountUserGroups } from "./AccountUserGroups"; -const MockGroup2: Group = { +const MockGroup2 = { ...MockGroup1, avatar_url: "", display_name: "Goofy Goobers", diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx index dccb85b001f42..0096df0756263 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx @@ -55,12 +55,7 @@ export const AccountUserGroups: FC = ({ imgUrl={group.avatar_url} altText={group.display_name || group.name} header={group.display_name || group.name} - subtitle={ - <> - {group.total_member_count} member - {group.total_member_count !== 1 && "s"} - - } + subtitle={group.organization_display_name} /> ))} diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index bc73e919fd948..363bc72627794 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -43,7 +43,7 @@ const UsersPage: FC = () => { const { entitlements, experiments } = useDashboard(); const [searchParams] = searchParamsResult; - const groupsByUserIdQuery = useQuery(groupsByUserId("default")); + const groupsByUserIdQuery = useQuery(groupsByUserId()); const authMethodsQuery = useQuery(authMethods()); const { permissions, user: me } = useAuthenticated(); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 05f50019272dd..55e50a30fe0c4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2497,27 +2497,33 @@ export const MockGroup: TypesGen.Group = { display_name: "Front-End", avatar_url: "https://example.com", organization_id: MockOrganization.id, + organization_name: MockOrganization.name, + organization_display_name: MockOrganization.display_name, members: [MockUser, MockUser2], quota_allowance: 5, source: "user", total_member_count: 2, }; -const everyOneGroup = (organizationId: string): TypesGen.Group => ({ - id: organizationId, +const MockEveryoneGroup: TypesGen.Group = { + // The "Everyone" group must have the same ID as a the organization it belongs + // to. + id: MockOrganization.id, name: "Everyone", display_name: "", - organization_id: organizationId, + organization_id: MockOrganization.id, + organization_name: MockOrganization.name, + organization_display_name: MockOrganization.display_name, members: [], avatar_url: "", quota_allowance: 0, source: "user", total_member_count: 0, -}); +}; export const MockTemplateACL: TypesGen.TemplateACL = { group: [ - { ...everyOneGroup(MockOrganization.id), role: "use" }, + { ...MockEveryoneGroup, role: "use" }, { ...MockGroup, role: "admin" }, ], users: [{ ...MockUser, role: "use" }],