From 0ec27e92db1920ea7d0a39e8c7750c00f939b383 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Jun 2024 14:45:58 +0100 Subject: [PATCH 1/2] feat: add "Full Name" field to user creation Adds the ability to specify "Full Name" (a.k.a. Name) when creating users either via CLI or UI. --- Makefile | 4 + cli/login.go | 28 ++++ cli/login_test.go | 149 ++++++++++++++++-- cli/server_createadminuser.go | 3 + cli/testdata/coder_login_--help.golden | 3 + cli/testdata/coder_users_create_--help.golden | 3 + .../coder_users_list_--output_json.golden | 2 +- cli/usercreate.go | 24 +++ cli/usercreate_test.go | 89 ++++++++++- cli/userlist.go | 1 + cli/userlist_test.go | 10 +- coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + coderd/coderdtest/coderdtest.go | 25 +++ coderd/database/dbauthz/dbauthz_test.go | 1 + coderd/database/dbgen/dbgen.go | 1 + coderd/database/dbmem/dbmem.go | 2 + coderd/database/modelmethods.go | 1 + coderd/database/queries.sql.go | 5 +- coderd/database/queries/users.sql | 3 +- coderd/users.go | 2 + coderd/users_test.go | 9 +- codersdk/users.go | 2 + docs/api/schemas.md | 4 + docs/api/users.md | 2 + docs/cli/login.md | 9 ++ docs/cli/users_create.md | 8 + scripts/develop.sh | 4 +- site/e2e/api.ts | 1 + .../users/createUserWithPassword.spec.ts | 32 ++++ site/src/api/typesGenerated.ts | 2 + .../pages/CreateUserPage/CreateUserForm.tsx | 16 ++ site/src/pages/SetupPage/SetupPageView.tsx | 15 +- 33 files changed, 447 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index ca54d51842c0b..75c5a945e113b 100644 --- a/Makefile +++ b/Makefile @@ -865,3 +865,7 @@ test-tailnet-integration: test-clean: go clean -testcache .PHONY: test-clean + +.PHONY: test-e2e +test-e2e: + cd ./site && DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1 diff --git a/cli/login.go b/cli/login.go index faec491270344..7dde98b118c5d 100644 --- a/cli/login.go +++ b/cli/login.go @@ -58,6 +58,21 @@ func promptFirstUsername(inv *serpent.Invocation) (string, error) { return username, nil } +func promptFirstName(inv *serpent.Invocation) (string, error) { + name, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "(Optional) What " + pretty.Sprint(cliui.DefaultStyles.Field, "name") + " would you like?", + Default: "", + }) + if err != nil { + if errors.Is(err, cliui.Canceled) { + return "", nil + } + return "", err + } + + return name, nil +} + func promptFirstPassword(inv *serpent.Invocation) (string, error) { retry: password, err := cliui.Prompt(inv, cliui.PromptOptions{ @@ -130,6 +145,7 @@ func (r *RootCmd) login() *serpent.Command { var ( email string username string + name string password string trial bool useTokenForSession bool @@ -191,6 +207,7 @@ func (r *RootCmd) login() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL) + // nolint: nestif if !hasFirstUser { _, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n") @@ -212,6 +229,10 @@ func (r *RootCmd) login() *serpent.Command { if err != nil { return err } + name, err = promptFirstName(inv) + if err != nil { + return err + } } if email == "" { @@ -249,6 +270,7 @@ func (r *RootCmd) login() *serpent.Command { _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ Email: email, Username: username, + Name: name, Password: password, Trial: trial, }) @@ -360,6 +382,12 @@ func (r *RootCmd) login() *serpent.Command { Description: "Specifies a username to use if creating the first user for the deployment.", Value: serpent.StringOf(&username), }, + { + Flag: "first-user-full-name", + Env: "CODER_FIRST_USER_FULL_NAME", + Description: "Specifies a human-readable name for the first user of the deployment.", + Value: serpent.StringOf(&name), + }, { Flag: "first-user-password", Env: "CODER_FIRST_USER_PASSWORD", diff --git a/cli/login_test.go b/cli/login_test.go index dc551ccaf2422..b2f93ad5e6813 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestLogin(t *testing.T) { @@ -91,10 +92,11 @@ func TestLogin(t *testing.T) { matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "SomeSecurePassword!", - "password", "SomeSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -105,6 +107,64 @@ func TestLogin(t *testing.T) { } pty.ExpectMatch("Welcome to Coder") <-doneChan + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + }) + + t.Run("InitialUserTTYNameOptional", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + // The --force-tty flag is required on Windows, because the `isatty` library does not + // accurately detect Windows ptys when they are not attached to a process: + // https://github.com/mattn/go-isatty/issues/59 + doneChan := make(chan struct{}) + root, _ := clitest.New(t, "login", "--force-tty", client.URL.String()) + pty := ptytest.New(t).Attach(root) + go func() { + defer close(doneChan) + err := root.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "first user?", "yes", + "username", coderdtest.FirstUserParams.Username, + "name", "", + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm + "trial", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + pty.ExpectMatch("Welcome to Coder") + <-doneChan + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + assert.Empty(t, me.Name) }) t.Run("InitialUserTTYFlag", func(t *testing.T) { @@ -121,10 +181,11 @@ func TestLogin(t *testing.T) { pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String())) matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "SomeSecurePassword!", - "password", "SomeSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -134,6 +195,18 @@ func TestLogin(t *testing.T) { pty.WriteLine(value) } pty.ExpectMatch("Welcome to Coder") + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) }) t.Run("InitialUserFlags", func(t *testing.T) { @@ -141,13 +214,56 @@ func TestLogin(t *testing.T) { client := coderdtest.New(t, nil) inv, _ := clitest.New( t, "login", client.URL.String(), - "--first-user-username", "testuser", "--first-user-email", "user@coder.com", - "--first-user-password", "SomeSecurePassword!", "--first-user-trial", + "--first-user-username", coderdtest.FirstUserParams.Username, + "--first-user-full-name", coderdtest.FirstUserParams.Name, + "--first-user-email", coderdtest.FirstUserParams.Email, + "--first-user-password", coderdtest.FirstUserParams.Password, + "--first-user-trial", + ) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatch("Welcome to Coder") + w.RequireSuccess() + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + }) + + t.Run("InitialUserFlagsNameOptional", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + inv, _ := clitest.New( + t, "login", client.URL.String(), + "--first-user-username", coderdtest.FirstUserParams.Username, + "--first-user-email", coderdtest.FirstUserParams.Email, + "--first-user-password", coderdtest.FirstUserParams.Password, + "--first-user-trial", ) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) pty.ExpectMatch("Welcome to Coder") w.RequireSuccess() + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + assert.Empty(t, me.Name) }) t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) { @@ -169,10 +285,11 @@ func TestLogin(t *testing.T) { matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "MyFirstSecurePassword!", - "password", "MyNonMatchingSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", "something completely different", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -185,9 +302,9 @@ func TestLogin(t *testing.T) { pty.ExpectMatch("Passwords do not match") pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password")) - pty.WriteLine("SomeSecurePassword!") + pty.WriteLine(coderdtest.FirstUserParams.Password) pty.ExpectMatch("Confirm") - pty.WriteLine("SomeSecurePassword!") + pty.WriteLine(coderdtest.FirstUserParams.Password) pty.ExpectMatch("trial") pty.WriteLine("yes") pty.ExpectMatch("Welcome to Coder") diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index e43a9c401b8a0..19326ba728ce6 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -85,6 +85,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { // Use the validator tags so we match the API's validation. req := codersdk.CreateUserRequest{ Username: "username", + Name: "Admin User", Email: "email@coder.com", Password: "ValidPa$$word123!", OrganizationID: uuid.New(), @@ -116,6 +117,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { return err } } + if newUserEmail == "" { newUserEmail, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Email", @@ -189,6 +191,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { ID: uuid.New(), Email: newUserEmail, Username: newUserUsername, + Name: "Admin User", HashedPassword: []byte(hashedPassword), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), diff --git a/cli/testdata/coder_login_--help.golden b/cli/testdata/coder_login_--help.golden index f6fe15dc07273..e4109a494ed39 100644 --- a/cli/testdata/coder_login_--help.golden +++ b/cli/testdata/coder_login_--help.golden @@ -10,6 +10,9 @@ OPTIONS: Specifies an email address to use if creating the first user for the deployment. + --first-user-full-name string, $CODER_FIRST_USER_FULL_NAME + Specifies a human-readable name for the first user of the deployment. + --first-user-password string, $CODER_FIRST_USER_PASSWORD Specifies a password to use if creating the first user for the deployment. diff --git a/cli/testdata/coder_users_create_--help.golden b/cli/testdata/coder_users_create_--help.golden index 5216e00f3467b..d55d522181c95 100644 --- a/cli/testdata/coder_users_create_--help.golden +++ b/cli/testdata/coder_users_create_--help.golden @@ -7,6 +7,9 @@ OPTIONS: -e, --email string Specifies an email address for the new user. + -n, --full-name string + Specifies an optional human-readable name for the new user. + --login-type string Optionally specify the login type for the user. Valid values are: password, none, github, oidc. Using 'none' prevents the user from diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index b62ce009922f6..3c7ff44b6675a 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -3,7 +3,7 @@ "id": "[first user ID]", "username": "testuser", "avatar_url": "", - "name": "", + "name": "Test User", "email": "testuser@coder.com", "created_at": "[timestamp]", "last_seen_at": "[timestamp]", diff --git a/cli/usercreate.go b/cli/usercreate.go index 28cc3c0fe7049..3c4a43b33bc2d 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -10,6 +10,7 @@ import ( "github.com/coder/pretty" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/serpent" @@ -19,6 +20,7 @@ func (r *RootCmd) userCreate() *serpent.Command { var ( email string username string + name string password string disableLogin bool loginType string @@ -35,6 +37,9 @@ func (r *RootCmd) userCreate() *serpent.Command { if err != nil { return err } + // We only prompt for the full name if both username and email have not + // been set. This is to avoid breaking existing non-interactive usage. + shouldPromptName := username == "" && email == "" if username == "" { username, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Username:", @@ -58,6 +63,18 @@ func (r *RootCmd) userCreate() *serpent.Command { return err } } + if name == "" && shouldPromptName { + rawName, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Full name (optional):", + }) + if err != nil { + return err + } + name = httpapi.NormalizeRealUsername(rawName) + if !strings.EqualFold(rawName, name) { + cliui.Warnf(inv.Stderr, "Normalized name to %q", name) + } + } userLoginType := codersdk.LoginTypePassword if disableLogin && loginType != "" { return xerrors.New("You cannot specify both --disable-login and --login-type") @@ -79,6 +96,7 @@ func (r *RootCmd) userCreate() *serpent.Command { _, err = client.CreateUser(inv.Context(), codersdk.CreateUserRequest{ Email: email, Username: username, + Name: name, Password: password, OrganizationID: organization.ID, UserLoginType: userLoginType, @@ -127,6 +145,12 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`! Description: "Specifies a username for the new user.", Value: serpent.StringOf(&username), }, + { + Flag: "full-name", + FlagShorthand: "n", + Description: "Specifies an optional human-readable name for the new user.", + Value: serpent.StringOf(&name), + }, { Flag: "password", FlagShorthand: "p", diff --git a/cli/usercreate_test.go b/cli/usercreate_test.go index 5726cc84d25b5..66f7975d0bcdf 100644 --- a/cli/usercreate_test.go +++ b/cli/usercreate_test.go @@ -4,16 +4,19 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestUserCreate(t *testing.T) { t.Parallel() t.Run("Prompts", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) inv, root := clitest.New(t, "users", "create") @@ -28,6 +31,7 @@ func TestUserCreate(t *testing.T) { matches := []string{ "Username", "dean", "Email", "dean@coder.com", + "Full name (optional):", "Mr. Dean Deanington", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -35,6 +39,89 @@ func TestUserCreate(t *testing.T) { pty.ExpectMatch(match) pty.WriteLine(value) } - <-doneChan + _ = testutil.RequireRecvCtx(ctx, t, doneChan) + created, err := client.User(ctx, matches[1]) + require.NoError(t, err) + assert.Equal(t, matches[1], created.Username) + assert.Equal(t, matches[3], created.Email) + assert.Equal(t, matches[5], created.Name) + }) + + t.Run("PromptsNoName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "users", "create") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + matches := []string{ + "Username", "noname", + "Email", "noname@coder.com", + "Full name (optional):", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + _ = testutil.RequireRecvCtx(ctx, t, doneChan) + created, err := client.User(ctx, matches[1]) + require.NoError(t, err) + assert.Equal(t, matches[1], created.Username) + assert.Equal(t, matches[3], created.Email) + assert.Empty(t, created.Name) + }) + + t.Run("Args", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + args := []string{ + "users", "create", + "-e", "dean@coder.com", + "-u", "dean", + "-n", "Mr. Dean Deanington", + "-p", "1n5ecureP4ssw0rd!", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + created, err := client.User(ctx, "dean") + require.NoError(t, err) + assert.Equal(t, args[3], created.Email) + assert.Equal(t, args[5], created.Username) + assert.Equal(t, args[7], created.Name) + }) + + t.Run("ArgsNoName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + args := []string{ + "users", "create", + "-e", "dean@coder.com", + "-u", "dean", + "-p", "1n5ecureP4ssw0rd!", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + created, err := client.User(ctx, args[5]) + require.NoError(t, err) + assert.Equal(t, args[3], created.Email) + assert.Equal(t, args[5], created.Username) + assert.Empty(t, created.Name) }) } diff --git a/cli/userlist.go b/cli/userlist.go index 955154ce30f62..616126699cc03 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -137,6 +137,7 @@ func (*userShowFormat) Format(_ context.Context, out interface{}) (string, error // Add rows for each of the user's fields. addRow("ID", user.ID.String()) addRow("Username", user.Username) + addRow("Full name", user.Name) addRow("Email", user.Email) addRow("Status", user.Status) addRow("Created At", user.CreatedAt.Format(time.Stamp)) diff --git a/cli/userlist_test.go b/cli/userlist_test.go index feca8746df32c..1a4409bb898ac 100644 --- a/cli/userlist_test.go +++ b/cli/userlist_test.go @@ -57,7 +57,14 @@ func TestUserList(t *testing.T) { err := json.Unmarshal(buf.Bytes(), &users) require.NoError(t, err, "unmarshal JSON output") require.Len(t, users, 2) - require.Contains(t, users[0].Email, "coder.com") + for _, u := range users { + assert.NotEmpty(t, u.ID) + assert.NotEmpty(t, u.Email) + assert.NotEmpty(t, u.Username) + assert.NotEmpty(t, u.Name) + assert.NotEmpty(t, u.CreatedAt) + assert.NotEmpty(t, u.Status) + } }) t.Run("NoURLFileErrorHasHelperText", func(t *testing.T) { t.Parallel() @@ -133,5 +140,6 @@ func TestUserShow(t *testing.T) { require.Equal(t, otherUser.ID, newUser.ID) require.Equal(t, otherUser.Username, newUser.Username) require.Equal(t, otherUser.Email, newUser.Email) + require.Equal(t, otherUser.Name, newUser.Name) }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1e2f8fa7b1d22..7ff97bba2968d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8425,6 +8425,9 @@ const docTemplate = `{ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, @@ -8787,6 +8790,9 @@ const docTemplate = `{ } ] }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c58e0a9de10f5..bc6fcc19142a9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7493,6 +7493,9 @@ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, @@ -7824,6 +7827,9 @@ } ] }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d4e473a17a610..34d874d08a0dd 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -29,6 +29,7 @@ import ( "sync/atomic" "testing" "time" + "unicode" "cloud.google.com/go/compute/metadata" "github.com/fullsailor/pkcs7" @@ -658,6 +659,7 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ Email: "testuser@coder.com", Username: "testuser", Password: "SomeSecurePassword!", + Name: "Test User", } // CreateFirstUser creates a user with preset credentials and authenticates @@ -712,6 +714,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI req := codersdk.CreateUserRequest{ Email: namesgenerator.GetRandomName(10) + "@coder.com", Username: RandomUsername(t), + Name: RandomName(t), Password: "SomeSecurePassword!", OrganizationID: organizationID, } @@ -1390,6 +1393,28 @@ func RandomUsername(t testing.TB) string { return n } +func RandomName(t testing.TB) string { + var sb strings.Builder + var err error + ss := strings.Split(namesgenerator.GetRandomName(10), "_") + for si, s := range ss { + for ri, r := range s { + if ri == 0 { + _, err = sb.WriteRune(unicode.ToTitle(r)) + require.NoError(t, err) + } else { + _, err = sb.WriteRune(r) + require.NoError(t, err) + } + } + if si < len(ss)-1 { + _, err = sb.WriteRune(' ') + require.NoError(t, err) + } + } + return sb.String() +} + // Used to easily create an HTTP transport! type roundTripper func(req *http.Request) (*http.Response, error) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 17c0d76c4ef31..3c1021bfd6eda 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1114,6 +1114,7 @@ func (s *MethodTestSuite) TestUser() { ID: u.ID, Email: u.Email, Username: u.Username, + Name: u.Name, UpdatedAt: u.UpdatedAt, }).Asserts(u, policy.ActionUpdatePersonal).Returns(u) })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 23c6e8a351da0..d2b66e5d4b6df 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -289,6 +289,7 @@ func User(t testing.TB, db database.Store, orig database.User) database.User { ID: takeFirst(orig.ID, uuid.New()), Email: takeFirst(orig.Email, namesgenerator.GetRandomName(1)), Username: takeFirst(orig.Username, namesgenerator.GetRandomName(1)), + Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), HashedPassword: takeFirstSlice(orig.HashedPassword, []byte(must(cryptorand.String(32)))), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 04eecd5d86355..89748825cef6e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -322,6 +322,7 @@ func convertUsers(users []database.User, count int64) []database.GetUsersRow { ID: u.ID, Email: u.Email, Username: u.Username, + Name: u.Name, HashedPassword: u.HashedPassword, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, @@ -6474,6 +6475,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Username: arg.Username, + Name: arg.Name, Status: database.UserStatusDormant, RBACRoles: arg.RBACRoles, LoginType: arg.LoginType, diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 0ae838894aa8b..f8a3fc2c537b1 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -330,6 +330,7 @@ func ConvertUserRows(rows []GetUsersRow) []User { ID: r.ID, Email: r.Email, Username: r.Username, + Name: r.Name, HashedPassword: r.HashedPassword, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4f113323a024f..67d7f0db07198 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8855,6 +8855,7 @@ INSERT INTO id, email, username, + name, hashed_password, created_at, updated_at, @@ -8862,13 +8863,14 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name ` type InsertUserParams struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` Username string `db:"username" json:"username"` + Name string `db:"name" json:"name"` HashedPassword []byte `db:"hashed_password" json:"hashed_password"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -8881,6 +8883,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.ID, arg.Email, arg.Username, + arg.Name, arg.HashedPassword, arg.CreatedAt, arg.UpdatedAt, diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index cd2b3456379fa..6bbfdac112d7a 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -62,6 +62,7 @@ INSERT INTO id, email, username, + name, hashed_password, created_at, updated_at, @@ -69,7 +70,7 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; -- name: UpdateUserProfile :one UPDATE diff --git a/coderd/users.go b/coderd/users.go index b8a3306b12121..5ef0b2f8316e8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -187,6 +187,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { CreateUserRequest: codersdk.CreateUserRequest{ Email: createUser.Email, Username: createUser.Username, + Name: createUser.Name, Password: createUser.Password, OrganizationID: defaultOrg.ID, }, @@ -1224,6 +1225,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create ID: uuid.New(), Email: req.Email, Username: req.Username, + Name: httpapi.NormalizeRealUsername(req.Name), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), HashedPassword: []byte{}, diff --git a/coderd/users_test.go b/coderd/users_test.go index 65a16cef2dedd..96be279e1ecf3 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -70,8 +70,14 @@ func TestFirstUser(t *testing.T) { t.Run("Create", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) + u, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Name, u.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, u.Email) + assert.Equal(t, coderdtest.FirstUserParams.Username, u.Username) }) t.Run("Trial", func(t *testing.T) { @@ -96,6 +102,7 @@ func TestFirstUser(t *testing.T) { req := codersdk.CreateFirstUserRequest{ Email: "testuser@coder.com", Username: "testuser", + Name: "Test User", Password: "SomeSecurePassword!", Trial: true, } @@ -1486,7 +1493,7 @@ func TestUsersFilter(t *testing.T) { exp = append(exp, made) } } - require.ElementsMatch(t, exp, matched.Users, "expected workspaces returned") + require.ElementsMatch(t, exp, matched.Users, "expected users returned") }) } } diff --git a/codersdk/users.go b/codersdk/users.go index f99015b50bde5..dd6779e3a0342 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -90,6 +90,7 @@ type LicensorTrialRequest struct { type CreateFirstUserRequest struct { Email string `json:"email" validate:"required,email"` Username string `json:"username" validate:"required,username"` + Name string `json:"name" validate:"user_real_name"` Password string `json:"password" validate:"required"` Trial bool `json:"trial"` TrialInfo CreateFirstUserTrialInfo `json:"trial_info"` @@ -114,6 +115,7 @@ type CreateFirstUserResponse struct { type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` + Name string `json:"name" validate:"user_real_name"` Password string `json:"password"` // UserLoginType defaults to LoginTypePassword. UserLoginType LoginType `json:"login_type"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6329b49ee820e..161c5bc41213a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -938,6 +938,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "email": "string", + "name": "string", "password": "string", "trial": true, "trial_info": { @@ -958,6 +959,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ------------ | ---------------------------------------------------------------------- | -------- | ------------ | ----------- | | `email` | string | true | | | +| `name` | string | false | | | | `password` | string | true | | | | `trial` | boolean | false | | | | `trial_info` | [codersdk.CreateFirstUserTrialInfo](#codersdkcreatefirstusertrialinfo) | false | | | @@ -1248,6 +1250,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "disable_login": true, "email": "user@example.com", "login_type": "", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -1261,6 +1264,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. Deprecated: Set UserLoginType=LoginTypeDisabled instead. | | `email` | string | true | | | | `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `name` | string | false | | | | `organization_id` | string | false | | | | `password` | string | false | | | | `username` | string | true | | | diff --git a/docs/api/users.md b/docs/api/users.md index 0fd67493def34..22d1c7b9cfca8 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -83,6 +83,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "disable_login": true, "email": "user@example.com", "login_type": "", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -229,6 +230,7 @@ curl -X POST http://coder-server:8080/api/v2/users/first \ ```json { "email": "string", + "name": "string", "password": "string", "trial": true, "trial_info": { diff --git a/docs/cli/login.md b/docs/cli/login.md index 8dab8a884149c..9a27e4a6357c8 100644 --- a/docs/cli/login.md +++ b/docs/cli/login.md @@ -30,6 +30,15 @@ Specifies an email address to use if creating the first user for the deployment. Specifies a username to use if creating the first user for the deployment. +### --first-user-full-name + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_FIRST_USER_FULL_NAME | + +Specifies a human-readable name for the first user of the deployment. + ### --first-user-password | | | diff --git a/docs/cli/users_create.md b/docs/cli/users_create.md index 3934f2482ac02..1e8e12530939f 100644 --- a/docs/cli/users_create.md +++ b/docs/cli/users_create.md @@ -26,6 +26,14 @@ Specifies an email address for the new user. Specifies a username for the new user. +### -n, --full-name + +| | | +| ---- | ------------------- | +| Type | string | + +Specifies an optional human-readable name for the new user. + ### -p, --password | | | diff --git a/scripts/develop.sh b/scripts/develop.sh index 00d96389e21fd..3eb9c006003de 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -155,7 +155,7 @@ fatal() { if [ ! -f "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" ]; then # Try to create the initial admin user. - if "${CODER_DEV_SHIM}" login http://127.0.0.1:3000 --first-user-username=admin --first-user-email=admin@coder.com --first-user-password="${password}" --first-user-trial=true; then + if "${CODER_DEV_SHIM}" login http://127.0.0.1:3000 --first-user-username=admin --first-user-email=admin@coder.com --first-user-password="${password}" --first-user-full-name="Admin User" --first-user-trial=true; then # Only create this file if an admin user was successfully # created, otherwise we won't retry on a later attempt. touch "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" @@ -164,7 +164,7 @@ fatal() { fi # Try to create a regular user. - "${CODER_DEV_SHIM}" users create --email=member@coder.com --username=member --password="${password}" || + "${CODER_DEV_SHIM}" users create --email=member@coder.com --username=member --full-name "Regular User" --password="${password}" || echo 'Failed to create regular user. To troubleshoot, try running this command manually.' fi diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 08a25543b0fb6..5b9e5254d2930 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -33,6 +33,7 @@ export const createUser = async (orgId: string) => { const user = await API.createUser({ email: `${name}@coder.com`, username: name, + name: name, password: "s3cure&password!", login_type: "password", disable_login: false, diff --git a/site/e2e/tests/users/createUserWithPassword.spec.ts b/site/e2e/tests/users/createUserWithPassword.spec.ts index b8c95d35b32d7..9620d56fd8e9f 100644 --- a/site/e2e/tests/users/createUserWithPassword.spec.ts +++ b/site/e2e/tests/users/createUserWithPassword.spec.ts @@ -11,6 +11,38 @@ test("create user with password", async ({ page, baseURL }) => { await page.getByRole("button", { name: "Create user" }).click(); await expect(page).toHaveTitle("Create User - Coder"); + const name = randomName(); + const userValues = { + username: name, + name: name, + email: `${name}@coder.com`, + loginType: "password", + password: "s3cure&password!", + }; + + await page.getByLabel("Username").fill(userValues.username); + await page.getByLabel("Full name").fill(userValues.username); + await page.getByLabel("Email").fill(userValues.email); + await page.getByLabel("Login Type").click(); + await page.getByRole("option", { name: "Password", exact: false }).click(); + // Using input[name=password] due to the select element utilizing 'password' + // as the label for the currently active option. + const passwordField = page.locator("input[name=password]"); + await passwordField.fill(userValues.password); + await page.getByRole("button", { name: "Create user" }).click(); + await expect(page.getByText("Successfully created user.")).toBeVisible(); + + await expect(page).toHaveTitle("Users - Coder"); + await expect(page.locator("tr", { hasText: userValues.email })).toBeVisible(); +}); + +test("create user without full name is optional", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/users`, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveTitle("Users - Coder"); + + await page.getByRole("button", { name: "Create user" }).click(); + await expect(page).toHaveTitle("Create User - Coder"); + const name = randomName(); const userValues = { username: name, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ef3e997f1a1e4..cdcacf1823edb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -194,6 +194,7 @@ export interface ConvertLoginRequest { export interface CreateFirstUserRequest { readonly email: string; readonly username: string; + readonly name: string; readonly password: string; readonly trial: boolean; readonly trial_info: CreateFirstUserTrialInfo; @@ -294,6 +295,7 @@ export interface CreateTokenRequest { export interface CreateUserRequest { readonly email: string; readonly username: string; + readonly name: string; readonly password: string; readonly login_type: LoginType; readonly disable_login: boolean; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 3f44c718381d8..ede3e54c1b41a 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -11,6 +11,7 @@ import { FormFooter } from "components/FormFooter/FormFooter"; import { FullPageForm } from "components/FullPageForm/FullPageForm"; import { Stack } from "components/Stack/Stack"; import { + displayNameValidator, getFormHelpers, nameValidator, onChangeTrimmed, @@ -20,6 +21,8 @@ export const Language = { emailLabel: "Email", passwordLabel: "Password", usernameLabel: "Username", + nameLabel: "Full name", + nameHelperText: "Optional human-readable name", emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", passwordRequired: "Please enter a password.", @@ -78,6 +81,7 @@ const validationSchema = Yup.object({ otherwise: (schema) => schema, }), username: nameValidator(Language.usernameLabel), + name: displayNameValidator(Language.nameLabel), login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)), }); @@ -90,6 +94,7 @@ export const CreateUserForm: FC< email: "", password: "", username: "", + name: "", organization_id: organizationId, disable_login: false, login_type: "", @@ -124,6 +129,17 @@ export const CreateUserForm: FC< fullWidth label={Language.usernameLabel} /> + { + e.target.value = e.target.value.trim(); + form.handleChange(e); + }} + autoComplete="name" + fullWidth + label={Language.nameLabel} + helperText={Language.nameHelperText} + /> = ({ email: "", password: "", username: "", + name: "", trial: false, trial_info: { first_name: "", @@ -152,6 +155,17 @@ export const SetupPageView: FC = ({ fullWidth label={Language.usernameLabel} /> + { + e.target.value = e.target.value.trim(); + form.handleChange(e); + }} + autoComplete="name" + fullWidth + label={Language.nameLabel} + helperText={Language.nameHelperText} + /> = ({ label={Language.passwordLabel} type="password" /> -