feat: add "Full Name" field to user creation (#13659)

Adds the ability to specify "Full Name" (a.k.a. Name) when
creating users either via CLI or UI.
This commit is contained in:
Cian Johnston
2024-06-26 09:00:42 +01:00
committed by GitHub
parent 87ad560aff
commit 8a3592582b
33 changed files with 435 additions and 25 deletions
+28
View File
@@ -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",
+133 -16
View File
@@ -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")
+3
View File
@@ -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(),
+3
View File
@@ -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.
+3
View File
@@ -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
+1 -1
View File
@@ -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]",
+24
View File
@@ -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",
+88 -1
View File
@@ -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)
})
}
+1
View File
@@ -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))
+9 -1
View File
@@ -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)
})
}