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
+4
View File
@@ -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
+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)
})
}
+6
View File
@@ -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"
+6
View File
@@ -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"
+25
View File
@@ -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)
+1
View File
@@ -1122,6 +1122,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)
}))
+1
View File
@@ -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()),
+2
View File
@@ -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,
@@ -6492,6 +6493,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,
+1
View File
@@ -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,
+4 -1
View File
@@ -8917,6 +8917,7 @@ INSERT INTO
id,
email,
username,
name,
hashed_password,
created_at,
updated_at,
@@ -8924,13 +8925,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"`
@@ -8943,6 +8945,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,
+2 -1
View File
@@ -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
+2
View File
@@ -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{},
+8 -1
View File
@@ -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")
})
}
}
+2
View File
@@ -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"`
+4
View File
@@ -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 | | |
+2
View File
@@ -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": {
+9
View File
@@ -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 | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_FULL_NAME</code> |
Specifies a human-readable name for the first user of the deployment.
### --first-user-password
| | |
+8
View File
@@ -26,6 +26,14 @@ Specifies an email address for the new user.
Specifies a username for the new user.
### -n, --full-name
| | |
| ---- | ------------------- |
| Type | <code>string</code> |
Specifies an optional human-readable name for the new user.
### -p, --password
| | |
+2 -2
View File
@@ -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
+1
View File
@@ -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,
@@ -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,
+2
View File
@@ -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;
@@ -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}
/>
<TextField
{...getFieldHelpers("name")}
autoComplete="name"
fullWidth
label={Language.nameLabel}
/>
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
+8 -1
View File
@@ -26,6 +26,7 @@ import { countries } from "./countries";
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
nameLabel: "Full Name",
usernameLabel: "Username",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
@@ -96,6 +97,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
email: "",
password: "",
username: "",
name: "",
trial: false,
trial_info: {
first_name: "",
@@ -152,6 +154,12 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
fullWidth
label={Language.usernameLabel}
/>
<TextField
{...getFieldHelpers("name")}
autoComplete="name"
fullWidth
label={Language.nameLabel}
/>
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
@@ -167,7 +175,6 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
label={Language.passwordLabel}
type="password"
/>
<label
htmlFor="trial"
css={{