Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions internal/cli/setup/prompt_auth_login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package setup

import (
"errors"
"testing"

"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
)

func TestStepGrantSync_NoGrants_AuthLoginSuccess(t *testing.T) {
orig := promptAuthLoginFn
t.Cleanup(func() { promptAuthLoginFn = orig })

promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) {
return &domain.Grant{
ID: "grant-new",
Email: "user@example.com",
Provider: domain.ProviderGoogle,
}, nil
}

status := &SetupStatus{HasGrants: false}

// stepGrantSync requires real keyring/config access which we can't easily
// mock. Instead, test the promptAuthLoginFn integration directly by
// simulating the zero-grants branch inline.
grant, err := promptAuthLoginFn(nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if grant == nil {
t.Fatal("expected non-nil grant")
}
if grant.Email != "user@example.com" {
t.Fatalf("grant email = %q, want %q", grant.Email, "user@example.com")
}

// Simulate what stepGrantSync does after successful login.
status.HasGrants = true
if !status.HasGrants {
t.Fatal("expected HasGrants to be true after successful auth login")
}
}

func TestStepGrantSync_NoGrants_AuthLoginDeclined(t *testing.T) {
orig := promptAuthLoginFn
t.Cleanup(func() { promptAuthLoginFn = orig })

promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) {
return nil, nil
}

grant, err := promptAuthLoginFn(nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if grant != nil {
t.Fatal("expected nil grant when user declines")
}

// Simulate stepGrantSync: status.HasGrants should remain false.
status := &SetupStatus{HasGrants: false}
if status.HasGrants {
t.Fatal("expected HasGrants to remain false when user declines")
}
}

func TestStepGrantSync_NoGrants_AuthLoginError(t *testing.T) {
orig := promptAuthLoginFn
t.Cleanup(func() { promptAuthLoginFn = orig })

promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) {
return nil, errors.New("oauth callback timeout")
}

grant, err := promptAuthLoginFn(nil, nil)
if err == nil {
t.Fatal("expected error from auth login")
}
if err.Error() != "oauth callback timeout" {
t.Fatalf("error = %q, want %q", err.Error(), "oauth callback timeout")
}
if grant != nil {
t.Fatal("expected nil grant on error")
}

// Simulate stepGrantSync: status.HasGrants should remain false.
status := &SetupStatus{HasGrants: false}
if status.HasGrants {
t.Fatal("expected HasGrants to remain false on auth error")
}
}

func TestPromptAuthLoginFn_DefaultPointsToRealFunction(t *testing.T) {
if promptAuthLoginFn == nil {
t.Fatal("promptAuthLoginFn should not be nil")
}
}
19 changes: 17 additions & 2 deletions internal/cli/setup/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var (
getSetupStatusFn = GetSetupStatus
stepGrantSyncFn = stepGrantSync
printCompleteFn = printComplete
promptAuthLoginFn = promptAuthLogin
)

func runWizard(opts wizardOpts) error {
Expand Down Expand Up @@ -441,8 +442,22 @@ func stepGrantSync(status *SetupStatus) {
if len(result.ValidGrants) == 0 {
_, _ = common.Dim.Println(" No existing email accounts found")
fmt.Println()
fmt.Println(" To authenticate with your email provider:")
fmt.Println(" nylas auth login")

grant, err := promptAuthLoginFn(configStore, grantStore)
if err != nil {
_, _ = common.Yellow.Printf(" Could not authenticate: %v\n", err)
fmt.Println()
fmt.Println(" To authenticate later:")
fmt.Println(" nylas auth login")
return
}
if grant != nil {
_, _ = common.Green.Printf(" ✓ Authenticated as %s\n", grant.Email)
status.HasGrants = true
} else {
fmt.Println(" To connect an email account later:")
fmt.Println(" nylas auth login")
}
return
}

Expand Down
55 changes: 55 additions & 0 deletions internal/cli/setup/wizard_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import (
"fmt"
"strings"

"github.com/nylas/cli/internal/adapters/browser"
"github.com/nylas/cli/internal/adapters/config"
"github.com/nylas/cli/internal/adapters/keyring"
nylasadapter "github.com/nylas/cli/internal/adapters/nylas"
"github.com/nylas/cli/internal/adapters/oauth"
authapp "github.com/nylas/cli/internal/app/auth"
dashboardapp "github.com/nylas/cli/internal/app/dashboard"
"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/cli/dashboard"
Expand Down Expand Up @@ -207,3 +211,54 @@ func sanitizeAPIKey(key string) string {
}
return strings.TrimSpace(result.String())
}

// promptAuthLogin asks the user to connect an email account via OAuth.
// Returns nil grant if the user declines.
func promptAuthLogin(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) {
yes, err := common.ConfirmPrompt("Connect a Google or Microsoft email account now?", true)
if err != nil || !yes {
return nil, nil
}

provider, err := common.Select("Which provider?", []common.SelectOption[domain.Provider]{
{Label: "Google (Gmail)", Value: domain.ProviderGoogle},
{Label: "Microsoft (Outlook)", Value: domain.ProviderMicrosoft},
})
if err != nil {
return nil, err
}

cfg, _ := configStore.Load()
if cfg == nil {
cfg = domain.DefaultConfig()
}

secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir())
if err != nil {
return nil, fmt.Errorf("could not access keyring: %w", err)
}

client := nylasadapter.NewHTTPClient()
if cfg.API != nil && cfg.API.BaseURL != "" {
client.SetBaseURL(cfg.API.BaseURL)
} else {
client.SetRegion(cfg.Region)
}
apiKey, _ := secretStore.Get(ports.KeyAPIKey)
clientID, _ := secretStore.Get(ports.KeyClientID)
clientSecret, _ := secretStore.Get(ports.KeyClientSecret)
client.SetCredentials(clientID, clientSecret, apiKey)

oauthServer := oauth.NewCallbackServer(cfg.CallbackPort)
b := browser.NewDefaultBrowser()
authSvc := authapp.NewService(client, grantStore, configStore, oauthServer, b)

fmt.Println()
fmt.Println(" Opening browser for authentication...")
fmt.Println(" Complete the sign-in process in your browser.")

ctx, cancel := common.CreateLongContext()
defer cancel()

return authSvc.Login(ctx, provider)
}
Loading