diff --git a/.golangci.yaml b/.golangci.yaml index 5f474602b2..84a213ea17 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -131,7 +131,8 @@ linters-settings: - trialer nestif: - min-complexity: 4 # Min complexity of if statements (def 5, goal 4) + # goal: 10 + min-complexity: 20 revive: # see https://github.com/mgechev/revive#available-rules for details. @@ -237,6 +238,7 @@ linters: # create a good culture around cognitive complexity. # - gocyclo - gocognit + - nestif - goimports - gomodguard - gosec diff --git a/cli/login.go b/cli/login.go index 3fe871ad84..a686037631 100644 --- a/cli/login.go +++ b/cli/login.go @@ -37,6 +37,95 @@ func init() { browser.Stdout = io.Discard } +func promptFirstUsername(inv *clibase.Invocation) (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", xerrors.Errorf("get current user: %w", err) + } + username, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "What " + cliui.DefaultStyles.Field.Render("username") + " would you like?", + Default: currentUser.Username, + }) + if errors.Is(err, cliui.Canceled) { + return "", nil + } + if err != nil { + return "", err + } + + return username, nil +} + +func promptFirstPassword(inv *clibase.Invocation) (string, error) { +retry: + password, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enter a " + cliui.DefaultStyles.Field.Render("password") + ":", + Secret: true, + Validate: func(s string) error { + return userpassword.Validate(s) + }, + }) + if err != nil { + return "", xerrors.Errorf("specify password prompt: %w", err) + } + confirm, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm " + cliui.DefaultStyles.Field.Render("password") + ":", + Secret: true, + Validate: cliui.ValidateNotEmpty, + }) + if err != nil { + return "", xerrors.Errorf("confirm password prompt: %w", err) + } + + if confirm != password { + _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Passwords do not match")) + goto retry + } + + return password, nil +} + +func (r *RootCmd) loginWithPassword( + inv *clibase.Invocation, + client *codersdk.Client, + email, password string, +) error { + resp, err := client.LoginWithPassword(inv.Context(), codersdk.LoginWithPasswordRequest{ + Email: email, + Password: password, + }) + if err != nil { + return xerrors.Errorf("login with password: %w", err) + } + + sessionToken := resp.SessionToken + config := r.createConfig() + err = config.Session().Write(sessionToken) + if err != nil { + return xerrors.Errorf("write session token: %w", err) + } + + client.SetSessionToken(sessionToken) + + // Nice side-effect: validates the token. + u, err := client.User(inv.Context(), "me") + if err != nil { + return xerrors.Errorf("get user: %w", err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + cliui.DefaultStyles.Paragraph.Render( + fmt.Sprintf( + "Welcome to Coder, %s! You're authenticated.", + cliui.DefaultStyles.Keyword.Render(u.Username), + ), + )+"\n", + ) + + return nil +} + func (r *RootCmd) login() *clibase.Cmd { const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL" @@ -91,41 +180,30 @@ func (r *RootCmd) login() *clibase.Cmd { _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Warn.Render(err.Error())) } - hasInitialUser, err := client.HasFirstUser(ctx) + hasFirstUser, err := client.HasFirstUser(ctx) if err != nil { return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err) } - if !hasInitialUser { + if !hasFirstUser { _, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n") if username == "" { if !isTTY(inv) { return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") } + _, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Would you like to create the first user?", Default: cliui.ConfirmYes, IsConfirm: true, }) - if errors.Is(err, cliui.Canceled) { - return nil - } if err != nil { return err } - currentUser, err := user.Current() + + username, err = promptFirstUsername(inv) if err != nil { - return xerrors.Errorf("get current user: %w", err) - } - username, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "What " + cliui.DefaultStyles.Field.Render("username") + " would you like?", - Default: currentUser.Username, - }) - if errors.Is(err, cliui.Canceled) { - return nil - } - if err != nil { - return xerrors.Errorf("pick username prompt: %w", err) + return err } } @@ -141,37 +219,14 @@ func (r *RootCmd) login() *clibase.Cmd { }, }) if err != nil { - return xerrors.Errorf("specify email prompt: %w", err) + return err } } if password == "" { - var matching bool - - for !matching { - password, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Enter a " + cliui.DefaultStyles.Field.Render("password") + ":", - Secret: true, - Validate: func(s string) error { - return userpassword.Validate(s) - }, - }) - if err != nil { - return xerrors.Errorf("specify password prompt: %w", err) - } - confirm, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm " + cliui.DefaultStyles.Field.Render("password") + ":", - Secret: true, - Validate: cliui.ValidateNotEmpty, - }) - if err != nil { - return xerrors.Errorf("confirm password prompt: %w", err) - } - - matching = confirm == password - if !matching { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Passwords do not match")) - } + password, err = promptFirstPassword(inv) + if err != nil { + return err } } @@ -193,29 +248,19 @@ func (r *RootCmd) login() *clibase.Cmd { if err != nil { return xerrors.Errorf("create initial user: %w", err) } - resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ - Email: email, - Password: password, - }) + + err := r.loginWithPassword(inv, client, email, password) if err != nil { - return xerrors.Errorf("login with password: %w", err) + return err } - sessionToken := resp.SessionToken - config := r.createConfig() - err = config.Session().Write(sessionToken) - if err != nil { - return xerrors.Errorf("write session token: %w", err) - } - err = config.URL().Write(serverURL.String()) + err = r.createConfig().URL().Write(serverURL.String()) if err != nil { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, - cliui.DefaultStyles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.DefaultStyles.Keyword.Render(username)))+"\n") - - _, _ = fmt.Fprintf(inv.Stdout, + _, _ = fmt.Fprintf( + inv.Stdout, cliui.DefaultStyles.Paragraph.Render("Get started by creating a template: "+cliui.DefaultStyles.Code.Render("coder templates init"))+"\n") return nil } diff --git a/cli/login_test.go b/cli/login_test.go index 0837c1e89b..910f58ea1f 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -97,16 +97,15 @@ func TestLogin(t *testing.T) { t.Run("InitialUserFlags", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - doneChan := make(chan struct{}) - root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "SomeSecurePassword!", "--first-user-trial") - pty := ptytest.New(t).Attach(root) - go func() { - defer close(doneChan) - err := root.Run() - assert.NoError(t, err) - }() + 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", + ) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) pty.ExpectMatch("Welcome to Coder") - <-doneChan + w.RequireSuccess() }) t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) { diff --git a/site/site.go b/site/site.go index 4619bd5b04..60113e0c71 100644 --- a/site/site.go +++ b/site/site.go @@ -279,10 +279,15 @@ func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, req return false } +func execTmpl(tmpl *template.Template, state htmlState) ([]byte, error) { + var buf bytes.Buffer + err := tmpl.Execute(&buf, state) + return buf.Bytes(), err +} + // renderWithState will render the file using the given nonce if the file exists // as a template. If it does not, it will return an error. func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) { - var buf bytes.Buffer if filePath == "" { filePath = "index.html" } @@ -307,96 +312,94 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht RedirectToLogin: false, SessionTokenFunc: nil, }) - if ok && apiKey != nil && actor != nil { - ctx := dbauthz.As(r.Context(), actor.Actor) + if !ok || apiKey == nil || actor == nil { + return execTmpl(tmpl, state) + } - var eg errgroup.Group - var user database.User - orgIDs := []uuid.UUID{} - eg.Go(func() error { - var err error - user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID) - return err - }) - eg.Go(func() error { - memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID}) - if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 { - return nil + ctx := dbauthz.As(r.Context(), actor.Actor) + + var eg errgroup.Group + var user database.User + orgIDs := []uuid.UUID{} + eg.Go(func() error { + var err error + user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID) + return err + }) + eg.Go(func() error { + memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID}) + if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 { + return nil + } + if err != nil { + return nil + } + orgIDs = memberIDs[0].OrganizationIDs + return err + }) + err := eg.Wait() + if err == nil { + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + user, err := json.Marshal(db2sdk.User(user, orgIDs)) + if err == nil { + state.User = html.EscapeString(string(user)) } - if err != nil { - return nil - } - orgIDs = memberIDs[0].OrganizationIDs - return err - }) - err := eg.Wait() - if err == nil { - var wg sync.WaitGroup + }() + entitlements := h.Entitlements.Load() + if entitlements != nil { wg.Add(1) go func() { defer wg.Done() - user, err := json.Marshal(db2sdk.User(user, orgIDs)) + entitlements, err := json.Marshal(entitlements) if err == nil { - state.User = html.EscapeString(string(user)) + state.Entitlements = html.EscapeString(string(entitlements)) } }() - entitlements := h.Entitlements.Load() - if entitlements != nil { - wg.Add(1) - go func() { - defer wg.Done() - entitlements, err := json.Marshal(entitlements) - if err == nil { - state.Entitlements = html.EscapeString(string(entitlements)) - } - }() - } - if h.AppearanceFetcher != nil { - wg.Add(1) - go func() { - defer wg.Done() - cfg, err := h.AppearanceFetcher(ctx) - if err == nil { - appearance, err := json.Marshal(cfg) - if err == nil { - state.Appearance = html.EscapeString(string(appearance)) - } - } - }() - } - if h.RegionsFetcher != nil { - wg.Add(1) - go func() { - defer wg.Done() - regions, err := h.RegionsFetcher(ctx) - if err == nil { - regions, err := json.Marshal(regions) - if err == nil { - state.Regions = html.EscapeString(string(regions)) - } - } - }() - } - experiments := h.Experiments.Load() - if experiments != nil { - wg.Add(1) - go func() { - defer wg.Done() - experiments, err := json.Marshal(experiments) - if err == nil { - state.Experiments = html.EscapeString(string(experiments)) - } - }() - } - wg.Wait() } + if h.AppearanceFetcher != nil { + wg.Add(1) + go func() { + defer wg.Done() + cfg, err := h.AppearanceFetcher(ctx) + if err == nil { + appearance, err := json.Marshal(cfg) + if err == nil { + state.Appearance = html.EscapeString(string(appearance)) + } + } + }() + } + if h.RegionsFetcher != nil { + wg.Add(1) + go func() { + defer wg.Done() + regions, err := h.RegionsFetcher(ctx) + if err == nil { + regions, err := json.Marshal(regions) + if err == nil { + state.Regions = html.EscapeString(string(regions)) + } + } + }() + } + experiments := h.Experiments.Load() + if experiments != nil { + wg.Add(1) + go func() { + defer wg.Done() + experiments, err := json.Marshal(experiments) + if err == nil { + state.Experiments = html.EscapeString(string(experiments)) + } + }() + } + wg.Wait() } - err := tmpl.Execute(&buf, state) - if err != nil { - return nil, err - } - return buf.Bytes(), nil + return execTmpl(tmpl, state) } // noopResponseWriter is a response writer that does nothing.