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}
+ />