diff --git a/cli/templateedit.go b/cli/templateedit.go index 867cb41d208a7..50307ec73c819 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -14,6 +14,7 @@ import ( func templateEdit() *cobra.Command { var ( name string + displayName string description string icon string defaultTTL time.Duration @@ -40,6 +41,7 @@ func templateEdit() *cobra.Command { // NOTE: coderd will ignore empty fields. req := codersdk.UpdateTemplateMeta{ Name: name, + DisplayName: displayName, Description: description, Icon: icon, DefaultTTLMillis: defaultTTL.Milliseconds(), @@ -55,6 +57,7 @@ func templateEdit() *cobra.Command { } cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name") + cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name") cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description") cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path") cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.") diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index fbfc77d26fdf5..ae39b71b4e00b 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -10,8 +10,8 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" + "github.com/coder/coder/testutil" ) func TestTemplateEdit(t *testing.T) { @@ -23,14 +23,11 @@ func TestTemplateEdit(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.Description = "original description" - ctr.Icon = "/icons/default-icon.png" - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) // Test the cli command. name := "new-template-name" + displayName := "New Display Name 789" desc := "lorem ipsum dolor sit amet et cetera" icon := "/icons/new-icon.png" defaultTTL := 12 * time.Hour @@ -39,6 +36,7 @@ func TestTemplateEdit(t *testing.T) { "edit", template.Name, "--name", name, + "--display-name", displayName, "--description", desc, "--icon", icon, "--default-ttl", defaultTTL.String(), @@ -46,7 +44,8 @@ func TestTemplateEdit(t *testing.T) { cmd, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, client, root) - err := cmd.Execute() + ctx, _ := testutil.Context(t) + err := cmd.ExecuteContext(ctx) require.NoError(t, err) @@ -54,22 +53,18 @@ func TestTemplateEdit(t *testing.T) { updated, err := client.Template(context.Background(), template.ID) require.NoError(t, err) assert.Equal(t, name, updated.Name) + assert.Equal(t, displayName, updated.DisplayName) assert.Equal(t, desc, updated.Description) assert.Equal(t, icon, updated.Icon) assert.Equal(t, defaultTTL.Milliseconds(), updated.DefaultTTLMillis) }) - t.Run("NotModified", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.Description = "original description" - ctr.Icon = "/icons/default-icon.png" - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) // Test the cli command. cmdArgs := []string{ @@ -84,7 +79,8 @@ func TestTemplateEdit(t *testing.T) { cmd, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, client, root) - err := cmd.Execute() + ctx, _ := testutil.Context(t) + err := cmd.ExecuteContext(ctx) require.ErrorContains(t, err, "not modified") @@ -96,4 +92,36 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) }) + t.Run("InvalidDisplayName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--name", template.Name, + "--display-name", " a-b-c", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx, _ := testutil.Context(t) + err := cmd.ExecuteContext(ctx) + + require.Error(t, err, "client call must fail") + _, isSdkError := codersdk.AsError(err) + require.True(t, isSdkError, "sdk error is expected") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, "", template.DisplayName) + }) } diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index bb5dcf5377ce4..9d90e182b2a76 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1303,6 +1303,7 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd } tpl.UpdatedAt = database.Now() tpl.Name = arg.Name + tpl.DisplayName = arg.DisplayName tpl.Description = arg.Description tpl.Icon = arg.Icon tpl.DefaultTtl = arg.DefaultTtl diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dfaf8e0451a4a..ed8ebcee65b91 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -353,11 +353,14 @@ CREATE TABLE templates ( created_by uuid NOT NULL, icon character varying(256) DEFAULT ''::character varying NOT NULL, user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, - group_acl jsonb DEFAULT '{}'::jsonb NOT NULL + group_acl jsonb DEFAULT '{}'::jsonb NOT NULL, + display_name character varying(64) DEFAULT ''::character varying NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.'; +COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-friendly template name that user can set.'; + CREATE TABLE user_links ( user_id uuid NOT NULL, login_type login_type NOT NULL, diff --git a/coderd/database/migrations/000075_template_display_name.down.sql b/coderd/database/migrations/000075_template_display_name.down.sql new file mode 100644 index 0000000000000..d12e2af3a5462 --- /dev/null +++ b/coderd/database/migrations/000075_template_display_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE templates DROP COLUMN display_name; diff --git a/coderd/database/migrations/000075_template_display_name.up.sql b/coderd/database/migrations/000075_template_display_name.up.sql new file mode 100644 index 0000000000000..9c44347cc6789 --- /dev/null +++ b/coderd/database/migrations/000075_template_display_name.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE templates ADD COLUMN display_name VARCHAR(64) NOT NULL DEFAULT ''; + +COMMENT ON COLUMN templates.display_name +IS 'Display name is a custom, human-friendly template name that user can set.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index f457dede07eec..58a849fd25b11 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -589,6 +589,8 @@ type Template struct { Icon string `db:"icon" json:"icon"` UserACL TemplateACL `db:"user_acl" json:"user_acl"` GroupACL TemplateACL `db:"group_acl" json:"group_acl"` + // Display name is a custom, human-friendly template name that user can set. + DisplayName string `db:"display_name" json:"display_name"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fc6cbfa9806eb..5fef1c7149cb8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3019,7 +3019,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates WHERE @@ -3046,13 +3046,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates WHERE @@ -3087,12 +3088,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates ORDER BY (name, id) ASC ` @@ -3120,6 +3122,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ); err != nil { return nil, err } @@ -3136,7 +3139,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates WHERE @@ -3199,6 +3202,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ); err != nil { return nil, err } @@ -3228,10 +3232,11 @@ INSERT INTO created_by, icon, user_acl, - group_acl + group_acl, + display_name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name ` type InsertTemplateParams struct { @@ -3248,6 +3253,7 @@ type InsertTemplateParams struct { Icon string `db:"icon" json:"icon"` UserACL TemplateACL `db:"user_acl" json:"user_acl"` GroupACL TemplateACL `db:"group_acl" json:"group_acl"` + DisplayName string `db:"display_name" json:"display_name"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -3265,6 +3271,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.Icon, arg.UserACL, arg.GroupACL, + arg.DisplayName, ) var i Template err := row.Scan( @@ -3282,6 +3289,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ) return i, err } @@ -3295,7 +3303,7 @@ SET WHERE id = $3 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name ` type UpdateTemplateACLByIDParams struct { @@ -3322,6 +3330,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ) return i, err } @@ -3376,11 +3385,12 @@ SET description = $3, default_ttl = $4, name = $5, - icon = $6 + icon = $6, + display_name = $7 WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name ` type UpdateTemplateMetaByIDParams struct { @@ -3390,6 +3400,7 @@ type UpdateTemplateMetaByIDParams struct { DefaultTtl int64 `db:"default_ttl" json:"default_ttl"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` + DisplayName string `db:"display_name" json:"display_name"` } func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) { @@ -3400,6 +3411,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.DefaultTtl, arg.Name, arg.Icon, + arg.DisplayName, ) var i Template err := row.Scan( @@ -3417,6 +3429,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.Icon, &i.UserACL, &i.GroupACL, + &i.DisplayName, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 7063b87075c6f..0757f823d6e40 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -69,10 +69,11 @@ INSERT INTO created_by, icon, user_acl, - group_acl + group_acl, + display_name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -100,7 +101,8 @@ SET description = $3, default_ttl = $4, name = $5, - icon = $6 + icon = $6, + display_name = $7 WHERE id = $1 RETURNING diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index e4c6fdd97eba7..43d6263a5e447 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -47,7 +47,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con // Default to the type. entry.ID = string(typ) } - if valid := httpapi.UsernameValid(entry.ID); valid != nil { + if valid := httpapi.NameValid(entry.ID); valid != nil { return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, valid) } diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index d2f1c07de0277..fb55cbb9ff5c7 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -33,13 +33,14 @@ func init() { } return name }) + nameValidator := func(fl validator.FieldLevel) bool { f := fl.Field().Interface() str, ok := f.(string) if !ok { return false } - valid := UsernameValid(str) + valid := NameValid(str) return valid == nil } for _, tag := range []string{"username", "template_name", "workspace_name"} { @@ -48,6 +49,20 @@ func init() { panic(err) } } + + templateDisplayNameValidator := func(fl validator.FieldLevel) bool { + f := fl.Field().Interface() + str, ok := f.(string) + if !ok { + return false + } + valid := TemplateDisplayNameValid(str) + return valid == nil + } + err := validate.RegisterValidation("template_display_name", templateDisplayNameValidator) + if err != nil { + panic(err) + } } // Convenience error functions don't take contexts since their responses are diff --git a/coderd/httpapi/username.go b/coderd/httpapi/name.go similarity index 60% rename from coderd/httpapi/username.go rename to coderd/httpapi/name.go index 89a9cce92016a..9327eb5556596 100644 --- a/coderd/httpapi/username.go +++ b/coderd/httpapi/name.go @@ -11,22 +11,9 @@ import ( var ( UsernameValidRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$") usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*") -) -// UsernameValid returns whether the input string is a valid username. -func UsernameValid(str string) error { - if len(str) > 32 { - return xerrors.New("must be <= 32 characters") - } - if len(str) < 1 { - return xerrors.New("must be >= 1 character") - } - matched := UsernameValidRegex.MatchString(str) - if !matched { - return xerrors.New("must be alphanumeric with hyphens") - } - return nil -} + templateDisplayName = regexp.MustCompile(`^[^\s](.*[^\s])?$`) +) // UsernameFrom returns a best-effort username from the provided string. // @@ -35,7 +22,7 @@ func UsernameValid(str string) error { // the username from an email address. If no success happens during // these steps, a random username will be returned. func UsernameFrom(str string) string { - if valid := UsernameValid(str); valid == nil { + if valid := NameValid(str); valid == nil { return str } emailAt := strings.LastIndex(str, "@") @@ -43,8 +30,39 @@ func UsernameFrom(str string) string { str = str[:emailAt] } str = usernameReplace.ReplaceAllString(str, "") - if valid := UsernameValid(str); valid == nil { + if valid := NameValid(str); valid == nil { return str } return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-") } + +// NameValid returns whether the input string is a valid name. +// It is a generic validator for any name (user, workspace, template, etc.). +func NameValid(str string) error { + if len(str) > 32 { + return xerrors.New("must be <= 32 characters") + } + if len(str) < 1 { + return xerrors.New("must be >= 1 character") + } + matched := UsernameValidRegex.MatchString(str) + if !matched { + return xerrors.New("must be alphanumeric with hyphens") + } + return nil +} + +// TemplateDisplayNameValid returns whether the input string is a valid template display name. +func TemplateDisplayNameValid(str string) error { + if len(str) == 0 { + return nil // empty display_name is correct + } + if len(str) > 64 { + return xerrors.New("must be <= 64 characters") + } + matched := templateDisplayName.MatchString(str) + if !matched { + return xerrors.New("must be alphanumeric with spaces") + } + return nil +} diff --git a/coderd/httpapi/username_test.go b/coderd/httpapi/name_test.go similarity index 62% rename from coderd/httpapi/username_test.go rename to coderd/httpapi/name_test.go index 547d5177c4e56..d07cce59f1a16 100644 --- a/coderd/httpapi/username_test.go +++ b/coderd/httpapi/name_test.go @@ -8,7 +8,7 @@ import ( "github.com/coder/coder/coderd/httpapi" ) -func TestValid(t *testing.T) { +func TestUsernameValid(t *testing.T) { t.Parallel() // Tests whether usernames are valid or not. testCases := []struct { @@ -59,7 +59,62 @@ func TestValid(t *testing.T) { testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() - valid := httpapi.UsernameValid(testCase.Username) + valid := httpapi.NameValid(testCase.Username) + require.Equal(t, testCase.Valid, valid == nil) + }) + } +} + +func TestTemplateDisplayNameValid(t *testing.T) { + t.Parallel() + // Tests whether display names are valid. + testCases := []struct { + Name string + Valid bool + }{ + {"", true}, + {"1", true}, + {"12", true}, + {"1 2", true}, + {"123 456", true}, + {"1234 678901234567890", true}, + {" ", true}, + {"S", true}, + {"a1", true}, + {"a1K2", true}, + {"!!!!1 ?????", true}, + {"k\r\rm", true}, + {"abcdefghijklmnopqrst", true}, + {"Wow Test", true}, + {"abcdefghijklmnopqrstu-", true}, + {"a1b2c3d4e5f6g7h8i9j0k-", true}, + {"BANANAS_wow", true}, + {"test--now", true}, + {"123456789012345678901234567890123", true}, + {"1234567890123456789012345678901234567890123456789012345678901234", true}, + {"-a1b2c3d4e5f6g7h8i9j0k", true}, + + {" ", false}, + {"\t", false}, + {"\r\r", false}, + {"\t1 ", false}, + {" a", false}, + {"\ra ", false}, + {" 1", false}, + {"1 ", false}, + {" aa", false}, + {"aa\r", false}, + {" 12", false}, + {"12 ", false}, + {"\fa1", false}, + {"a1\t", false}, + {"12345678901234567890123456789012345678901234567890123456789012345", false}, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + valid := httpapi.TemplateDisplayNameValid(testCase.Name) require.Equal(t, testCase.Valid, valid == nil) }) } @@ -92,7 +147,7 @@ func TestFrom(t *testing.T) { t.Parallel() converted := httpapi.UsernameFrom(testCase.From) t.Log(converted) - valid := httpapi.UsernameValid(converted) + valid := httpapi.NameValid(converted) require.True(t, valid == nil) if testCase.Match == "" { require.NotEqual(t, testCase.From, converted) diff --git a/coderd/templates.go b/coderd/templates.go index 8becfd31c04df..69520f585f80d 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -472,6 +472,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.Name == template.Name && req.Description == template.Description && + req.DisplayName == template.DisplayName && req.Icon == template.Icon && req.DefaultTTLMillis == time.Duration(template.DefaultTtl).Milliseconds() { return nil @@ -479,6 +480,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { // Update template metadata -- empty fields are not overwritten. name := req.Name + displayName := req.DisplayName desc := req.Description icon := req.Icon maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond @@ -486,6 +488,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if name == "" { name = template.Name } + if displayName == "" { + displayName = template.DisplayName + } if desc == "" { desc = template.Description } @@ -494,6 +499,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { ID: template.ID, UpdatedAt: database.Now(), Name: name, + DisplayName: displayName, Description: desc, Icon: icon, DefaultTtl: int64(maxTTL), @@ -738,6 +744,7 @@ func (api *API) convertTemplate( UpdatedAt: template.UpdatedAt, OrganizationID: template.OrganizationID, Name: template.Name, + DisplayName: template.DisplayName, Provisioner: codersdk.ProvisionerType(template.Provisioner), ActiveVersionID: template.ActiveVersionID, WorkspaceOwnerCount: workspaceOwnerCount, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index d095cb2d26ad6..a7f4e0ffce6da 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -283,13 +283,10 @@ func TestPatchTemplateMeta(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.Description = "original description" - ctr.Icon = "/icons/original-icon.png" - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateMeta{ Name: "new-template-name", + DisplayName: "Displayed Name 456", Description: "lorem ipsum dolor sit amet et cetera", Icon: "/icons/new-icon.png", DefaultTTLMillis: 12 * time.Hour.Milliseconds(), @@ -305,6 +302,7 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) assert.Equal(t, req.Name, updated.Name) + assert.Equal(t, req.DisplayName, updated.DisplayName) assert.Equal(t, req.Description, updated.Description) assert.Equal(t, req.Icon, updated.Icon) assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) @@ -314,6 +312,7 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) assert.Equal(t, req.Name, updated.Name) + assert.Equal(t, req.DisplayName, updated.DisplayName) assert.Equal(t, req.Description, updated.Description) assert.Equal(t, req.Icon, updated.Icon) assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) diff --git a/coderd/userauth.go b/coderd/userauth.go index b1392d0569b83..2132df20415ef 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -261,7 +261,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // The username is a required property in Coder. We make a best-effort // attempt at using what the claims provide, but if that fails we will // generate a random username. - usernameValid := httpapi.UsernameValid(username) + usernameValid := httpapi.NameValid(username) if usernameValid != nil { // If no username is provided, we can default to use the email address. // This will be converted in the from function below, so it's safe diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 1cb0ca5f975e2..50608e8e01df3 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -50,6 +50,8 @@ type CreateTemplateVersionRequest struct { type CreateTemplateRequest struct { // Name is the name of the template. Name string `json:"name" validate:"template_name,required"` + // DisplayName is the displayed name of the template. + DisplayName string `json:"display_name,omitempty" validate:"template_display_name"` // Description is a description of what the template contains. It must be // less than 128 bytes. Description string `json:"description,omitempty" validate:"lt=128"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 05e845240be8f..786984a1cedfc 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -19,6 +19,7 @@ type Template struct { UpdatedAt time.Time `json:"updated_at"` OrganizationID uuid.UUID `json:"organization_id"` Name string `json:"name"` + DisplayName string `json:"display_name"` Provisioner ProvisionerType `json:"provisioner"` ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` @@ -71,7 +72,8 @@ type UpdateTemplateACL struct { } type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,username"` + Name string `json:"name,omitempty" validate:"omitempty,template_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 72774ced4e142..18649460d0aa3 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -54,6 +54,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "organization_id": ActionIgnore, /// Never changes. "deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired. "name": ActionTrack, + "display_name": ActionTrack, "provisioner": ActionTrack, "active_version_id": ActionTrack, "description": ActionTrack, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7c4e3f2f1feea..cb4e186e4d6dc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -175,6 +175,7 @@ export interface CreateParameterRequest { // From codersdk/organizations.go export interface CreateTemplateRequest { readonly name: string + readonly display_name?: string readonly description?: string readonly icon?: string readonly template_version_id: string @@ -612,6 +613,7 @@ export interface Template { readonly updated_at: string readonly organization_id: string readonly name: string + readonly display_name: string readonly provisioner: ProvisionerType readonly active_version_id: string readonly workspace_owner_count: number @@ -696,6 +698,7 @@ export interface UpdateTemplateACL { // From codersdk/templates.go export interface UpdateTemplateMeta { readonly name?: string + readonly display_name?: string readonly description?: string readonly icon?: string readonly default_ttl_ms?: number diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index 3bbbc085ef43f..4c03bfa49d2af 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -69,6 +69,7 @@ export const TemplateSettingsForm: FC = ({ useFormik({ initialValues: { name: template.name, + display_name: template.display_name, description: template.description, // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index 2d5806c6d8d50..9690f7c5ecea9 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -24,6 +24,7 @@ const renderTemplateSettingsPage = async () => { const validFormValues = { name: "Name", + display_name: "Test Template", description: "A description", icon: "A string", default_ttl_ms: 1, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c8eed5024afa1..f70079a6b1f4c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -189,6 +189,7 @@ export const MockTemplate: TypesGen.Template = { updated_at: "2022-05-18T17:39:01.382927298Z", organization_id: MockOrganization.id, name: "test-template", + display_name: "Test Template", provisioner: MockProvisioner.provisioners[0], active_version_id: MockTemplateVersion.id, workspace_owner_count: 2,