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.
This commit is contained in:
Steven Masley 2023-08-02 10:53:06 -05:00 committed by GitHub
parent 6ea32e4e80
commit 4c1e63aae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 266 additions and 45 deletions

6
coderd/apidoc/docs.go generated
View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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),

View File

@ -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,

View File

@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE groups
DROP COLUMN display_name;
COMMIT;

View File

@ -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;

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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"`
}

View File

@ -13,7 +13,7 @@ We track the following resources:
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>inactivity_ttl</td><td>true</td></tr><tr><td>locked_ttl</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>restart_requirement_days_of_week</td><td>true</td></tr><tr><td>restart_requirement_weeks</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |

10
docs/api/enterprise.md generated
View File

@ -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 | | |

5
docs/api/schemas.md generated
View File

@ -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 | | |

View File

@ -20,3 +20,12 @@ coder groups create [flags] <name>
| Environment | <code>$CODER_AVATAR_URL</code> |
Set an avatar for a group.
### --display-name
| | |
| ----------- | -------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_DISPLAY_NAME</code> |
Optional human friendly name for the group.

View File

@ -28,6 +28,15 @@ Add users to the group. Accepts emails or IDs.
Update the group avatar.
### --display-name
| | |
| ----------- | -------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_DISPLAY_NAME</code> |
Optional human friendly name for the group.
### -n, --name
| | |

View File

@ -14,12 +14,12 @@ coder groups list [flags]
### -c, --column
| | |
| ------- | ---------------------------------------------------- |
| Type | <code>string-array</code> |
| Default | <code>name,organization id,members,avatar url</code> |
| | |
| ------- | ----------------------------------------------------------------- |
| Type | <code>string-array</code> |
| Default | <code>name,display name,organization id,members,avatar url</code> |
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

View File

@ -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,

View File

@ -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 <name>",
@ -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

View File

@ -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",

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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),

View File

@ -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)

View File

@ -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
}

View File

@ -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 (
<Box component="li" {...props}>
<AvatarData
title={isOptionGroup ? option.name : option.username}
title={
isOptionGroup
? option.display_name || option.name
: option.username
}
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
src={option.avatar_url}
/>

View File

@ -29,6 +29,7 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
const form = useFormik<CreateGroupRequest>({
initialValues: {
name: "",
display_name: "",
avatar_url: "",
quota_allowance: 0,
},
@ -51,6 +52,17 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
fullWidth
label="Name"
/>
<TextField
{...getFieldHelpers(
"display_name",
"Optional: keep empty to default to the name.",
)}
onChange={onChangeTrimmed(form)}
autoComplete="display_name"
autoFocus
fullWidth
label="Display Name"
/>
<TextField
{...getFieldHelpers("avatar_url")}
onChange={onChangeTrimmed(form)}

View File

@ -33,6 +33,7 @@ import { pageTitle } from "utils/page"
import { groupMachine } from "xServices/groups/groupXService"
import { Maybe } from "components/Conditionals/Maybe"
import { makeStyles } from "@mui/styles"
import { PaginationStatus } from "components/PaginationStatus/PaginationStatus"
const AddGroupMember: React.FC<{
isLoading: boolean
@ -101,7 +102,9 @@ export const GroupPage: React.FC = () => {
return (
<>
<Helmet>
<title>{pageTitle(group?.name ?? "Loading...")}</title>
<title>
{pageTitle((group?.display_name || group?.name) ?? "Loading...")}
</title>
</Helmet>
<ChooseOne>
<Cond condition={isLoading}>
@ -127,13 +130,18 @@ export const GroupPage: React.FC = () => {
</Maybe>
}
>
<PageHeaderTitle>{group?.name}</PageHeaderTitle>
<PageHeaderTitle>
{group?.display_name || group?.name}
</PageHeaderTitle>
<PageHeaderSubtitle>
{group?.members.length} members
{/* Show the name if it differs from the display name. */}
{group?.display_name && group?.display_name !== group?.name
? group?.name
: ""}{" "}
</PageHeaderSubtitle>
</PageHeader>
<Stack spacing={2.5}>
<Stack spacing={1}>
<Maybe condition={canUpdateGroup}>
<AddGroupMember
isLoading={state.matches("addingMember")}
@ -146,6 +154,13 @@ export const GroupPage: React.FC = () => {
}}
/>
</Maybe>
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={group?.members.length ?? 0}
total={group?.members.length ?? 0}
label="members"
/>
<TableContainer>
<Table>
<TableHead>

View File

@ -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: [],

View File

@ -137,11 +137,11 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
<AvatarData
avatar={
<GroupAvatar
name={group.name}
name={group.display_name || group.name}
avatarURL={group.avatar_url}
/>
}
title={group.name}
title={group.display_name || group.name}
subtitle={`${group.members.length} members`}
/>
</TableCell>

View File

@ -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<FormData>({
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"
/>
<TextField
{...getFieldHelpers(
"display_name",
"Optional: keep empty to default to the name.",
)}
onChange={onChangeTrimmed(form)}
autoComplete="display_name"
autoFocus
fullWidth
label="Display Name"
/>
<LazyIconField
{...getFieldHelpers("avatar_url")}
onChange={onChangeTrimmed(form)}
@ -63,7 +75,6 @@ const UpdateGroupForm: FC<{
label={t("form.fields.icon")}
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
/>
<TextField
{...getFieldHelpers(
"quota_allowance",

View File

@ -253,11 +253,11 @@ export const TemplatePermissionsPageView: FC<
<AvatarData
avatar={
<GroupAvatar
name={group.name}
name={group.display_name || group.name}
avatarURL={group.avatar_url}
/>
}
title={group.name}
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
/>
</TableCell>

View File

@ -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],

View File

@ -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: "",

View File

@ -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,

View File

@ -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],
})