diff --git a/cli/login.go b/cli/login.go index 65a94d8a4ec3e..522b448028228 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 errors.Is(err, cliui.Canceled) { + return "", nil + } + if err != 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, + FullName: name, Password: password, Trial: trial, }) @@ -353,6 +375,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 3cf9dc1945b57..f35a2372179e6 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -18,6 +18,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) { @@ -89,10 +90,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.FullName, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -103,6 +105,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.FullName, 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) { @@ -119,10 +179,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.FullName, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -132,6 +193,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.FullName, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) }) t.Run("InitialUserFlags", func(t *testing.T) { @@ -139,13 +212,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.FullName, + "--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.FullName, 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) { @@ -167,10 +283,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.FullName, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", "something completely different", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -183,9 +300,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/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_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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 26ddef4177d82..1088a27fbac7e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8897,6 +8897,9 @@ const docTemplate = `{ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 57800f5a38fa9..ccf35fc32d8ab 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7921,6 +7921,9 @@ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 316683a9f1e65..3ad2bdca24007 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -645,6 +645,7 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ Email: "testuser@coder.com", Username: "testuser", Password: "SomeSecurePassword!", + FullName: "Test User", } // CreateFirstUser creates a user with preset credentials and authenticates diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 2bfff39a949a9..311191b1a9630 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -320,6 +320,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, diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d71c63b089556..2b3a225ee9a05 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -313,6 +313,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/users.go b/coderd/users.go index 8db74cadadc9b..279947df81526 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -200,6 +202,19 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { return } + //nolint:gocritic // Needed to create first user. + if _, err := api.Database.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ + ID: user.ID, + UpdatedAt: dbtime.Now(), + Email: user.Email, + Username: user.Username, + AvatarURL: user.AvatarURL, + Name: createUser.FullName, + }); err != nil { + // This should not be a fatal error. Updating the user's profile can be done separately. + api.Logger.Error(ctx, "failed to update userprofile.Name", slog.Error(err)) + } + if api.RefreshEntitlements != nil { err = api.RefreshEntitlements(ctx) if err != nil { diff --git a/coderd/users_test.go b/coderd/users_test.go index 0fa42c4578c6d..779683fbd7721 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.FullName, 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", + FullName: "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/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index eb76239b84658..5d99e56820aa1 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -728,7 +728,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { validateCalls++ if userSuspended { // Simulate the user being suspended from the IDP too. - return "", http.StatusForbidden, fmt.Errorf("user is suspended") + return "", http.StatusForbidden, xerrors.New("user is suspended") } return "OK", 0, nil }, diff --git a/codersdk/users.go b/codersdk/users.go index 003ede2f9bd60..eab6a6a7d25b3 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"` + FullName string `json:"name" validate:"user_real_name"` Password string `json:"password" validate:"required"` Trial bool `json:"trial"` TrialInfo CreateFirstUserTrialInfo `json:"trial_info"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 65b6e0fb47106..9536acdabaecf 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1325,6 +1325,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "email": "string", + "name": "string", "password": "string", "trial": true, "trial_info": { @@ -1345,6 +1346,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 | | | diff --git a/docs/api/users.md b/docs/api/users.md index db5f959a7fa76..67e9f7fdd4eb2 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -229,6 +229,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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 86b6cf1e830f4..f89486f78ddca 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -193,6 +193,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; diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index af673acacc333..113b6f5c61a4c 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -23,6 +23,8 @@ import { countries } from "./countries"; export const Language = { emailLabel: "Email", passwordLabel: "Password", + fullNameLabel: "Full Name", + nameHelperText: "Optional human-readable name", usernameLabel: "Username", emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", @@ -93,6 +95,7 @@ export const SetupPageView: FC = ({ email: "", password: "", username: "", + name: "", trial: false, trial_info: { first_name: "", @@ -164,7 +167,17 @@ export const SetupPageView: FC = ({ label={Language.passwordLabel} type="password" /> - + { + e.target.value = e.target.value.trim(); + form.handleChange(e); + }} + autoComplete="name" + fullWidth + label={Language.fullNameLabel} + helperText={Language.nameHelperText} + />