From 4c1e63aae8e3a06f995e5c9bb23b9d0f0ad97d3f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 2 Aug 2023 10:53:06 -0500 Subject: [PATCH] feat: add display_name field to groups (#8740) * feat: add display_name field to groups This is a non-unique human friendly group name for display purposes. This means a display name can be used instead of using an environment var to remap groups with OIDC names to Coder names. Now groups can retain the OIDC name for mapping, and use a display name for display purposes. --- coderd/apidoc/docs.go | 6 ++++ coderd/apidoc/swagger.json | 6 ++++ coderd/database/dbfake/dbfake.go | 3 ++ coderd/database/dbgen/dbgen.go | 4 ++- coderd/database/dump.sql | 5 ++- .../000144_group_display_name.down.sql | 6 ++++ .../000144_group_display_name.up.sql | 8 +++++ coderd/database/models.go | 2 ++ coderd/database/queries.sql.go | 30 +++++++++++----- coderd/database/queries/groups.sql | 12 ++++--- codersdk/groups.go | 3 ++ docs/admin/audit-logs.md | 2 +- docs/api/enterprise.md | 10 ++++++ docs/api/schemas.md | 5 +++ docs/cli/groups_create.md | 9 +++++ docs/cli/groups_edit.md | 9 +++++ docs/cli/groups_list.md | 10 +++--- enterprise/audit/table.go | 1 + enterprise/cli/groupcreate.go | 17 +++++++-- enterprise/cli/groupedit.go | 19 +++++++--- enterprise/cli/grouplist.go | 2 ++ .../coder_groups_create_--help.golden | 3 ++ .../testdata/coder_groups_edit_--help.golden | 3 ++ .../testdata/coder_groups_list_--help.golden | 6 ++-- enterprise/coderd/groups.go | 7 ++++ enterprise/coderd/groups_test.go | 36 +++++++++++++++++++ site/src/api/typesGenerated.ts | 3 ++ .../UserOrGroupAutocomplete.tsx | 8 +++-- .../pages/GroupsPage/CreateGroupPageView.tsx | 12 +++++++ site/src/pages/GroupsPage/GroupPage.tsx | 23 +++++++++--- .../GroupsPage/GroupsPageView.stories.tsx | 7 ++++ site/src/pages/GroupsPage/GroupsPageView.tsx | 4 +-- .../GroupsPage/SettingsGroupPageView.tsx | 15 ++++++-- .../TemplatePermissionsPageView.tsx | 4 +-- site/src/testHelpers/entities.ts | 1 + site/src/utils/groups.ts | 1 + .../src/xServices/groups/editGroupXService.ts | 7 +++- site/src/xServices/groups/groupXService.ts | 2 ++ 38 files changed, 266 insertions(+), 45 deletions(-) create mode 100644 coderd/database/migrations/000144_group_display_name.down.sql create mode 100644 coderd/database/migrations/000144_group_display_name.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b73456f203..4d94e9bc21 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7263,6 +7263,9 @@ const docTemplate = `{ "avatar_url": { "type": "string" }, + "display_name": { + "type": "string" + }, "name": { "type": "string" }, @@ -8247,6 +8250,9 @@ const docTemplate = `{ "avatar_url": { "type": "string" }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7c3a647075..d77432f76a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6471,6 +6471,9 @@ "avatar_url": { "type": "string" }, + "display_name": { + "type": "string" + }, "name": { "type": "string" }, @@ -7403,6 +7406,9 @@ "avatar_url": { "type": "string" }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index cbada3d221..41027f67ac 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -3402,6 +3402,7 @@ func (q *FakeQuerier) InsertAllUsersGroup(ctx context.Context, orgID uuid.UUID) return q.InsertGroup(ctx, database.InsertGroupParams{ ID: orgID, Name: database.AllUsersGroup, + DisplayName: "", OrganizationID: orgID, }) } @@ -3521,6 +3522,7 @@ func (q *FakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar group := database.Group{ ID: arg.ID, Name: arg.Name, + DisplayName: arg.DisplayName, OrganizationID: arg.OrganizationID, AvatarURL: arg.AvatarURL, QuotaAllowance: arg.QuotaAllowance, @@ -4329,6 +4331,7 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou for i, group := range q.groups { if group.ID == arg.ID { + group.DisplayName = arg.DisplayName group.Name = arg.Name group.AvatarURL = arg.AvatarURL group.QuotaAllowance = arg.QuotaAllowance diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index a74763c5d1..3e38f5c456 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -279,9 +279,11 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat } func Group(t testing.TB, db database.Store, orig database.Group) database.Group { + name := takeFirst(orig.Name, namesgenerator.GetRandomName(1)) group, err := db.InsertGroup(genCtx, database.InsertGroupParams{ ID: takeFirst(orig.ID, uuid.New()), - Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), + Name: name, + DisplayName: takeFirst(orig.DisplayName, name), OrganizationID: takeFirst(orig.OrganizationID, uuid.New()), AvatarURL: takeFirst(orig.AvatarURL, "https://logo.example.com"), QuotaAllowance: takeFirst(orig.QuotaAllowance, 0), diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f7bbb2cc1a..f121fccf8c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -298,9 +298,12 @@ CREATE TABLE groups ( name text NOT NULL, organization_id uuid NOT NULL, avatar_url text DEFAULT ''::text NOT NULL, - quota_allowance integer DEFAULT 0 NOT NULL + quota_allowance integer DEFAULT 0 NOT NULL, + display_name text DEFAULT ''::text NOT NULL ); +COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.'; + CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, diff --git a/coderd/database/migrations/000144_group_display_name.down.sql b/coderd/database/migrations/000144_group_display_name.down.sql new file mode 100644 index 0000000000..b04850449f --- /dev/null +++ b/coderd/database/migrations/000144_group_display_name.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE groups + DROP COLUMN display_name; + +COMMIT; diff --git a/coderd/database/migrations/000144_group_display_name.up.sql b/coderd/database/migrations/000144_group_display_name.up.sql new file mode 100644 index 0000000000..a812ad8aa3 --- /dev/null +++ b/coderd/database/migrations/000144_group_display_name.up.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE groups + ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.'; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index d577d6c182..4e34989b09 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1496,6 +1496,8 @@ type Group struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` AvatarURL string `db:"avatar_url" json:"avatar_url"` QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"` + // Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string. + DisplayName string `db:"display_name" json:"display_name"` } type GroupMember struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f86b413225..53ddec2231 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1180,7 +1180,7 @@ func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error { const getGroupByID = `-- name: GetGroupByID :one SELECT - id, name, organization_id, avatar_url, quota_allowance + id, name, organization_id, avatar_url, quota_allowance, display_name FROM groups WHERE @@ -1198,13 +1198,14 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ) return i, err } const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT - id, name, organization_id, avatar_url, quota_allowance + id, name, organization_id, avatar_url, quota_allowance, display_name FROM groups WHERE @@ -1229,13 +1230,14 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ) return i, err } const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - id, name, organization_id, avatar_url, quota_allowance + id, name, organization_id, avatar_url, quota_allowance, display_name FROM groups WHERE @@ -1259,6 +1261,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ); err != nil { return nil, err } @@ -1280,7 +1283,7 @@ INSERT INTO groups ( organization_id ) VALUES - ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance + ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name ` // We use the organization_id as the id @@ -1295,6 +1298,7 @@ func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uui &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ) return i, err } @@ -1303,17 +1307,19 @@ const insertGroup = `-- name: InsertGroup :one INSERT INTO groups ( id, name, + display_name, organization_id, avatar_url, quota_allowance ) VALUES - ($1, $2, $3, $4, $5) RETURNING id, name, organization_id, avatar_url, quota_allowance + ($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name ` type InsertGroupParams struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` AvatarURL string `db:"avatar_url" json:"avatar_url"` QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"` @@ -1323,6 +1329,7 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr row := q.db.QueryRowContext(ctx, insertGroup, arg.ID, arg.Name, + arg.DisplayName, arg.OrganizationID, arg.AvatarURL, arg.QuotaAllowance, @@ -1334,6 +1341,7 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ) return i, err } @@ -1343,15 +1351,17 @@ UPDATE groups SET name = $1, - avatar_url = $2, - quota_allowance = $3 + display_name = $2, + avatar_url = $3, + quota_allowance = $4 WHERE - id = $4 -RETURNING id, name, organization_id, avatar_url, quota_allowance + id = $5 +RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name ` type UpdateGroupByIDParams struct { Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` AvatarURL string `db:"avatar_url" json:"avatar_url"` QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"` ID uuid.UUID `db:"id" json:"id"` @@ -1360,6 +1370,7 @@ type UpdateGroupByIDParams struct { func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) { row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, + arg.DisplayName, arg.AvatarURL, arg.QuotaAllowance, arg.ID, @@ -1371,6 +1382,7 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar &i.OrganizationID, &i.AvatarURL, &i.QuotaAllowance, + &i.DisplayName, ) return i, err } diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index a80a4c2256..e1ee6635a5 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -34,12 +34,13 @@ AND INSERT INTO groups ( id, name, + display_name, organization_id, avatar_url, quota_allowance ) VALUES - ($1, $2, $3, $4, $5) RETURNING *; + ($1, $2, $3, $4, $5, $6) RETURNING *; -- We use the organization_id as the id -- for simplicity since all users is @@ -57,11 +58,12 @@ VALUES UPDATE groups SET - name = $1, - avatar_url = $2, - quota_allowance = $3 + name = @name, + display_name = @display_name, + avatar_url = @avatar_url, + quota_allowance = @quota_allowance WHERE - id = $4 + id = @id RETURNING *; -- name: DeleteGroupByID :exec diff --git a/codersdk/groups.go b/codersdk/groups.go index 3d9495eb07..c04267e4e0 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -12,6 +12,7 @@ import ( type CreateGroupRequest struct { Name string `json:"name"` + DisplayName string `json:"display_name"` AvatarURL string `json:"avatar_url"` QuotaAllowance int `json:"quota_allowance"` } @@ -19,6 +20,7 @@ type CreateGroupRequest struct { type Group 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"` Members []User `json:"members"` AvatarURL string `json:"avatar_url"` @@ -98,6 +100,7 @@ type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` Name string `json:"name"` + DisplayName *string `json:"display_name"` AvatarURL *string `json:"avatar_url"` QuotaAllowance *int `json:"quota_allowance"` } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 1dc32df727..882a7274c7 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,7 +13,7 @@ We track the following resources: | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 5e1aa9864a..d859ac59ad 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -174,6 +174,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -234,6 +235,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -294,6 +296,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -429,6 +432,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups [ { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -470,6 +474,7 @@ Status Code **200** | --------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | | `[array item]` | array | false | | | | `» avatar_url` | string | false | | | +| `» display_name` | string | false | | | | `» id` | string(uuid) | false | | | | `» members` | array | false | | | | `»» avatar_url` | string(uri) | false | | | @@ -521,6 +526,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups ```json { "avatar_url": "string", + "display_name": "string", "name": "string", "quota_allowance": 0 } @@ -540,6 +546,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -601,6 +608,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -1171,6 +1179,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "groups": [ { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -1234,6 +1243,7 @@ Status Code **200** | `[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 | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index e9a0910ef9..b62322a54b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -765,6 +765,7 @@ "groups": [ { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -1418,6 +1419,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "avatar_url": "string", + "display_name": "string", "name": "string", "quota_allowance": 0 } @@ -1428,6 +1430,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ----------------- | ------- | -------- | ------------ | ----------- | | `avatar_url` | string | false | | | +| `display_name` | string | false | | | | `name` | string | false | | | | `quota_allowance` | integer | false | | | @@ -2930,6 +2933,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "avatar_url": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "members": [ { @@ -2961,6 +2965,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ----------------- | --------------------------------------- | -------- | ------------ | ----------- | | `avatar_url` | string | false | | | +| `display_name` | string | false | | | | `id` | string | false | | | | `members` | array of [codersdk.User](#codersdkuser) | false | | | | `name` | string | false | | | diff --git a/docs/cli/groups_create.md b/docs/cli/groups_create.md index 5158cf20dd..dd51ed7233 100644 --- a/docs/cli/groups_create.md +++ b/docs/cli/groups_create.md @@ -20,3 +20,12 @@ coder groups create [flags] | Environment | $CODER_AVATAR_URL | Set an avatar for a group. + +### --display-name + +| | | +| ----------- | -------------------------------- | +| Type | string | +| Environment | $CODER_DISPLAY_NAME | + +Optional human friendly name for the group. diff --git a/docs/cli/groups_edit.md b/docs/cli/groups_edit.md index 8de7b567bf..da87888063 100644 --- a/docs/cli/groups_edit.md +++ b/docs/cli/groups_edit.md @@ -28,6 +28,15 @@ Add users to the group. Accepts emails or IDs. Update the group avatar. +### --display-name + +| | | +| ----------- | -------------------------------- | +| Type | string | +| Environment | $CODER_DISPLAY_NAME | + +Optional human friendly name for the group. + ### -n, --name | | | diff --git a/docs/cli/groups_list.md b/docs/cli/groups_list.md index 3766de273a..5f9e184f39 100644 --- a/docs/cli/groups_list.md +++ b/docs/cli/groups_list.md @@ -14,12 +14,12 @@ coder groups list [flags] ### -c, --column -| | | -| ------- | ---------------------------------------------------- | -| Type | string-array | -| Default | name,organization id,members,avatar url | +| | | +| ------- | ----------------------------------------------------------------- | +| Type | string-array | +| Default | name,display name,organization id,members,avatar url | -Columns to display in table output. Available columns: name, organization id, members, avatar url. +Columns to display in table output. Available columns: name, display name, organization id, members, avatar url. ### -o, --output diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 7eb93f241e..b5ab50457c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -151,6 +151,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ &database.AuditableGroup{}: { "id": ActionTrack, "name": ActionTrack, + "display_name": ActionTrack, "organization_id": ActionIgnore, // Never changes. "avatar_url": ActionTrack, "quota_allowance": ActionTrack, diff --git a/enterprise/cli/groupcreate.go b/enterprise/cli/groupcreate.go index c7c63d469a..d80c1d441e 100644 --- a/enterprise/cli/groupcreate.go +++ b/enterprise/cli/groupcreate.go @@ -12,7 +12,11 @@ import ( ) func (r *RootCmd) groupCreate() *clibase.Cmd { - var avatarURL string + var ( + avatarURL string + displayName string + ) + client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "create ", @@ -30,8 +34,9 @@ func (r *RootCmd) groupCreate() *clibase.Cmd { } group, err := client.CreateGroup(ctx, org.ID, codersdk.CreateGroupRequest{ - Name: inv.Args[0], - AvatarURL: avatarURL, + Name: inv.Args[0], + DisplayName: displayName, + AvatarURL: avatarURL, }) if err != nil { return xerrors.Errorf("create group: %w", err) @@ -50,6 +55,12 @@ func (r *RootCmd) groupCreate() *clibase.Cmd { Env: "CODER_AVATAR_URL", Value: clibase.StringOf(&avatarURL), }, + { + Flag: "display-name", + Description: `Optional human friendly name for the group.`, + Env: "CODER_DISPLAY_NAME", + Value: clibase.StringOf(&displayName), + }, } return cmd diff --git a/enterprise/cli/groupedit.go b/enterprise/cli/groupedit.go index 0b8abae1da..9ef6a31006 100644 --- a/enterprise/cli/groupedit.go +++ b/enterprise/cli/groupedit.go @@ -15,10 +15,11 @@ import ( func (r *RootCmd) groupEdit() *clibase.Cmd { var ( - avatarURL string - name string - addUsers []string - rmUsers []string + avatarURL string + name string + displayName string + addUsers []string + rmUsers []string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -52,6 +53,10 @@ func (r *RootCmd) groupEdit() *clibase.Cmd { req.AvatarURL = &avatarURL } + if inv.ParsedFlags().Lookup("display-name").Changed { + req.DisplayName = &displayName + } + userRes, err := client.Users(ctx, codersdk.UsersRequest{}) if err != nil { return xerrors.Errorf("get users: %w", err) @@ -90,6 +95,12 @@ func (r *RootCmd) groupEdit() *clibase.Cmd { Description: "Update the group avatar.", Value: clibase.StringOf(&avatarURL), }, + { + Flag: "display-name", + Description: `Optional human friendly name for the group.`, + Env: "CODER_DISPLAY_NAME", + Value: clibase.StringOf(&displayName), + }, { Flag: "add-users", FlagShorthand: "a", diff --git a/enterprise/cli/grouplist.go b/enterprise/cli/grouplist.go index f164589c49..51c36c4ba8 100644 --- a/enterprise/cli/grouplist.go +++ b/enterprise/cli/grouplist.go @@ -67,6 +67,7 @@ type groupTableRow struct { // For table output: Name string `json:"-" table:"name,default_sort"` + DisplayName string `json:"-" table:"display_name"` OrganizationID uuid.UUID `json:"-" table:"organization_id"` Members []string `json:"-" table:"members"` AvatarURL string `json:"-" table:"avatar_url"` @@ -81,6 +82,7 @@ func groupsToRows(groups ...codersdk.Group) []groupTableRow { } rows = append(rows, groupTableRow{ Name: group.Name, + DisplayName: group.DisplayName, OrganizationID: group.OrganizationID, AvatarURL: group.AvatarURL, Members: members, diff --git a/enterprise/cli/testdata/coder_groups_create_--help.golden b/enterprise/cli/testdata/coder_groups_create_--help.golden index 1cc883ab83..d661a25b87 100644 --- a/enterprise/cli/testdata/coder_groups_create_--help.golden +++ b/enterprise/cli/testdata/coder_groups_create_--help.golden @@ -6,5 +6,8 @@ Create a user group -u, --avatar-url string, $CODER_AVATAR_URL Set an avatar for a group. + --display-name string, $CODER_DISPLAY_NAME + Optional human friendly name for the group. + --- Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_groups_edit_--help.golden b/enterprise/cli/testdata/coder_groups_edit_--help.golden index a265ef5215..b5743bc4fc 100644 --- a/enterprise/cli/testdata/coder_groups_edit_--help.golden +++ b/enterprise/cli/testdata/coder_groups_edit_--help.golden @@ -9,6 +9,9 @@ Edit a user group -u, --avatar-url string Update the group avatar. + --display-name string, $CODER_DISPLAY_NAME + Optional human friendly name for the group. + -n, --name string Update the group name. diff --git a/enterprise/cli/testdata/coder_groups_list_--help.golden b/enterprise/cli/testdata/coder_groups_list_--help.golden index 99c20f5be3..fafa537205 100644 --- a/enterprise/cli/testdata/coder_groups_list_--help.golden +++ b/enterprise/cli/testdata/coder_groups_list_--help.golden @@ -3,9 +3,9 @@ Usage: coder groups list [flags] List user groups Options - -c, --column string-array (default: name,organization id,members,avatar url) - Columns to display in table output. Available columns: name, - organization id, members, avatar url. + -c, --column string-array (default: name,display name,organization id,members,avatar url) + Columns to display in table output. Available columns: name, display + name, organization id, members, avatar url. -o, --output string (default: table) Output format. Available formats: table, json. diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 79fba137f8..b6f126e1f6 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -56,6 +56,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) group, err := api.Database.InsertGroup(ctx, database.InsertGroupParams{ ID: uuid.New(), Name: req.Name, + DisplayName: req.DisplayName, OrganizationID: org.ID, AvatarURL: req.AvatarURL, QuotaAllowance: int32(req.QuotaAllowance), @@ -177,6 +178,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { ID: group.ID, AvatarURL: group.AvatarURL, Name: group.Name, + DisplayName: group.DisplayName, QuotaAllowance: group.QuotaAllowance, } @@ -190,6 +192,9 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { if req.QuotaAllowance != nil { updateGroupParams.QuotaAllowance = int32(*req.QuotaAllowance) } + if req.DisplayName != nil { + updateGroupParams.DisplayName = *req.DisplayName + } group, err = tx.UpdateGroupByID(ctx, updateGroupParams) if err != nil { @@ -395,9 +400,11 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group { for _, user := range users { orgs[user.ID] = []uuid.UUID{g.OrganizationID} } + return codersdk.Group{ ID: g.ID, Name: g.Name, + DisplayName: g.DisplayName, OrganizationID: g.OrganizationID, AvatarURL: g.AvatarURL, QuotaAllowance: int(g.QuotaAllowance), diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 322ee17c36..5999fa47b1 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -37,6 +37,7 @@ func TestCreateGroup(t *testing.T) { require.Equal(t, "hi", group.Name) require.Equal(t, "https://example.com", group.AvatarURL) require.Empty(t, group.Members) + require.Empty(t, group.DisplayName) require.NotEqual(t, uuid.Nil.String(), group.ID.String()) }) @@ -124,11 +125,45 @@ func TestPatchGroup(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, }, }}) + const displayName = "foobar" ctx := testutil.Context(t, testutil.WaitLong) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", AvatarURL: "https://example.com", QuotaAllowance: 10, + DisplayName: "", + }) + require.NoError(t, err) + require.Equal(t, 10, group.QuotaAllowance) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "bye", + AvatarURL: ptr.Ref("https://google.com"), + QuotaAllowance: ptr.Ref(20), + DisplayName: ptr.Ref(displayName), + }) + require.NoError(t, err) + require.Equal(t, displayName, group.DisplayName) + require.Equal(t, "bye", group.Name) + require.Equal(t, "https://google.com", group.AvatarURL) + require.Equal(t, 20, group.QuotaAllowance) + }) + + t.Run("DisplayNameUnchanged", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + const displayName = "foobar" + ctx := testutil.Context(t, testutil.WaitLong) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + AvatarURL: "https://example.com", + QuotaAllowance: 10, + DisplayName: displayName, }) require.NoError(t, err) require.Equal(t, 10, group.QuotaAllowance) @@ -139,6 +174,7 @@ func TestPatchGroup(t *testing.T) { QuotaAllowance: ptr.Ref(20), }) require.NoError(t, err) + require.Equal(t, displayName, group.DisplayName) require.Equal(t, "bye", group.Name) require.Equal(t, "https://google.com", group.AvatarURL) require.Equal(t, 20, group.QuotaAllowance) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4f5b5ea8b5..07da6e5de4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -174,6 +174,7 @@ export interface CreateFirstUserResponse { // From codersdk/groups.go export interface CreateGroupRequest { readonly name: string + readonly display_name: string readonly avatar_url: string readonly quota_allowance: number } @@ -506,6 +507,7 @@ export interface GitSSHKey { export interface Group { readonly id: string readonly name: string + readonly display_name: string readonly organization_id: string readonly members: User[] readonly avatar_url: string @@ -666,6 +668,7 @@ export interface PatchGroupRequest { readonly add_users: string[] readonly remove_users: string[] readonly name: string + readonly display_name?: string readonly avatar_url?: string readonly quota_allowance?: number } diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx index 5a1e2f08aa..127d970c9e 100644 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx @@ -71,7 +71,7 @@ export const UserOrGroupAutocomplete: React.FC< }} isOptionEqualToValue={(option, value) => option.id === value.id} getOptionLabel={(option) => - isGroup(option) ? option.name : option.email + isGroup(option) ? option.display_name || option.name : option.email } renderOption={(props, option) => { const isOptionGroup = isGroup(option) @@ -79,7 +79,11 @@ export const UserOrGroupAutocomplete: React.FC< return ( diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.tsx index b5b132c1f7..a2936a5924 100644 --- a/site/src/pages/GroupsPage/CreateGroupPageView.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPageView.tsx @@ -29,6 +29,7 @@ export const CreateGroupPageView: FC = ({ const form = useFormik({ initialValues: { name: "", + display_name: "", avatar_url: "", quota_allowance: 0, }, @@ -51,6 +52,17 @@ export const CreateGroupPageView: FC = ({ fullWidth label="Name" /> + { return ( <> - {pageTitle(group?.name ?? "Loading...")} + + {pageTitle((group?.display_name || group?.name) ?? "Loading...")} + @@ -127,13 +130,18 @@ export const GroupPage: React.FC = () => { } > - {group?.name} + + {group?.display_name || group?.name} + - {group?.members.length} members + {/* Show the name if it differs from the display name. */} + {group?.display_name && group?.display_name !== group?.name + ? group?.name + : ""}{" "} - + { }} /> + + diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index cc5456207e..c82baac4d5 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -25,6 +25,13 @@ WithGroups.args = { isTemplateRBACEnabled: true, } +export const WithDisplayGroup = Template.bind({}) +WithGroups.args = { + groups: [{ ...MockGroup, name: "front-end" }], + canCreateGroup: true, + isTemplateRBACEnabled: true, +} + export const EmptyGroup = Template.bind({}) EmptyGroup.args = { groups: [], diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index b14bc7fdf6..1acb5e79cf 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -137,11 +137,11 @@ export const GroupsPageView: FC = ({ } - title={group.name} + title={group.display_name || group.name} subtitle={`${group.members.length} members`} /> diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx index f5c8595894..fd5c3d4e3f 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx @@ -15,6 +15,7 @@ import { Stack } from "components/Stack/Stack" type FormData = { name: string + display_name: string avatar_url: string quota_allowance: number } @@ -34,6 +35,7 @@ const UpdateGroupForm: FC<{ const form = useFormik({ initialValues: { name: group.name, + display_name: group.display_name, avatar_url: group.avatar_url, quota_allowance: group.quota_allowance, }, @@ -55,7 +57,17 @@ const UpdateGroupForm: FC<{ fullWidth label="Name" /> - + form.setFieldValue("avatar_url", value)} /> - } - title={group.name} + title={group.display_name || group.name} subtitle={getGroupSubtitle(group)} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6fffb71e76..da79830725 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1665,6 +1665,7 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", name: "Front-End", + display_name: "Front-End", avatar_url: "https://example.com", organization_id: MockOrganization.id, members: [MockUser, MockUser2], diff --git a/site/src/utils/groups.ts b/site/src/utils/groups.ts index f96a9f3545..9afc048ce7 100644 --- a/site/src/utils/groups.ts +++ b/site/src/utils/groups.ts @@ -3,6 +3,7 @@ import { Group } from "api/typesGenerated" export const everyOneGroup = (organizationId: string): Group => ({ id: organizationId, name: "Everyone", + display_name: "", organization_id: organizationId, members: [], avatar_url: "", diff --git a/site/src/xServices/groups/editGroupXService.ts b/site/src/xServices/groups/editGroupXService.ts index 25104dbb62..ef919b7db7 100644 --- a/site/src/xServices/groups/editGroupXService.ts +++ b/site/src/xServices/groups/editGroupXService.ts @@ -23,7 +23,12 @@ export const editGroupMachine = createMachine( }, events: {} as { type: "UPDATE" - data: { name: string; avatar_url: string; quota_allowance: number } + data: { + display_name: string + name: string + avatar_url: string + quota_allowance: number + } }, }, tsTypes: {} as import("./editGroupXService.typegen").Typegen0, diff --git a/site/src/xServices/groups/groupXService.ts b/site/src/xServices/groups/groupXService.ts index 66d9b482d1..d9aa63fa23 100644 --- a/site/src/xServices/groups/groupXService.ts +++ b/site/src/xServices/groups/groupXService.ts @@ -183,6 +183,7 @@ export const groupMachine = createMachine( return patchGroup(group.id, { name: "", + display_name: "", add_users: [userId], remove_users: [], }) @@ -194,6 +195,7 @@ export const groupMachine = createMachine( return patchGroup(group.id, { name: "", + display_name: "", add_users: [], remove_users: [userId], })