feat: Add users create and list commands (#1111)

This allows for *extremely basic* user management.
This commit is contained in:
Kyle Carberry
2022-04-24 20:08:26 -05:00
committed by GitHub
parent 7496c3da81
commit be974cf280
21 changed files with 245 additions and 127 deletions

90
cli/usercreate.go Normal file
View File

@ -0,0 +1,90 @@
package cli
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
)
func userCreate() *cobra.Command {
var (
email string
username string
password string
)
cmd := &cobra.Command{
Use: "create",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
if username == "" {
username, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Username:",
})
if err != nil {
return err
}
}
if email == "" {
email, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Email:",
Validate: func(s string) error {
err := validator.New().Var(s, "email")
if err != nil {
return xerrors.New("That's not a valid email address!")
}
return err
},
})
if err != nil {
return err
}
}
if password == "" {
password, err = cryptorand.StringCharset(cryptorand.Human, 12)
if err != nil {
return err
}
}
_, err = client.CreateUser(cmd.Context(), codersdk.CreateUserRequest{
Email: email,
Username: username,
Password: password,
OrganizationID: organization.ID,
})
if err != nil {
return err
}
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), `A new user has been created!
Share the instructions below to get them started.
`+cliui.Styles.Placeholder.Render("—————————————————————————————————————————————————")+`
Download the Coder command line for your operating system:
https://github.com/coder/coder/releases
Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+` to authenticate.
Your email is: `+cliui.Styles.Field.Render(email)+`
Your password is: `+cliui.Styles.Field.Render(password)+`
Create a workspace `+cliui.Styles.Code.Render("coder workspaces create")+`!`)
return nil
},
}
cmd.Flags().StringVarP(&email, "email", "e", "", "Specifies an email address for the new user.")
cmd.Flags().StringVarP(&username, "username", "u", "", "Specifies a username for the new user.")
cmd.Flags().StringVarP(&password, "password", "p", "", "Specifies a password for the new user.")
return cmd
}

42
cli/usercreate_test.go Normal file
View File

@ -0,0 +1,42 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/pty/ptytest"
)
func TestUserCreate(t *testing.T) {
t.Parallel()
t.Run("Prompts", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "users", "create")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
require.NoError(t, err)
}()
matches := []string{
"Username", "dean",
"Email", "dean@coder.com",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
})
}

46
cli/userlist.go Normal file
View File

@ -0,0 +1,46 @@
package cli
import (
"fmt"
"sort"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"github.com/coder/coder/codersdk"
)
func userList() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
users, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
if err != nil {
return err
}
sort.Slice(users, func(i, j int) bool {
return users[i].Username < users[j].Username
})
tableWriter := table.NewWriter()
tableWriter.SetStyle(table.StyleLight)
tableWriter.Style().Options.SeparateColumns = false
tableWriter.AppendHeader(table.Row{"Username", "Email", "Created At"})
for _, user := range users {
tableWriter.AppendRow(table.Row{
user.Username,
user.Email,
user.CreatedAt.Format(time.Stamp),
})
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
return err
},
}
}

30
cli/userlist_test.go Normal file
View File

@ -0,0 +1,30 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/pty/ptytest"
)
func TestUserList(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "users", "list")
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
require.NoError(t, err)
}()
pty.ExpectMatch("coder.com")
<-doneChan
}

View File

@ -6,5 +6,6 @@ func users() *cobra.Command {
cmd := &cobra.Command{
Use: "users",
}
cmd.AddCommand(userCreate(), userList())
return cmd
}

View File

@ -207,8 +207,6 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
tmp = append(tmp, users[i])
} else if strings.Contains(user.Username, params.Search) {
tmp = append(tmp, users[i])
} else if strings.Contains(user.Name, params.Search) {
tmp = append(tmp, users[i])
}
}
users = tmp
@ -1116,8 +1114,6 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
user := database.User{
ID: arg.ID,
Email: arg.Email,
Name: arg.Name,
LoginType: arg.LoginType,
HashedPassword: arg.HashedPassword,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
@ -1135,7 +1131,6 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs
if user.ID != arg.ID {
continue
}
user.Name = arg.Name
user.Email = arg.Email
user.Username = arg.Username
q.users[index] = user

View File

@ -218,13 +218,10 @@ CREATE TABLE templates (
CREATE TABLE users (
id uuid NOT NULL,
email text NOT NULL,
name text NOT NULL,
revoked boolean NOT NULL,
login_type login_type NOT NULL,
username text DEFAULT ''::text NOT NULL,
hashed_password bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
username text DEFAULT ''::text NOT NULL
updated_at timestamp with time zone NOT NULL
);
CREATE TABLE workspace_agents (

View File

@ -12,13 +12,10 @@ CREATE TYPE login_type AS ENUM (
CREATE TABLE IF NOT EXISTS users (
id uuid NOT NULL,
email text NOT NULL,
name text NOT NULL,
revoked boolean NOT NULL,
login_type login_type NOT NULL,
username text DEFAULT ''::text NOT NULL,
hashed_password bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
username text DEFAULT ''::text NOT NULL,
PRIMARY KEY (id)
);

View File

@ -374,13 +374,10 @@ type TemplateVersion struct {
type User struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Revoked bool `db:"revoked" json:"revoked"`
LoginType LoginType `db:"login_type" json:"login_type"`
Username string `db:"username" json:"username"`
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"`
Username string `db:"username" json:"username"`
}
type Workspace struct {

View File

@ -1782,7 +1782,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username
id, email, username, hashed_password, created_at, updated_at
FROM
users
WHERE
@ -1803,20 +1803,17 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Revoked,
&i.LoginType,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username
id, email, username, hashed_password, created_at, updated_at
FROM
users
WHERE
@ -1831,13 +1828,10 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Revoked,
&i.LoginType,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
)
return i, err
}
@ -1858,7 +1852,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username
id, email, username, hashed_password, created_at, updated_at
FROM
users
WHERE
@ -1888,7 +1882,6 @@ WHERE
WHEN $2 :: text != '' THEN (
email LIKE concat('%', $2, '%')
OR username LIKE concat('%', $2, '%')
OR 'name' LIKE concat('%', $2, '%')
)
ELSE true
END
@ -1925,13 +1918,10 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Revoked,
&i.LoginType,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
); err != nil {
return nil, err
}
@ -1951,51 +1941,41 @@ INSERT INTO
users (
id,
email,
"name",
login_type,
revoked,
username,
hashed_password,
created_at,
updated_at,
username
updated_at
)
VALUES
($1, $2, $3, $4, FALSE, $5, $6, $7, $8) RETURNING id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username
($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at
`
type InsertUserParams struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
LoginType LoginType `db:"login_type" json:"login_type"`
Username string `db:"username" json:"username"`
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"`
Username string `db:"username" json:"username"`
}
func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, insertUser,
arg.ID,
arg.Email,
arg.Name,
arg.LoginType,
arg.Username,
arg.HashedPassword,
arg.CreatedAt,
arg.UpdatedAt,
arg.Username,
)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Revoked,
&i.LoginType,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
)
return i, err
}
@ -2005,17 +1985,15 @@ UPDATE
users
SET
email = $2,
"name" = $3,
username = $4,
updated_at = $5
username = $3,
updated_at = $4
WHERE
id = $1 RETURNING id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at
`
type UpdateUserProfileParams struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Username string `db:"username" json:"username"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
@ -2024,7 +2002,6 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
row := q.db.QueryRowContext(ctx, updateUserProfile,
arg.ID,
arg.Email,
arg.Name,
arg.Username,
arg.UpdatedAt,
)
@ -2032,13 +2009,10 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Revoked,
&i.LoginType,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
)
return i, err
}

View File

@ -30,25 +30,21 @@ INSERT INTO
users (
id,
email,
"name",
login_type,
revoked,
username,
hashed_password,
created_at,
updated_at,
username
updated_at
)
VALUES
($1, $2, $3, $4, FALSE, $5, $6, $7, $8) RETURNING *;
($1, $2, $3, $4, $5, $6) RETURNING *;
-- name: UpdateUserProfile :one
UPDATE
users
SET
email = $2,
"name" = $3,
username = $4,
updated_at = $5
username = $3,
updated_at = $4
WHERE
id = $1 RETURNING *;
@ -84,7 +80,6 @@ WHERE
WHEN @search :: text != '' THEN (
email LIKE concat('%', @search, '%')
OR username LIKE concat('%', @search, '%')
OR 'name' LIKE concat('%', @search, '%')
)
ELSE true
END

View File

@ -40,8 +40,6 @@ func TestOrganizationParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -39,8 +39,6 @@ func TestTemplateParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -39,8 +39,6 @@ func TestTemplateVersionParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -39,8 +39,6 @@ func TestWorkspaceAgentParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -39,8 +39,6 @@ func TestWorkspaceBuildParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -39,8 +39,6 @@ func TestWorkspaceParam(t *testing.T) {
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypePassword,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),

View File

@ -233,11 +233,6 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) {
if !httpapi.Read(rw, r, &params) {
return
}
if params.Name == nil {
params.Name = &user.Name
}
existentUser, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
Email: params.Email,
Username: params.Username,
@ -273,7 +268,6 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) {
updatedUserProfile, err := api.Database.UpdateUserProfile(r.Context(), database.UpdateUserProfileParams{
ID: user.ID,
Name: *params.Name,
Email: params.Email,
Username: params.Username,
UpdatedAt: database.Now(),
@ -896,7 +890,6 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest)
ID: uuid.New(),
Email: req.Email,
Username: req.Username,
LoginType: database.LoginTypePassword,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
}
@ -949,7 +942,6 @@ func convertUser(user database.User) codersdk.User {
Email: user.Email,
CreatedAt: user.CreatedAt,
Username: user.Username,
Name: user.Name,
}
}

View File

@ -283,28 +283,6 @@ func TestUpdateUserProfile(t *testing.T) {
require.Equal(t, userProfile.Username, me.Username)
require.Equal(t, userProfile.Email, "newemail@coder.com")
})
t.Run("KeepUserName", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
me, _ := client.User(context.Background(), codersdk.Me)
newName := "New Name"
firstProfile, _ := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{
Username: me.Username,
Email: me.Email,
Name: &newName,
})
t.Log(firstProfile)
userProfile, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{
Username: "newusername",
Email: "newemail@coder.com",
})
require.NoError(t, err)
require.Equal(t, userProfile.Username, "newusername")
require.Equal(t, userProfile.Email, "newemail@coder.com")
require.Equal(t, userProfile.Name, newName)
})
}
func TestUserByName(t *testing.T) {

View File

@ -34,7 +34,6 @@ type User struct {
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
Name string `json:"name"`
}
type CreateFirstUserRequest struct {
@ -60,7 +59,6 @@ type CreateUserRequest struct {
type UpdateUserProfileRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Name *string `json:"name"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.

View File

@ -81,10 +81,9 @@ export interface UsersRequest {
export interface User {
readonly email: string
readonly username: string
readonly name: string
}
// From codersdk/users.go:40:6.
// From codersdk/users.go:39:6.
export interface CreateFirstUserRequest {
readonly email: string
readonly username: string
@ -92,47 +91,46 @@ export interface CreateFirstUserRequest {
readonly organization: string
}
// From codersdk/users.go:53:6.
// From codersdk/users.go:52:6.
export interface CreateUserRequest {
readonly email: string
readonly username: string
readonly password: string
}
// From codersdk/users.go:60:6.
// From codersdk/users.go:59:6.
export interface UpdateUserProfileRequest {
readonly email: string
readonly username: string
readonly name?: string
}
// From codersdk/users.go:67:6.
// From codersdk/users.go:65:6.
export interface LoginWithPasswordRequest {
readonly email: string
readonly password: string
}
// From codersdk/users.go:73:6.
// From codersdk/users.go:71:6.
export interface LoginWithPasswordResponse {
readonly session_token: string
}
// From codersdk/users.go:78:6.
// From codersdk/users.go:76:6.
export interface GenerateAPIKeyResponse {
readonly key: string
}
// From codersdk/users.go:82:6.
// From codersdk/users.go:80:6.
export interface CreateOrganizationRequest {
readonly name: string
}
// From codersdk/users.go:87:6.
// From codersdk/users.go:85:6.
export interface CreateWorkspaceRequest {
readonly name: string
}
// From codersdk/users.go:96:6.
// From codersdk/users.go:94:6.
export interface AuthMethods {
readonly password: boolean
readonly github: boolean