diff --git a/.gitignore b/.gitignore index 73452f9b1..56dc910db 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ tags # misc .vscode .DS_Store +.idea \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f87554b2f..3052bc386 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -30,7 +30,8 @@ var requiredScopes = []string{ "create:clients", "delete:clients", "read:clients", "update:clients", "create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers", "create:rules", "delete:rules", "read:rules", "update:rules", - "read:users", "update:users", + "read:client_keys", "read:logs", "read:connections", "update:connections", + "create:users", "delete:users", "read:users", "update:users", "read:branding", "update:branding", "read:client_keys", "read:logs", "read:tenant_settings", "read:custom_domains", } diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index e5d846b1f..25d3e1967 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -20,6 +20,7 @@ type API struct { Rule RuleAPI Tenant TenantAPI User UserAPI + Connection ConnectionAPI } func NewAPI(m *management.Management) *API { @@ -36,6 +37,7 @@ func NewAPI(m *management.Management) *API { Rule: m.Rule, Tenant: m.Tenant, User: m.User, + Connection: m.Connection, } } diff --git a/internal/auth0/connection.go b/internal/auth0/connection.go new file mode 100644 index 000000000..9c9a32878 --- /dev/null +++ b/internal/auth0/connection.go @@ -0,0 +1,26 @@ +//go:generate mockgen -source=connection.go -destination=connection.go -package=auth0 + +package auth0 + +import "gopkg.in/auth0.v5/management" + +type ConnectionAPI interface { + + // Create a new connection. + Create(c *management.Connection, opts ...management.RequestOption) (err error) + + // Read retrieves a connection by its id. + Read(id string, opts ...management.RequestOption) (c *management.Connection, err error) + + // ReadByName retrieves a connection by its name. + ReadByName(id string, opts ...management.RequestOption) (c *management.Connection, err error) + + // Update a connection. + Update(id string, c *management.Connection, opts ...management.RequestOption) (err error) + + // Delete a connection. + Delete(id string, opts ...management.RequestOption) (err error) + + // List all connections. + List(opts ...management.RequestOption) (ul *management.ConnectionList, err error) +} diff --git a/internal/auth0/user.go b/internal/auth0/user.go index 1a8017210..5e7123a3a 100644 --- a/internal/auth0/user.go +++ b/internal/auth0/user.go @@ -11,4 +11,22 @@ type UserAPI interface { // Unblock a user that was blocked due to an excessive amount of incorrectly // provided credentials. Unblock(id string, opts ...management.RequestOption) error + + // Create a new user. + Create(u *management.User, opts ...management.RequestOption) (err error) + + // Read user details for a given user. + Read(id string, opts ...management.RequestOption) (u *management.User, err error) + + // Update user. + Update(id string, u *management.User, opts ...management.RequestOption) (err error) + + // Delete a user. + Delete(id string, opts ...management.RequestOption) (err error) + + // List all users. + List(opts ...management.RequestOption) (ul *management.UserList, err error) + + // Search for users + Search(opts ...management.RequestOption) (us *management.UserList, err error) } diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 6b4a2e53b..b9dfa694e 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -136,6 +136,14 @@ func (f *Flag) EditorPromptU(cmd *cobra.Command, value *string, initialValue, fi return nil } +func (f *Flag) AskPassword(cmd *cobra.Command, value *string, defaultValue *string) error { + return askPasswordFlag(cmd, f, value, defaultValue, false) +} + +func (f *Flag) AskPasswordU(cmd *cobra.Command, value *string, defaultValue *string) error { + return askPasswordFlag(cmd, f, value, defaultValue, true) +} + func (f *Flag) RegisterString(cmd *cobra.Command, value *string, defaultValue string) { registerString(cmd, f, value, defaultValue, false) } @@ -206,6 +214,16 @@ func selectFlag(cmd *cobra.Command, f *Flag, value interface{}, options []string return nil } +func askPasswordFlag(cmd *cobra.Command, f *Flag, value *string, defaultValue *string, isUpdate bool) error { + if shouldAsk(cmd, f, isUpdate) { + if err := askPassword(cmd, f, value, defaultValue, isUpdate); err != nil { + return err + } + } + + return nil +} + func registerString(cmd *cobra.Command, f *Flag, value *string, defaultValue string, isUpdate bool) { cmd.Flags().StringVarP(value, f.LongForm, f.ShortForm, defaultValue, f.Help) diff --git a/internal/cli/input.go b/internal/cli/input.go index 2a1f66c01..aa249baa8 100644 --- a/internal/cli/input.go +++ b/internal/cli/input.go @@ -36,6 +36,17 @@ func askBool(cmd *cobra.Command, i commandInput, value *bool, defaultValue *bool return nil } +func askPassword(cmd *cobra.Command, i commandInput, value interface{}, defaultValue *string, isUpdate bool) error { + isRequired := !isUpdate && i.GetIsRequired() + input := prompt.Password("", i.GetLabel(), auth0.StringValue(defaultValue), isRequired) + + if err := prompt.AskOne(input, value); err != nil { + return handleInputError(err) + } + + return nil +} + func _select(cmd *cobra.Command, i commandInput, value interface{}, options []string, defaultValue *string, isUpdate bool) error { isRequired := !isUpdate && i.GetIsRequired() diff --git a/internal/cli/users.go b/internal/cli/users.go index 3af6d1f52..281184e62 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -1,9 +1,13 @@ package cli import ( + "encoding/json" "fmt" + "net/url" "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/prompt" "github.com/spf13/cobra" "gopkg.in/auth0.v5/management" ) @@ -13,6 +17,54 @@ var ( Name: "User ID", Help: "Id of the user.", } + userConnection = Flag{ + Name: "Connection", + LongForm: "connection", + ShortForm: "c", + Help: "Name of the connection this user should be created in.", + IsRequired: true, + } + userEmail = Flag{ + Name: "Email", + LongForm: "email", + ShortForm: "e", + Help: "The user's email.", + IsRequired: true, + } + userPassword = Flag{ + Name: "Password", + LongForm: "password", + ShortForm: "p", + Help: "Initial password for this user (mandatory for non-SMS connections).", + IsRequired: true, + } + userUsername = Flag{ + Name: "Username", + LongForm: "username", + ShortForm: "u", + Help: "The user's username. Only valid if the connection requires a username.", + } + userName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "The user's full name.", + IsRequired: true, + AlwaysPrompt: true, + } + userQuery = Flag{ + Name: "Query", + LongForm: "query", + ShortForm: "q", + Help: "Query in Lucene query string syntax. See https://auth0.com/docs/users/user-search/user-search-query-syntax for more details.", + IsRequired: true, + } + userSort = Flag{ + Name: "Sort", + LongForm: "sort", + ShortForm: "s", + Help: "Field to sort by. Use 'field:order' where 'order' is '1' for ascending and '-1' for descending. e.g. 'created_at:1'.", + } ) func usersCmd(cli *cli) *cobra.Command { @@ -22,8 +74,374 @@ func usersCmd(cli *cli) *cobra.Command { } cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(searchUsersCmd(cli)) + cmd.AddCommand(createUserCmd(cli)) + cmd.AddCommand(showUserCmd(cli)) + cmd.AddCommand(deleteUserCmd(cli)) + cmd.AddCommand(updateUserCmd(cli)) + cmd.AddCommand(openUserCmd(cli)) cmd.AddCommand(userBlocksCmd(cli)) cmd.AddCommand(deleteUserBlocksCmd(cli)) + + return cmd +} + +func searchUsersCmd(cli *cli) *cobra.Command { + var inputs struct { + query string + sort string + } + + cmd := &cobra.Command{ + Use: "search", + Args: cobra.NoArgs, + Short: "Search for users", + Long: `Search for users. To create one try: +auth0 users create`, + Example: `auth0 users search +auth0 users search --query id +auth0 users search -q name --sort "name:1" +auth0 users search -q name -s "name:1"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := userQuery.Ask(cmd, &inputs.query, nil); err != nil { + return err + } + + search := &management.UserList{} + + var queryParams []management.RequestOption + + if len(inputs.sort) == 0 { + queryParams = append(queryParams, management.Query(auth0.StringValue(&inputs.query))) + } else { + queryParams = append(queryParams, + management.Query(auth0.StringValue(&inputs.query)), + management.Parameter("sort", auth0.StringValue(&inputs.sort)), + ) + } + + if err := ansi.Waiting(func() error { + var err error + search, err = cli.api.User.Search(queryParams...) + return err + }); err != nil { + return fmt.Errorf("An unexpected error occurred: %w", err) + } + + cli.renderer.UserSearch(search.Users) + return nil + }, + } + + userQuery.RegisterString(cmd, &inputs.query, "") + userSort.RegisterString(cmd, &inputs.sort, "") + + return cmd +} + +func createUserCmd(cli *cli) *cobra.Command { + var inputs struct { + Connection string + Email string + Password string + Username string + Name string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new user", + Long: "Create a new user.", + Example: `auth0 users create +auth0 users create --name "John Doe" +auth0 users create -n "John Doe" --email john@example.com +auth0 users create -n "John Doe" --e john@example.com --connection "Username-Password-Authentication"`, + RunE: func(cmd *cobra.Command, args []string) error { + // Select from the available connection types + // Users API currently support database connections + if err := userConnection.Select(cmd, &inputs.Connection, cli.connectionPickerOptions(), nil); err != nil { + return err + } + + // Prompt for user's name + if err := userName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + // Prompt for user email + if err := userEmail.Ask(cmd, &inputs.Email, nil); err != nil { + return err + } + + ////Prompt for user password + if err := userPassword.AskPassword(cmd, &inputs.Password, nil); err != nil { + return err + } + + // The getConnReqUsername returns the value for the requires_username field for the selected connection + // The result will be used to determine whether to prompt for username + conn := cli.getConnReqUsername(auth0.StringValue(&inputs.Connection)) + requireUsername := auth0.BoolValue(conn) + + // Prompt for username if the requireUsername is set to true + // Load values including the username's field into a fresh users instance + a := &management.User{ + Connection: &inputs.Connection, + Email: &inputs.Email, + Name: &inputs.Name, + Password: &inputs.Password, + } + + if requireUsername { + if err := userUsername.Ask(cmd, &inputs.Username, nil); err != nil { + return err + } + a.Username = &inputs.Username + } + // Create app + if err := ansi.Waiting(func() error { + return cli.api.User.Create(a) + }); err != nil { + return fmt.Errorf("Unable to create user: %w", err) + } + + // Render Result + cli.renderer.UserCreate(a, requireUsername) + + return nil + }, + } + userName.RegisterString(cmd, &inputs.Name, "") + userConnection.RegisterString(cmd, &inputs.Connection, "") + userPassword.RegisterString(cmd, &inputs.Password, "") + userEmail.RegisterString(cmd, &inputs.Email, "") + userUsername.RegisterString(cmd, &inputs.Username, "") + + return cmd +} + +func showUserCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show an existing user", + Long: "Show an existing user.", + Example: `auth0 users show +auth0 users show `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := userID.Ask(cmd, &inputs.ID); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + a := &management.User{ID: &inputs.ID} + + if err := ansi.Waiting(func() error { + var err error + a, err = cli.api.User.Read(inputs.ID) + return err + }); err != nil { + return fmt.Errorf("Unable to load user. The Id %v specified doesn't exist", inputs.ID) + } + + // get the current connection + conn := stringSliceToCommaSeparatedString(cli.getUserConnection(a)) + a.Connection = auth0.String(conn) + + // parse the connection name to get the requireUsername status + u := cli.getConnReqUsername(auth0.StringValue(a.Connection)) + requireUsername := auth0.BoolValue(u) + + cli.renderer.UserShow(a, requireUsername) + return nil + }, + } + + return cmd +} + +func deleteUserCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "delete", + Args: cobra.MaximumNArgs(1), + Short: "Delete a user", + Long: "Delete a user.", + Example: `auth0 users delete +auth0 users delete `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := userID.Ask(cmd, &inputs.ID); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.Spinner("Deleting user", func() error { + _, err := cli.api.User.Read(inputs.ID) + + if err != nil { + return fmt.Errorf("Unable to delete user. The specified Id: %v doesn't exist", inputs.ID) + } + + return cli.api.User.Delete(inputs.ID) + }) + }, + } + + return cmd +} + +func updateUserCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Email string + Password string + Name string + Connection string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a user", + Long: "Update a user.", + Example: `auth0 users update +auth0 users update +auth0 users update --name John Doe +auth0 users update -n John Doe --email john.doe@example.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := userID.Ask(cmd, &inputs.ID); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var current *management.User + + if err := ansi.Waiting(func() error { + var err error + current, err = cli.api.User.Read(inputs.ID) + return err + }); err != nil { + return fmt.Errorf("Unable to load user. The Id %v specified doesn't exist", inputs.ID) + } + // using getUserConnection to get connection name from user Identities + // just using current.connection will return empty + conn := stringSliceToCommaSeparatedString(cli.getUserConnection(current)) + current.Connection = auth0.String(conn) + + if err := userName.AskU(cmd, &inputs.Name, current.Name); err != nil { + return err + } + + if err := userEmail.AskU(cmd, &inputs.Email, current.Email); err != nil { + return err + } + + if err := userPassword.AskPasswordU(cmd, &inputs.Password, current.Password); err != nil { + return err + } + + // username cannot be updated for database connections + //if err := userUsername.AskU(cmd, &inputs.Username, current.Username); err != nil { + // return err + //} + + user := &management.User{} + + if len(inputs.Name) == 0 { + user.Name = current.Name + } else { + user.Name = &inputs.Name + } + + if len(inputs.Email) == 0 { + user.Email = current.Email + } else { + user.Email = &inputs.Email + } + + if len(inputs.Password) == 0 { + user.Password = current.Password + } else { + user.Password = &inputs.Password + } + + if len(inputs.Connection) == 0 { + user.Connection = current.Connection + } else { + user.Connection = &inputs.Connection + } + + if err := ansi.Waiting(func() error { + return cli.api.User.Update(current.GetID(), user) + }); err != nil { + return fmt.Errorf("An unexpected error occurred while trying to update an user with Id '%s': %w", inputs.ID, err) + } + + con := cli.getConnReqUsername(auth0.StringValue(user.Connection)) + requireUsername := auth0.BoolValue(con) + + cli.renderer.UserUpdate(user, requireUsername) + return nil + }, + } + + userName.RegisterStringU(cmd, &inputs.Name, "") + userConnection.RegisterStringU(cmd, &inputs.Connection, "") + userPassword.RegisterStringU(cmd, &inputs.Password, "") + userEmail.RegisterStringU(cmd, &inputs.Email, "") + + return cmd +} + +func openUserCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "open", + Args: cobra.MaximumNArgs(1), + Short: "Open user details page in Auth0 Manage", + Long: "Open user details page in Auth0 Manage.", + Example: `auth0 users open +auth0 users open "auth0|xxxxxxxxxx"`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := userID.Ask(cmd, &inputs.ID); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + openManageURL(cli, cli.config.DefaultTenant, formatUserDetailsPath(url.PathEscape(inputs.ID))) + return nil + }, + } + return cmd } @@ -112,3 +530,50 @@ func deleteUserBlocksCmd(cli *cli) *cobra.Command { return cmd } + +func formatUserDetailsPath(id string) string { + if len(id) == 0 { + return "" + } + return fmt.Sprintf("users/%s", id) +} + +func (c *cli) connectionPickerOptions() []string { + var res []string + + list, err := c.api.Connection.List() + if err != nil { + fmt.Println(err) + } + for _, conn := range list.Connections { + res = append(res, conn.GetName()) + } + + return res +} + +func (c *cli) getUserConnection(users *management.User) []string { + var res []string + for _, i := range users.Identities { + res = append(res, fmt.Sprintf("%v", auth0.StringValue(i.Connection))) + + } + + return res +} + +// This is a workaround to get the requires_username field nested inside Options field +func (c *cli) getConnReqUsername(s string) *bool { + conn, err := c.api.Connection.ReadByName(s) + if err != nil { + fmt.Println(err) + } + res := fmt.Sprintln(conn.Options) + + opts := &management.ConnectionOptions{} + if err := json.Unmarshal([]byte(res), &opts); err != nil { + fmt.Println(err) + } + + return opts.RequiresUsername +} diff --git a/internal/display/users.go b/internal/display/users.go new file mode 100644 index 000000000..327bce940 --- /dev/null +++ b/internal/display/users.go @@ -0,0 +1,132 @@ +package display + +import ( + "fmt" + "strings" + + "github.com/auth0/auth0-cli/internal/ansi" + "gopkg.in/auth0.v5" + "gopkg.in/auth0.v5/management" +) + +type userView struct { + UserID string + Email string + Connection string + Username string + RequireUsername bool +} + +func (v *userView) AsTableHeader() []string { + return []string{ + "UserID", + "Email", + "Connection", + } +} + +func (v *userView) AsTableRow() []string { + return []string{ + ansi.Faint(v.UserID), + v.Email, + v.Connection, + } +} + +func (v *userView) KeyValues() [][]string { + if v.RequireUsername { + return [][]string{ + []string{"ID", ansi.Faint(v.UserID)}, + []string{"EMAIL", v.Email}, + []string{"CONNECTION", v.Connection}, + []string{"USERNAME", v.Username}, + } + } + return [][]string{ + []string{"ID", ansi.Faint(v.UserID)}, + []string{"EMAIL", v.Email}, + []string{"CONNECTION", v.Connection}, + } +} + +func (r *Renderer) UserSearch(users []*management.User) { + resource := "user" + + r.Heading(resource) + + if len(users) == 0 { + r.EmptyState(resource) + r.Infof("Use 'auth0 users create' to add one") + return + } + + var res []View + for _, c := range users { + conn := getUserConnection(c) + res = append(res, &userView{ + UserID: ansi.Faint(auth0.StringValue(c.ID)), + Email: auth0.StringValue(c.Email), + Connection: stringSliceToCommaSeparatedString(conn), + Username: auth0.StringValue(c.Username), + }) + } + + r.Results(res) +} + +func (r *Renderer) UserShow(users *management.User, requireUsername bool) { + r.Heading("user") + + conn := getUserConnection(users) + v := &userView{ + RequireUsername: requireUsername, + UserID: ansi.Faint(auth0.StringValue(users.ID)), + Email: auth0.StringValue(users.Email), + Connection: stringSliceToCommaSeparatedString(conn), + Username: auth0.StringValue(users.Username), + } + + r.Result(v) +} + +func (r *Renderer) UserCreate(users *management.User, requireUsername bool) { + r.Heading("user created") + + v := &userView{ + RequireUsername: requireUsername, + UserID: ansi.Faint(auth0.StringValue(users.ID)), + Email: auth0.StringValue(users.Email), + Connection: auth0.StringValue(users.Connection), + Username: auth0.StringValue(users.Username), + } + + r.Result(v) +} + +func (r *Renderer) UserUpdate(users *management.User, requireUsername bool) { + r.Heading("user updated") + + conn := getUserConnection(users) + v := &userView{ + RequireUsername: requireUsername, + UserID: auth0.StringValue(users.ID), + Email: auth0.StringValue(users.Email), + Connection: stringSliceToCommaSeparatedString(conn), + Username: auth0.StringValue(users.Username), + } + + r.Result(v) +} + +func getUserConnection(users *management.User) []string { + var res []string + for _, i := range users.Identities { + res = append(res, fmt.Sprintf("%v", auth0.StringValue(i.Connection))) + + } + return res +} + +func stringSliceToCommaSeparatedString(s []string) string { + return strings.Join(s, ", ") +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 586a8e49c..486d67f98 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -92,3 +92,16 @@ func SelectInput(name string, message string, help string, options []string, def return input } + +func Password(name string, message string, defaultValue string, required bool) *survey.Question { + input := &survey.Question{ + Name: name, + Prompt: &survey.Password{Message: message}, + } + + if required { + input.Validate = survey.Required + } + + return input +} diff --git a/pkg/auth0-cli-config-generator/main.go b/pkg/auth0-cli-config-generator/main.go index 955837529..6d8bef902 100644 --- a/pkg/auth0-cli-config-generator/main.go +++ b/pkg/auth0-cli-config-generator/main.go @@ -33,7 +33,8 @@ var requiredScopes = []string{ "create:clients", "delete:clients", "read:clients", "update:clients", "create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers", "create:rules", "delete:rules", "read:rules", "update:rules", - "read:users", "update:users", + "read:client_keys", "read:logs", "read:connections", "update:connections", + "create:users", "delete:users", "read:users", "update:users", "read:branding", "update:branding", "read:client_keys", "read:logs", "read:tenant_settings", "read:custom_domains", }