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..8aeb478b08dd1 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,7 @@ export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
usernameLabel: "Username",
+ nameLabel: "Full name",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
@@ -78,6 +80,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 +93,7 @@ export const CreateUserForm: FC<
email: "",
password: "",
username: "",
+ name: "",
organization_id: organizationId,
disable_login: false,
login_type: "",
@@ -124,6 +128,12 @@ export const CreateUserForm: FC<
fullWidth
label={Language.usernameLabel}
/>
+
= ({
email: "",
password: "",
username: "",
+ name: "",
trial: false,
trial_info: {
first_name: "",
@@ -152,6 +154,12 @@ export const SetupPageView: FC = ({
fullWidth
label={Language.usernameLabel}
/>
+
= ({
label={Language.passwordLabel}
type="password"
/>
-