diff --git a/go.mod b/go.mod index edcfb8283..202eea408 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/auth0/go-auth0 v1.11.0 github.com/briandowns/spinner v1.23.1 github.com/charmbracelet/glamour v0.8.0 + github.com/charmbracelet/lipgloss v0.12.1 github.com/fsnotify/fsnotify v1.7.0 github.com/getsentry/sentry-go v0.29.1 github.com/golang/mock v1.6.0 @@ -23,6 +24,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lestrrat-go/jwx v1.2.30 github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-isatty v0.0.20 github.com/mholt/archiver/v3 v3.5.1 github.com/olekukonko/tablewriter v0.0.5 @@ -38,7 +40,7 @@ require ( golang.org/x/net v0.30.0 golang.org/x/oauth2 v0.23.0 golang.org/x/sync v0.8.0 - golang.org/x/sys v0.26.0 + golang.org/x/sys v0.27.0 golang.org/x/term v0.25.0 golang.org/x/text v0.19.0 gopkg.in/yaml.v2 v2.4.0 @@ -52,8 +54,8 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.12.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 127d6ad86..f67089591 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,12 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4 h1:6KzM github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -165,6 +171,8 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -276,6 +284,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -289,8 +298,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= diff --git a/internal/ansi/spinner.go b/internal/ansi/spinner.go index 7b4830d22..07d8a1bb9 100644 --- a/internal/ansi/spinner.go +++ b/internal/ansi/spinner.go @@ -1,6 +1,9 @@ package ansi import ( + "fmt" + "github.com/charmbracelet/lipgloss" + "strings" "time" "github.com/briandowns/spinner" @@ -21,12 +24,45 @@ func Waiting(fn func() error) error { return loading("", "", "", fn) } +// Spinner simulates a spinner animation while executing a function. func Spinner(text string, fn func() error) error { - initialMsg := text + spinnerTextEllipsis + " " - doneMsg := initialMsg + spinnerTextDone + "\n" - failMsg := initialMsg + spinnerTextFailed + "\n" + // Spinner frames + frames := []string{"πŸŒ‘", "πŸŒ’", "πŸŒ“", "πŸŒ”", "πŸŒ•", "πŸŒ–", "πŸŒ—", "🌘"} - return loading(initialMsg, doneMsg, failMsg, fn) + // Styles + spinnerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("45")) // Cyan + textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) // Gray + successStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) // Green + failStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("124")) // Red + + // Messages + initialMsg := textStyle.Render(text) + doneMsg := successStyle.Render("βœ” Completed" + strings.Repeat("\t", 50)) + failMsg := failStyle.Render("βœ– Failed" + strings.Repeat("\t", 50)) + + // Print initial message + fmt.Print(initialMsg) + + // Start spinner + done := make(chan error) + go func() { done <- fn() }() + + for i := 0; ; i++ { + select { + case err := <-done: // Function completed + if err != nil { + fmt.Println("\r" + failMsg) + return err + } + fmt.Println("\r" + doneMsg) + return nil + default: + // Update spinner + frame := spinnerStyle.Render(frames[i%len(frames)]) + fmt.Printf("\r%s %s", frame, initialMsg) + time.Sleep(150 * time.Millisecond) + } + } } func loading(initialMsg, doneMsg, failMsg string, fn func() error) error { diff --git a/internal/cli/login.go b/internal/cli/login.go index e1824ab4c..d99d91e83 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -3,17 +3,16 @@ package cli import ( "context" "fmt" - "net/http" - "strings" - - "github.com/pkg/browser" - "github.com/spf13/cobra" - "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth" "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/keyring" "github.com/auth0/auth0-cli/internal/prompt" + "github.com/charmbracelet/lipgloss" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "net/http" + "strings" ) var ( @@ -137,19 +136,45 @@ func loginCmd(cli *cli) *cobra.Command { determine if it's LoginAsUser or LoginAsMachine */ if !shouldLoginAsUser && !shouldLoginAsMachine { - cli.renderer.Output( + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Align(lipgloss.Center). + Foreground(lipgloss.Color("159")). + Background(lipgloss.Color("57")). + AlignHorizontal(30) + + bulletPointStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + SetString("β€’ ") + + highlightBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")).Padding(1).Margin(1) + + // Compose the message parts with styling + title := titleStyle.Render("\t\t\t\t\t\tβœͺ Welcome to the Auth0 CLI πŸš€\t\t\t\t\t\t") + requirement := bulletPointStyle.String() + "An Auth0 tenant is required to operate this CLI." + signupLink := fmt.Sprintf("%s%s", bulletPointStyle.String(), "To create one, visit: https://auth0.com/signup.") + authOptions := fmt.Sprintf("%s%s", bulletPointStyle.String(), "You may authenticate to your tenant either as a user with \n "+ + " personal credentials or as a machine via client credentials.") + learnMore := fmt.Sprintf("%s%s", bulletPointStyle.String(), "For more information about authenticating the CLI to your tenant, "+ + "\n visit the docs: https://auth0.github.io/auth0-cli/auth0_login.html") + + // Combine everything into a final formatted message + finalMessage := highlightBoxStyle.Render( fmt.Sprintf( - "%s\n\n%s\n%s\n\n%s\n%s\n%s\n%s\n\n", - ansi.Bold("βœͺ Welcome to the Auth0 CLI 🎊"), - "An Auth0 tenant is required to operate this CLI.", - "To create one, visit: https://auth0.com/signup.", - "You may authenticate to your tenant either as a user with personal", - "credentials or as a machine via client credentials. For more", - "information about authenticating the CLI to your tenant, visit", - "the docs: https://auth0.github.io/auth0-cli/auth0_login.html", + "%s\n\n%s\n%s\n\n%s\n%s", + title, + requirement, + signupLink, + authOptions, + learnMore, ), ) + cli.renderer.Output(finalMessage) + label := "How would you like to authenticate?" help := fmt.Sprintf( "%s\n%s\n", @@ -235,25 +260,24 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string, do return config.Tenant{}, fmt.Errorf("failed to get the device code: %w", err) } - message := fmt.Sprintf("\n%s\n\n", - "Verify "+ansi.Bold(state.UserCode)+" code in opened browser window to complete authentication.", - ) - cli.renderer.Output(message) + cli.renderer.Infof(fmt.Sprintf("%s", + "Verify "+ansi.Bold(ansi.Green(state.UserCode))+" code in opened browser window to complete authentication.\n", + )) if cli.noInput { - message = "Open the following URL in a browser: %s\n" - cli.renderer.Infof(message, ansi.Green(state.VerificationURI)) + cli.renderer.Infof("Open the following URL in a browser: " + state.VerificationURI + "\n") } else { - message = "%s to open the browser to log in or %s to quit..." - cli.renderer.Infof(message, ansi.Green("Press Enter"), ansi.Red("^C")) + cli.renderer.Infof("Enter to open browser and log in") + cli.renderer.Warnf("^C to quit") if _, err = fmt.Scanln(); err != nil { return config.Tenant{}, err } - + cli.renderer.ProgressBar() + cli.renderer.Success("Browser opened successfully!") if err = browser.OpenURL(state.VerificationURI); err != nil { - message = "Couldn't open the URL, please do it manually: %s." - cli.renderer.Warnf(message, state.VerificationURI) + message := "Couldn't open the URL, please do it manually: " + state.VerificationURI + "." + cli.renderer.Warnf(message) } } @@ -297,7 +321,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string, do cli.tracker.TrackFirstLogin(cli.Config.InstallID) if cli.Config.DefaultTenant != result.Domain { - message = fmt.Sprintf( + message := fmt.Sprintf( "Your default tenant is %s. Do you want to change it to %s?", cli.Config.DefaultTenant, result.Domain, diff --git a/internal/cli/users.go b/internal/cli/users.go index f55f47746..b51a05bf0 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -224,6 +224,28 @@ func searchUsersCmd(cli *cli) *cobra.Command { cli.renderer.UserSearch(foundUsers) + if cli.renderer.ID != "" { + a := &management.User{ID: &cli.renderer.ID} + + if err := ansi.Waiting(func() error { + var err error + a, err = cli.api.User.Read(cmd.Context(), cli.renderer.ID) + return err + }); err != nil { + return fmt.Errorf("failed to load user with ID %q: %w", cli.renderer.ID, err) + } + + // 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(cmd.Context(), auth0.StringValue(a.Connection)) + requireUsername := auth0.BoolValue(u) + cli.renderer.Format = "json" + cli.renderer.UserShow(a, requireUsername) + } + return nil }, } @@ -483,6 +505,7 @@ func showUserCmd(cli *cli) *cobra.Command { requireUsername := auth0.BoolValue(u) cli.renderer.UserShow(a, requireUsername) + return nil }, } @@ -887,7 +910,6 @@ func (c *cli) getConnReqUsername(ctx context.Context, s string) *bool { if err := json.Unmarshal([]byte(res), &opts); err != nil { fmt.Println(err) } - return opts.RequiresUsername } diff --git a/internal/display/display.go b/internal/display/display.go index 4c0a41e01..d909d11c7 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -1,10 +1,14 @@ package display import ( + "bytes" "encoding/csv" "encoding/json" "fmt" + "github.com/charmbracelet/lipgloss" + "github.com/manifoldco/promptui" "io" + "regexp" "strings" "time" @@ -22,6 +26,10 @@ const ( OutputFormatCSV OutputFormat = "csv" ) +var infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) // Green +var warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // Yellow +var successStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) // Green for success + type Renderer struct { Tenant string @@ -33,6 +41,8 @@ type Renderer struct { // Format indicates how the results are rendered. Default (empty) will write as table. Format OutputFormat + + ID string } type View interface { @@ -57,15 +67,53 @@ func (r *Renderer) Newline() { } func (r *Renderer) Infof(format string, a ...interface{}) { - fmt.Fprint(r.MessageWriter, ansi.Green(" β–Έ ")) + fmt.Fprint(r.MessageWriter, infoStyle.Render(" β–Έβ–Έ ")) fmt.Fprintf(r.MessageWriter, format+"\n", a...) } func (r *Renderer) Warnf(format string, a ...interface{}) { - fmt.Fprint(r.MessageWriter, ansi.Yellow(" β–Έ ")) + fmt.Fprint(r.MessageWriter, warningStyle.Render(" ⚠️ ")) + fmt.Fprintf(r.MessageWriter, format+"\n", a...) +} + +func (r *Renderer) Success(format string, a ...interface{}) { + fmt.Fprint(r.MessageWriter, successStyle.Render("βœ” ")) fmt.Fprintf(r.MessageWriter, format+"\n", a...) } +func (r *Renderer) ProgressBar() { + gradientStyle := func(progress int) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(fmt.Sprintf("%d", 42+progress/2))). + Width(60) + } + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("244")) // Light gray for percentage + + // Simulate progress + total := 100 + for i := 0; i <= total; i++ { + progress := strings.Repeat("βœͺ", i*60/total) + remaining := strings.Repeat(" ", 60-(i*60/total)) + + bar := gradientStyle(i).Render(progress + remaining) + percentage := labelStyle.Render(fmt.Sprintf("%3d%%", i)) + + // Clear the previous output to simulate updating the progress + if i > 0 { + fmt.Print("\033[1A\033[K") // Clear the previous line + //fmt.Print("\033[1A\033[K") // Clear the previous bar + //fmt.Print("\033[1A\033[K") // Clear the previous label + } + + fmt.Printf("%s %s\n", bar, percentage) + + time.Sleep(20 * time.Millisecond) // Simulate work + } + +} + func (r *Renderer) Errorf(format string, a ...interface{}) { fmt.Fprint(r.MessageWriter, ansi.BrightRed(" β–Έ ")) fmt.Fprintf(r.MessageWriter, format+"\n", a...) @@ -122,10 +170,60 @@ func (r *Renderer) Results(data []View) { for _, d := range data { rows = append(rows, d.AsTableRow()) } - writeTable(r.ResultWriter, data[0].AsTableHeader(), rows) + + buffer := &bytes.Buffer{} + writeTable(buffer, data[0].AsTableHeader(), rows) + + if len(data) < 25 { + // Split the rendered table into rows + rows := bytes.Split(buffer.Bytes(), []byte("\n")) + + // Convert rows to a list of strings and remove empty rows + var formattedRows []string + for _, row := range rows { + if len(row) > 0 { + formattedRows = append(formattedRows, string(row)) + } + } + + // Use the rows as prompt items + prompt := promptui.Select{ + Label: "Select a User", + Items: formattedRows[1:], // Skip the header row for selection + } + + // Run the prompt + _, result, err := prompt.Run() + if err != nil { + fmt.Printf("Prompt failed: %v\n", err) + return + } + + r.ID, err = fetchId(result) + if err != nil { + return + } + } else { + r.ResultWriter = buffer + } + } } +func fetchId(inputString string) (string, error) { + regex := regexp.MustCompile(`(sms|auth0|email)\|[a-zA-Z0-9]+`) + + // Find the first match + match := regex.FindString(inputString) + + // Check if a match was found + if match == "" { + return "", fmt.Errorf("no valid ID found in the input string") + } + + return match, nil +} + func (r *Renderer) Result(data View) { switch r.Format { case OutputFormatJSON: @@ -141,7 +239,8 @@ func (r *Renderer) Result(data View) { v := pair[1] kvs = append(kvs, []string{k, v}) } - writeTable(r.ResultWriter, nil, kvs) + buffer := &bytes.Buffer{} + writeTable(buffer, nil, kvs) } } } @@ -211,10 +310,10 @@ func fprintfStr(w io.Writer, fmtStr string, argsStr ...string) { fmt.Fprintf(w, fmtStr, args...) } -func writeTable(w io.Writer, header []string, data [][]string) { - table := tablewriter.NewWriter(w) - table.SetHeader(header) +func writeTable(buffer *bytes.Buffer, header []string, data [][]string) { + table := tablewriter.NewWriter(buffer) + table.SetHeader(header) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) @@ -229,6 +328,7 @@ func writeTable(w io.Writer, header []string, data [][]string) { table.Append(v) } table.Render() + } func writeCSV(w io.Writer, header []string, data [][]string) error { diff --git a/internal/display/users.go b/internal/display/users.go index 6a0349424..fba1334ef 100644 --- a/internal/display/users.go +++ b/internal/display/users.go @@ -33,6 +33,7 @@ func (v *userView) AsTableHeader() []string { "UserID", "Email", "Connection", + "Name", } } @@ -49,6 +50,7 @@ func (v *userView) AsTableRow() []string { ansi.Faint(v.UserID), v.Email, v.Connection, + v.Username, } } @@ -71,6 +73,7 @@ func (v *userView) KeyValues() [][]string { {"ID", ansi.Faint(v.UserID)}, {"EMAIL", v.Email}, {"CONNECTION", v.Connection}, + {"USERNAME", v.Username}, } }