From 1da767bc111939bdfb239877dd3a37c83fca5c52 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 26 Mar 2026 00:07:21 -0400 Subject: [PATCH 1/5] [TW-4719] feat(dashboard): add org switching and session-aware org defaulting - Add GET /sessions/current and POST /sessions/switch-org to dashboard account client - Add doGet/doGetRaw methods to HTTP client for GET request support - Add GetCurrentSession, SwitchOrg, and SyncSessionOrg to auth service - Call SyncSessionOrg after login/SSO to store the server's actual active org - Add 'nylas dashboard orgs list' command to list all user organizations - Add 'nylas dashboard orgs switch' command to switch the active org - Enhance status command to show org name and total org count - Clear active app on org switch to prevent stale cross-org state - Add domain types for session and switch-org API responses - Add 16 unit tests for new auth service methods --- internal/adapters/dashboard/account_client.go | 23 + internal/adapters/dashboard/http.go | 63 +++ internal/adapters/dashboard/mock.go | 14 + internal/app/dashboard/auth_service.go | 54 +++ internal/app/dashboard/auth_service_test.go | 432 ++++++++++++++++++ internal/cli/dashboard/dashboard.go | 4 +- internal/cli/dashboard/exports.go | 11 + internal/cli/dashboard/helpers.go | 28 +- internal/cli/dashboard/login.go | 5 + internal/cli/dashboard/orgs.go | 66 +++ internal/cli/dashboard/sso.go | 5 + internal/cli/dashboard/status.go | 26 +- internal/cli/dashboard/switch_org.go | 136 ++++++ internal/domain/dashboard.go | 28 ++ internal/ports/dashboard.go | 6 + 15 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 internal/app/dashboard/auth_service_test.go create mode 100644 internal/cli/dashboard/orgs.go create mode 100644 internal/cli/dashboard/switch_org.go diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go index 0eeeb47..db0f02a 100644 --- a/internal/adapters/dashboard/account_client.go +++ b/internal/adapters/dashboard/account_client.go @@ -196,6 +196,29 @@ func (c *AccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) return &result, nil } +// GetCurrentSession returns the current session info including the active org. +func (c *AccountClient) GetCurrentSession(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) { + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardSessionResponse + if err := c.doGet(ctx, "/sessions/current", headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to get current session: %w", err) + } + return &result, nil +} + +// SwitchOrg switches the session to a different organization. +func (c *AccountClient) SwitchOrg(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) { + body := map[string]any{ + "orgPublicId": orgPublicID, + } + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardSwitchOrgResponse + if err := c.doPost(ctx, "/sessions/switch-org", body, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to switch organization: %w", err) + } + return &result, nil +} + // bearerHeaders creates the Authorization and X-Nylas-Org headers. func bearerHeaders(userToken, orgToken string) map[string]string { h := map[string]string{ diff --git a/internal/adapters/dashboard/http.go b/internal/adapters/dashboard/http.go index ae9917d..e386b89 100644 --- a/internal/adapters/dashboard/http.go +++ b/internal/adapters/dashboard/http.go @@ -126,6 +126,69 @@ func unwrapEnvelope(body []byte) ([]byte, error) { return envelope.Data, nil } +// doGetRaw sends a GET request and returns the raw (envelope-unwrapped) response body. +func (c *AccountClient) doGetRaw(ctx context.Context, path string, extraHeaders map[string]string, accessToken string) ([]byte, error) { + fullURL := c.baseURL + path + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add DPoP proof + proof, err := c.dpop.GenerateProof(http.MethodGet, fullURL, accessToken) + if err != nil { + return nil, err + } + req.Header.Set("DPoP", proof) + + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + return nil, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseErrorResponse(resp.StatusCode, respBody) + } + + data, unwrapErr := unwrapEnvelope(respBody) + if unwrapErr != nil { + return nil, unwrapErr + } + + return data, nil +} + +// doGet sends a GET request and decodes the envelope-unwrapped response into result. +func (c *AccountClient) doGet(ctx context.Context, path string, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doGetRaw(ctx, path, extraHeaders, accessToken) + if err != nil { + return err + } + + if result != nil { + if err := json.Unmarshal(raw, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} + // DashboardAPIError represents an error from the dashboard API. // It carries the status code and server message for debugging. type DashboardAPIError struct { diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go index f3721ef..af24b8a 100644 --- a/internal/adapters/dashboard/mock.go +++ b/internal/adapters/dashboard/mock.go @@ -17,6 +17,8 @@ type MockAccountClient struct { LogoutFn func(ctx context.Context, userToken, orgToken string) error SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) + GetCurrentSessionFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) + SwitchOrgFn func(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) } func (m *MockAccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { @@ -46,6 +48,18 @@ func (m *MockAccountClient) SSOStart(ctx context.Context, loginType, mode string func (m *MockAccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { return m.SSOPollFn(ctx, flowID, orgPublicID) } +func (m *MockAccountClient) GetCurrentSession(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) { + if m.GetCurrentSessionFn != nil { + return m.GetCurrentSessionFn(ctx, userToken, orgToken) + } + return &domain.DashboardSessionResponse{}, nil +} +func (m *MockAccountClient) SwitchOrg(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) { + if m.SwitchOrgFn != nil { + return m.SwitchOrgFn(ctx, orgPublicID, userToken, orgToken) + } + return &domain.DashboardSwitchOrgResponse{}, nil +} // MockGatewayClient is a test mock for ports.DashboardGatewayClient. type MockGatewayClient struct { diff --git a/internal/app/dashboard/auth_service.go b/internal/app/dashboard/auth_service.go index 631785b..84b5c1a 100644 --- a/internal/app/dashboard/auth_service.go +++ b/internal/app/dashboard/auth_service.go @@ -163,6 +163,60 @@ func (s *AuthService) GetStatus() Status { return st } +// GetCurrentSession returns the current session info, including the active org and all orgs. +func (s *AuthService) GetCurrentSession(ctx context.Context) (*domain.DashboardSessionResponse, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + return s.account.GetCurrentSession(ctx, userToken, orgToken) +} + +// SwitchOrg switches the active organization and stores the new org token. +func (s *AuthService) SwitchOrg(ctx context.Context, orgPublicID string) (*domain.DashboardSwitchOrgResponse, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + resp, err := s.account.SwitchOrg(ctx, orgPublicID, userToken, orgToken) + if err != nil { + return nil, err + } + + // Store the new org token and org ID + if resp.OrgToken != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgToken, resp.OrgToken); err != nil { + return nil, fmt.Errorf("failed to store org token: %w", err) + } + } + if resp.Org.PublicID != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgPublicID, resp.Org.PublicID); err != nil { + return nil, fmt.Errorf("failed to store org ID: %w", err) + } + } + + // Clear active app since it belongs to the previous org + _ = s.secrets.Delete(ports.KeyDashboardAppID) + _ = s.secrets.Delete(ports.KeyDashboardAppRegion) + + return resp, nil +} + +// SyncSessionOrg fetches the current session from the server and stores the +// actual active org. Call this after login to ensure the stored org matches +// the server-side default rather than guessing from the organizations list. +func (s *AuthService) SyncSessionOrg(ctx context.Context) error { + session, err := s.GetCurrentSession(ctx) + if err != nil { + return nil // best effort — login already succeeded + } + if session.CurrentOrg != "" { + _ = s.secrets.Set(ports.KeyDashboardOrgPublicID, session.CurrentOrg) + } + return nil +} + // storeTokens persists auth tokens and user/org identifiers. func (s *AuthService) storeTokens(resp *domain.DashboardAuthResponse) error { if err := s.secrets.Set(ports.KeyDashboardUserToken, resp.UserToken); err != nil { diff --git a/internal/app/dashboard/auth_service_test.go b/internal/app/dashboard/auth_service_test.go new file mode 100644 index 0000000..7d1d2a2 --- /dev/null +++ b/internal/app/dashboard/auth_service_test.go @@ -0,0 +1,432 @@ +package dashboard + +import ( + "context" + "errors" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dashboardadapter "github.com/nylas/cli/internal/adapters/dashboard" +) + +// memSecretStore is a simple in-memory implementation of ports.SecretStore for +// use in tests only. It never touches the OS keyring. +type memSecretStore struct { + data map[string]string +} + +func newMemSecretStore() *memSecretStore { + return &memSecretStore{data: make(map[string]string)} +} + +func (m *memSecretStore) Set(key, value string) error { + m.data[key] = value + return nil +} + +func (m *memSecretStore) Get(key string) (string, error) { + v, ok := m.data[key] + if !ok { + return "", nil + } + return v, nil +} + +func (m *memSecretStore) Delete(key string) error { + delete(m.data, key) + return nil +} + +func (m *memSecretStore) IsAvailable() bool { return true } + +func (m *memSecretStore) Name() string { return "mem" } + +// errSecretStore is a secret store that always returns an error on Set, +// used to test secret-persistence failure paths. +type errSecretStore struct { + memSecretStore + setErr error +} + +func newErrSecretStore(setErr error) *errSecretStore { + return &errSecretStore{ + memSecretStore: memSecretStore{data: make(map[string]string)}, + setErr: setErr, + } +} + +func (e *errSecretStore) Set(key, value string) error { + return e.setErr +} + +// seedTokens pre-populates userToken (and optionally orgToken) so that +// loadTokens() succeeds without going through a full Login flow. +func seedTokens(s ports.SecretStore, userToken, orgToken string) { + _ = s.Set(ports.KeyDashboardUserToken, userToken) + if orgToken != "" { + _ = s.Set(ports.KeyDashboardOrgToken, orgToken) + } +} + +// --------------------------------------------------------------------------- +// TestAuthService_GetCurrentSession +// --------------------------------------------------------------------------- + +func TestAuthService_GetCurrentSession(t *testing.T) { + t.Parallel() + + sessionResp := &domain.DashboardSessionResponse{ + User: domain.DashboardUser{PublicID: "user-1"}, + CurrentOrg: "org-1", + } + + tests := []struct { + name string + seedUser string + seedOrg string + mockFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) + wantErr bool + wantErrIs error + wantSession *domain.DashboardSessionResponse + // verify the tokens forwarded to the mock + wantUserToken string + wantOrgToken string + }{ + { + name: "passes stored tokens to account client", + seedUser: "ut-abc", + seedOrg: "ot-xyz", + wantUserToken: "ut-abc", + wantOrgToken: "ot-xyz", + mockFn: func(_ context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) { + return sessionResp, nil + }, + wantSession: sessionResp, + }, + { + name: "works without org token", + seedUser: "ut-abc", + wantUserToken: "ut-abc", + wantOrgToken: "", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return sessionResp, nil + }, + wantSession: sessionResp, + }, + { + name: "returns ErrDashboardNotLoggedIn when no user token stored", + wantErr: true, + wantErrIs: domain.ErrDashboardNotLoggedIn, + }, + { + name: "propagates error from account client", + seedUser: "ut-abc", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return nil, errors.New("upstream error") + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + seedTokens(store, tt.seedUser, tt.seedOrg) + + // capture forwarded tokens + var gotUserToken, gotOrgToken string + mock := &dashboardadapter.MockAccountClient{ + GetCurrentSessionFn: func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) { + gotUserToken = userToken + gotOrgToken = orgToken + if tt.mockFn != nil { + return tt.mockFn(ctx, userToken, orgToken) + } + return &domain.DashboardSessionResponse{}, nil + }, + } + + svc := NewAuthService(mock, store) + got, err := svc.GetCurrentSession(context.Background()) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + assert.Nil(t, got) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantSession, got) + + if tt.seedUser != "" { + assert.Equal(t, tt.wantUserToken, gotUserToken) + assert.Equal(t, tt.wantOrgToken, gotOrgToken) + } + }) + } +} + +// --------------------------------------------------------------------------- +// TestAuthService_SwitchOrg +// --------------------------------------------------------------------------- + +func TestAuthService_SwitchOrg(t *testing.T) { + t.Parallel() + + switchResp := &domain.DashboardSwitchOrgResponse{ + OrgToken: "new-org-token", + Org: domain.DashboardSwitchOrgOrg{PublicID: "org-new", Name: "New Corp"}, + } + + tests := []struct { + name string + seedUser string + seedOrg string + orgPublicID string + mockFn func(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) + setupStore func(s *memSecretStore) + wantErr bool + wantErrIs error + wantOrgToken string + wantOrgPublicID string + wantAppIDGone bool + wantResp *domain.DashboardSwitchOrgResponse + }{ + { + name: "stores new org token and clears active app", + seedUser: "ut-abc", + seedOrg: "ot-old", + orgPublicID: "org-new", + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { return switchResp, nil }, + wantOrgToken: "new-org-token", + wantOrgPublicID: "org-new", + wantAppIDGone: true, + wantResp: switchResp, + }, + { + name: "stores org public ID from response", + seedUser: "ut-abc", + orgPublicID: "org-new", + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { + return &domain.DashboardSwitchOrgResponse{ + OrgToken: "t1", + Org: domain.DashboardSwitchOrgOrg{PublicID: "org-stored"}, + }, nil + }, + wantOrgPublicID: "org-stored", + }, + { + name: "skips storing empty org token", + seedUser: "ut-abc", + orgPublicID: "org-new", + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { + return &domain.DashboardSwitchOrgResponse{ + OrgToken: "", + Org: domain.DashboardSwitchOrgOrg{PublicID: "org-stored"}, + }, nil + }, + wantOrgToken: "", + wantOrgPublicID: "org-stored", + }, + { + name: "returns ErrDashboardNotLoggedIn when no user token", + wantErr: true, + wantErrIs: domain.ErrDashboardNotLoggedIn, + }, + { + name: "propagates account client error", + seedUser: "ut-abc", + orgPublicID: "org-new", + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { + return nil, errors.New("network failure") + }, + wantErr: true, + }, + { + name: "pre-existing app ID is deleted after switch", + seedUser: "ut-abc", + orgPublicID: "org-new", + setupStore: func(s *memSecretStore) { + _ = s.Set(ports.KeyDashboardAppID, "app-old-123") + _ = s.Set(ports.KeyDashboardAppRegion, "us") + }, + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { return switchResp, nil }, + wantAppIDGone: true, + wantOrgToken: "new-org-token", + wantOrgPublicID: "org-new", + wantResp: switchResp, + }, + { + name: "forwards correct tokens to account client", + seedUser: "ut-user", + seedOrg: "ot-org", + orgPublicID: "org-target", + mockFn: func(_ context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) { + assert.Equal(t, "org-target", orgPublicID) + assert.Equal(t, "ut-user", userToken) + assert.Equal(t, "ot-org", orgToken) + return switchResp, nil + }, + wantOrgToken: "new-org-token", + wantOrgPublicID: "org-new", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + seedTokens(store, tt.seedUser, tt.seedOrg) + if tt.setupStore != nil { + tt.setupStore(store) + } + + mock := &dashboardadapter.MockAccountClient{ + SwitchOrgFn: tt.mockFn, + } + + svc := NewAuthService(mock, store) + resp, err := svc.SwitchOrg(context.Background(), tt.orgPublicID) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + assert.Nil(t, resp) + return + } + + require.NoError(t, err) + + if tt.wantResp != nil { + assert.Equal(t, tt.wantResp, resp) + } + + // Verify stored org token. + if tt.wantOrgToken != "" { + storedOrgToken, _ := store.Get(ports.KeyDashboardOrgToken) + assert.Equal(t, tt.wantOrgToken, storedOrgToken) + } + + // Verify stored org public ID. + if tt.wantOrgPublicID != "" { + storedOrgID, _ := store.Get(ports.KeyDashboardOrgPublicID) + assert.Equal(t, tt.wantOrgPublicID, storedOrgID) + } + + // Verify active app was cleared. + if tt.wantAppIDGone { + appID, _ := store.Get(ports.KeyDashboardAppID) + assert.Empty(t, appID, "app ID should be cleared after org switch") + appRegion, _ := store.Get(ports.KeyDashboardAppRegion) + assert.Empty(t, appRegion, "app region should be cleared after org switch") + } + }) + } +} + +// --------------------------------------------------------------------------- +// TestAuthService_SyncSessionOrg +// --------------------------------------------------------------------------- + +func TestAuthService_SyncSessionOrg(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seedUser string + seedOrg string + mockFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) + wantErr bool // SyncSessionOrg is best-effort; always returns nil + wantOrgPublicID string + wantNoOrgPublicID bool + }{ + { + name: "stores CurrentOrg.PublicID on success", + seedUser: "ut-abc", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return &domain.DashboardSessionResponse{ + CurrentOrg: "org-synced", + }, nil + }, + wantOrgPublicID: "org-synced", + }, + { + name: "returns nil when GetCurrentSession fails (best-effort)", + seedUser: "ut-abc", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return nil, errors.New("session fetch failed") + }, + // wantErr is false — SyncSessionOrg is best-effort. + wantNoOrgPublicID: true, + }, + { + name: "returns nil when not logged in (best-effort)", + // No seedUser means loadTokens returns ErrDashboardNotLoggedIn. + // SyncSessionOrg should still return nil. + wantNoOrgPublicID: true, + }, + { + name: "does not store empty CurrentOrg.PublicID", + seedUser: "ut-abc", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return &domain.DashboardSessionResponse{ + CurrentOrg: "", + }, nil + }, + wantNoOrgPublicID: true, + }, + { + name: "overwrites pre-existing org public ID with server value", + seedUser: "ut-abc", + seedOrg: "ot-xyz", + mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) { + return &domain.DashboardSessionResponse{ + CurrentOrg: "org-from-server", + }, nil + }, + wantOrgPublicID: "org-from-server", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + seedTokens(store, tt.seedUser, tt.seedOrg) + + mock := &dashboardadapter.MockAccountClient{ + GetCurrentSessionFn: tt.mockFn, + } + + svc := NewAuthService(mock, store) + err := svc.SyncSessionOrg(context.Background()) + + // SyncSessionOrg is always best-effort — it must never return an error. + require.NoError(t, err) + + stored, _ := store.Get(ports.KeyDashboardOrgPublicID) + if tt.wantOrgPublicID != "" { + assert.Equal(t, tt.wantOrgPublicID, stored) + } + if tt.wantNoOrgPublicID { + assert.Empty(t, stored) + } + }) + } +} diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go index 61054f7..3b42f55 100644 --- a/internal/cli/dashboard/dashboard.go +++ b/internal/cli/dashboard/dashboard.go @@ -20,7 +20,8 @@ Commands: logout Log out of the Nylas Dashboard status Show current dashboard authentication status refresh Refresh dashboard session tokens - apps Manage Nylas applications`, + apps Manage Nylas applications + orgs Manage organizations (list, switch)`, } cmd.AddCommand(newRegisterCmd()) @@ -30,6 +31,7 @@ Commands: cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newRefreshCmd()) cmd.AddCommand(newAppsCmd()) + cmd.AddCommand(newOrgsCmd()) return cmd } diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go index e212c67..87d0816 100644 --- a/internal/cli/dashboard/exports.go +++ b/internal/cli/dashboard/exports.go @@ -36,6 +36,17 @@ func GetActiveOrgID() (string, error) { return getActiveOrgID() } +// SyncSessionOrg syncs the active org from the server session (exported for setup wizard). +func SyncSessionOrg() error { + authSvc, _, err := createAuthService() + if err != nil { + return err + } + ctx, cancel := common.CreateContext() + defer cancel() + return authSvc.SyncSessionOrg(ctx) +} + // ReadLine prompts for a line of text input (exported for setup wizard). func ReadLine(prompt string) (string, error) { return common.InputPrompt(prompt, "") diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go index 64efea6..7e941ad 100644 --- a/internal/cli/dashboard/helpers.go +++ b/internal/cli/dashboard/helpers.go @@ -179,10 +179,34 @@ func selectOrg(orgs []domain.DashboardOrganization) string { } // printAuthSuccess prints the standard post-login success message. +// It reads the stored active org from the keyring (set by SyncSessionOrg) +// so it reflects the server's actual current org. func printAuthSuccess(auth *domain.DashboardAuthResponse) { _, _ = common.Green.Printf("✓ Authenticated as %s\n", auth.User.PublicID) - if len(auth.Organizations) > 0 { - fmt.Printf(" Organization: %s\n", auth.Organizations[0].PublicID) + + // Show the active org from keyring (most accurate after SyncSessionOrg) + orgID := "" + if _, secrets, err := createDPoPService(); err == nil { + orgID, _ = secrets.Get(ports.KeyDashboardOrgPublicID) + } + if orgID == "" && len(auth.Organizations) > 0 { + orgID = auth.Organizations[0].PublicID + } + + if orgID != "" { + // Find the org name if available + orgLabel := orgID + for _, org := range auth.Organizations { + if org.PublicID == orgID && org.Name != "" { + orgLabel = fmt.Sprintf("%s (%s)", org.Name, orgID) + break + } + } + fmt.Printf(" Organization: %s\n", orgLabel) + } + + if len(auth.Organizations) > 1 { + fmt.Printf(" Available orgs: %d (switch with: nylas dashboard orgs switch)\n", len(auth.Organizations)) } } diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go index d95b58a..5f39bc3 100644 --- a/internal/cli/dashboard/login.go +++ b/internal/cli/dashboard/login.go @@ -140,6 +140,11 @@ func runEmailLogin(userFlag, passFlag, orgPublicID string) error { _ = authSvc.SetActiveOrg(orgID) } + // Sync the actual active org from the server session + syncCtx, syncCancel := common.CreateContext() + defer syncCancel() + _ = authSvc.SyncSessionOrg(syncCtx) + printAuthSuccess(auth) return nil } diff --git a/internal/cli/dashboard/orgs.go b/internal/cli/dashboard/orgs.go new file mode 100644 index 0000000..3aaabb5 --- /dev/null +++ b/internal/cli/dashboard/orgs.go @@ -0,0 +1,66 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newOrgsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "orgs", + Short: "Manage organizations", + Long: `List and manage organizations you belong to.`, + } + + cmd.AddCommand(newOrgsListCmd()) + cmd.AddCommand(newSwitchOrgCmd()) + + return cmd +} + +func newOrgsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List organizations you belong to", + Example: ` nylas dashboard orgs list + nylas dashboard orgs list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + session, err := authSvc.GetCurrentSession(ctx) + if err != nil { + return wrapDashboardError(err) + } + + if len(session.Relations) == 0 { + fmt.Println("No organizations found.") + return nil + } + + rows := make([]orgRow, len(session.Relations)) + for i, rel := range session.Relations { + current := "" + if rel.OrgPublicID == session.CurrentOrg { + current = "✓" + } + rows[i] = orgRow{ + PublicID: rel.OrgPublicID, + Name: rel.OrgName, + Role: rel.Role, + Current: current, + } + } + + return common.WriteListWithColumns(cmd, rows, orgColumns) + }, + } +} diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index 88b77b3..ec5acd8 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -125,6 +125,11 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...s return wrapDashboardError(err) } + // Sync the actual active org from the server session + syncCtx, syncCancel := common.CreateContext() + defer syncCancel() + _ = authSvc.SyncSessionOrg(syncCtx) + printAuthSuccess(auth) return nil } diff --git a/internal/cli/dashboard/status.go b/internal/cli/dashboard/status.go index 3f3449f..5b6c8d6 100644 --- a/internal/cli/dashboard/status.go +++ b/internal/cli/dashboard/status.go @@ -31,8 +31,30 @@ func newStatusCmd() *cobra.Command { if status.UserID != "" { fmt.Printf(" User: %s\n", status.UserID) } - if status.OrgID != "" { - fmt.Printf(" Organization: %s\n", status.OrgID) + + // Try to get org details from server for richer display + orgLabel := status.OrgID + orgCount := 0 + ctx, cancel := common.CreateContext() + defer cancel() + if session, sErr := authSvc.GetCurrentSession(ctx); sErr == nil { + if session.CurrentOrg != "" { + orgLabel = session.CurrentOrg + // Find the org name from relations + for _, rel := range session.Relations { + if rel.OrgPublicID == session.CurrentOrg && rel.OrgName != "" { + orgLabel = formatOrgLabel(session.CurrentOrg, rel.OrgName) + break + } + } + } + orgCount = len(session.Relations) + } + if orgLabel != "" { + fmt.Printf(" Organization: %s\n", orgLabel) + } + if orgCount > 1 { + fmt.Printf(" Total orgs: %d (switch with: nylas dashboard orgs switch)\n", orgCount) } fmt.Printf(" Org token: %s\n", presentAbsent(status.HasOrgToken)) diff --git a/internal/cli/dashboard/switch_org.go b/internal/cli/dashboard/switch_org.go new file mode 100644 index 0000000..3bffd32 --- /dev/null +++ b/internal/cli/dashboard/switch_org.go @@ -0,0 +1,136 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func newSwitchOrgCmd() *cobra.Command { + var orgFlag string + + cmd := &cobra.Command{ + Use: "switch", + Short: "Switch the active organization", + Long: `Switch your dashboard session to a different organization. + +Lists all organizations you belong to and lets you select one, +or pass --org to switch directly.`, + Example: ` # Interactive — choose from your orgs + nylas dashboard orgs switch + + # Switch directly by org ID + nylas dashboard orgs switch --org org_abc123`, + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + // Get current session to list available orgs + var session *domain.DashboardSessionResponse + err = common.RunWithSpinner("Loading organizations...", func() error { + session, err = authSvc.GetCurrentSession(ctx) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if len(session.Relations) == 0 { + fmt.Println("No organizations found.") + return nil + } + + targetOrgID := orgFlag + if targetOrgID == "" { + targetOrgID, err = selectOrgFromSession(session) + if err != nil { + return wrapDashboardError(err) + } + } + + if targetOrgID == session.CurrentOrg { + _, _ = common.Green.Printf("✓ Already on organization: %s\n", formatSessionOrg(session, session.CurrentOrg)) + return nil + } + + // Switch org via API + ctx2, cancel2 := common.CreateContext() + defer cancel2() + + var resp *domain.DashboardSwitchOrgResponse + err = common.RunWithSpinner("Switching organization...", func() error { + resp, err = authSvc.SwitchOrg(ctx2, targetOrgID) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Printf("✓ Switched to organization: %s\n", formatOrgLabel(resp.Org.PublicID, resp.Org.Name)) + return nil + }, + } + + cmd.Flags().StringVar(&orgFlag, "org", "", "Organization public ID to switch to") + + return cmd +} + +// selectOrgFromSession prompts the user to select an org from the session's relations. +func selectOrgFromSession(session *domain.DashboardSessionResponse) (string, error) { + opts := make([]common.SelectOption[string], 0, len(session.Relations)) + for _, rel := range session.Relations { + label := formatOrgLabel(rel.OrgPublicID, rel.OrgName) + if rel.OrgPublicID == session.CurrentOrg { + label += " (current)" + } + if rel.Role != "" { + label += " [" + rel.Role + "]" + } + opts = append(opts, common.SelectOption[string]{Label: label, Value: rel.OrgPublicID}) + } + + return common.Select("Select organization", opts) +} + +// formatSessionOrg returns a display label for an org in a session, looking up the name from relations. +func formatSessionOrg(session *domain.DashboardSessionResponse, orgPublicID string) string { + for _, rel := range session.Relations { + if rel.OrgPublicID == orgPublicID && rel.OrgName != "" { + return formatOrgLabel(orgPublicID, rel.OrgName) + } + } + return orgPublicID +} + +// formatOrgLabel returns a display label for an org. +func formatOrgLabel(publicID, name string) string { + if name != "" { + return fmt.Sprintf("%s (%s)", name, publicID) + } + return publicID +} + +// orgRow is a flat struct for table output of organizations. +type orgRow struct { + PublicID string `json:"public_id"` + Name string `json:"name"` + Role string `json:"role"` + Current string `json:"current"` +} + +var orgColumns = []ports.Column{ + {Header: "PUBLIC ID", Field: "PublicID"}, + {Header: "NAME", Field: "Name"}, + {Header: "ROLE", Field: "Role"}, + {Header: "CURRENT", Field: "Current"}, +} diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go index 04dc0e8..156a5e0 100644 --- a/internal/domain/dashboard.go +++ b/internal/domain/dashboard.go @@ -133,6 +133,34 @@ type GatewayCreatedAPIKey struct { CreatedAt float64 `json:"createdAt"` } +// DashboardSessionRelation represents an org membership in the session response. +type DashboardSessionRelation struct { + OrgPublicID string `json:"orgPublicId"` + OrgName string `json:"orgName"` + OrgRegion string `json:"orgRegion,omitempty"` + Role string `json:"role,omitempty"` +} + +// DashboardSessionResponse is the response from GET /sessions/current. +type DashboardSessionResponse struct { + User DashboardUser `json:"user"` + CurrentOrg string `json:"currentOrg"` + Relations []DashboardSessionRelation `json:"relations"` +} + +// DashboardSwitchOrgOrg is the org object in the switch-org response. +type DashboardSwitchOrgOrg struct { + PublicID string `json:"publicId"` + Name string `json:"name"` +} + +// DashboardSwitchOrgResponse is the response from POST /sessions/switch-org. +type DashboardSwitchOrgResponse struct { + OrgToken string `json:"orgToken"` + OrgSessionID string `json:"orgSessionId"` + Org DashboardSwitchOrgOrg `json:"org"` +} + // DashboardConfig holds dashboard authentication settings. type DashboardConfig struct { AccountBaseURL string `yaml:"account_base_url,omitempty"` diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go index c126e0f..1556978 100644 --- a/internal/ports/dashboard.go +++ b/internal/ports/dashboard.go @@ -35,6 +35,12 @@ type DashboardAccountClient interface { // SSOPoll polls the SSO device flow for completion. SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) + + // GetCurrentSession returns the current session info including the active org. + GetCurrentSession(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) + + // SwitchOrg switches the session to a different organization. + SwitchOrg(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) } // DashboardGatewayClient defines the interface for dashboard API gateway GraphQL operations. From ae7f51876c6d6090d12ef62ca6785a52756d4e72 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 26 Mar 2026 00:08:05 -0400 Subject: [PATCH 2/5] [TW-4719] feat(dashboard): make apps use interactive by default - When called without arguments, fetch apps and show interactive selector - User can still pass app ID directly for non-interactive use - Region is auto-detected from the selected app --- internal/cli/dashboard/apps.go | 85 +++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/internal/cli/dashboard/apps.go b/internal/cli/dashboard/apps.go index 5313c7f..1da29ad 100644 --- a/internal/cli/dashboard/apps.go +++ b/internal/cli/dashboard/apps.go @@ -165,19 +165,38 @@ func newAppsUseCmd() *cobra.Command { var region string cmd := &cobra.Command{ - Use: "use ", + Use: "use [application-id]", Short: "Set the active application for subsequent commands", Long: `Set an application as active so you don't need to pass --app and --region -to every apikeys command.`, - Example: ` # Set active app +to every apikeys command. + +When called without arguments, lists your applications and lets you pick one interactively.`, + Example: ` # Interactive — choose from your apps + nylas dashboard apps use + + # Set active app directly nylas dashboard apps use b09141da-ead2-46bd-8f4c-c9ec5af4c6cc --region us # Now apikeys commands use the active app automatically nylas dashboard apps apikeys list nylas dashboard apps apikeys create --name "My key"`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - appID := args[0] + appID := "" + if len(args) > 0 { + appID = args[0] + } + + // If no app ID provided, show interactive selector + if appID == "" { + selectedID, selectedRegion, err := selectApp(region) + if err != nil { + return wrapDashboardError(err) + } + appID = selectedID + region = selectedRegion + } + if region == "" { return dashboardError("region is required", "Use --region us or --region eu") } @@ -199,11 +218,65 @@ to every apikeys command.`, }, } - cmd.Flags().StringVarP(®ion, "region", "r", "", "Region of the application (required: us or eu)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region of the application (us or eu)") return cmd } +// selectApp fetches apps and presents an interactive selector. +// Returns the selected app ID and region. +func selectApp(regionFilter string) (appID, region string, err error) { + appSvc, err := createAppService() + if err != nil { + return "", "", err + } + + orgPublicID, err := getActiveOrgID() + if err != nil { + return "", "", err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var apps []domain.GatewayApplication + err = common.RunWithSpinner("Loading applications...", func() error { + apps, err = appSvc.ListApplications(ctx, orgPublicID, regionFilter) + return err + }) + if err != nil { + return "", "", err + } + + if len(apps) == 0 { + return "", "", dashboardError( + "no applications found", + "Create one with: nylas dashboard apps create --name MyApp --region us", + ) + } + + opts := make([]common.SelectOption[int], len(apps)) + for i, app := range apps { + name := "" + if app.Branding != nil { + name = app.Branding.Name + } + label := fmt.Sprintf("%s (%s)", app.ApplicationID, app.Region) + if name != "" { + label = fmt.Sprintf("%s — %s (%s)", name, app.ApplicationID, app.Region) + } + opts[i] = common.SelectOption[int]{Label: label, Value: i} + } + + idx, err := common.Select("Select application", opts) + if err != nil { + return "", "", err + } + + selected := apps[idx] + return selected.ApplicationID, selected.Region, nil +} + // getActiveApp returns the active app ID and region from the keyring. // Flags take priority over the stored active app. func getActiveApp(appFlag, regionFlag string) (appID, region string, err error) { From 3adc765db6a71e083b983cc4affa81e2c213e5a6 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 26 Mar 2026 00:11:13 -0400 Subject: [PATCH 3/5] [TW-4719] chore: fix gofmt formatting for CI --- internal/app/dashboard/auth_service_test.go | 18 +++++++++++------- internal/cli/dashboard/orgs.go | 6 +++--- internal/domain/dashboard.go | 6 +++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/app/dashboard/auth_service_test.go b/internal/app/dashboard/auth_service_test.go index 7d1d2a2..4195908 100644 --- a/internal/app/dashboard/auth_service_test.go +++ b/internal/app/dashboard/auth_service_test.go @@ -203,11 +203,13 @@ func TestAuthService_SwitchOrg(t *testing.T) { wantResp *domain.DashboardSwitchOrgResponse }{ { - name: "stores new org token and clears active app", - seedUser: "ut-abc", - seedOrg: "ot-old", - orgPublicID: "org-new", - mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { return switchResp, nil }, + name: "stores new org token and clears active app", + seedUser: "ut-abc", + seedOrg: "ot-old", + orgPublicID: "org-new", + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { + return switchResp, nil + }, wantOrgToken: "new-org-token", wantOrgPublicID: "org-new", wantAppIDGone: true, @@ -260,7 +262,9 @@ func TestAuthService_SwitchOrg(t *testing.T) { _ = s.Set(ports.KeyDashboardAppID, "app-old-123") _ = s.Set(ports.KeyDashboardAppRegion, "us") }, - mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { return switchResp, nil }, + mockFn: func(_ context.Context, _, _, _ string) (*domain.DashboardSwitchOrgResponse, error) { + return switchResp, nil + }, wantAppIDGone: true, wantOrgToken: "new-org-token", wantOrgPublicID: "org-new", @@ -374,7 +378,7 @@ func TestAuthService_SyncSessionOrg(t *testing.T) { wantNoOrgPublicID: true, }, { - name: "returns nil when not logged in (best-effort)", + name: "returns nil when not logged in (best-effort)", // No seedUser means loadTokens returns ErrDashboardNotLoggedIn. // SyncSessionOrg should still return nil. wantNoOrgPublicID: true, diff --git a/internal/cli/dashboard/orgs.go b/internal/cli/dashboard/orgs.go index 3aaabb5..c34246f 100644 --- a/internal/cli/dashboard/orgs.go +++ b/internal/cli/dashboard/orgs.go @@ -54,9 +54,9 @@ func newOrgsListCmd() *cobra.Command { } rows[i] = orgRow{ PublicID: rel.OrgPublicID, - Name: rel.OrgName, - Role: rel.Role, - Current: current, + Name: rel.OrgName, + Role: rel.Role, + Current: current, } } diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go index 156a5e0..5aa9421 100644 --- a/internal/domain/dashboard.go +++ b/internal/domain/dashboard.go @@ -156,9 +156,9 @@ type DashboardSwitchOrgOrg struct { // DashboardSwitchOrgResponse is the response from POST /sessions/switch-org. type DashboardSwitchOrgResponse struct { - OrgToken string `json:"orgToken"` - OrgSessionID string `json:"orgSessionId"` - Org DashboardSwitchOrgOrg `json:"org"` + OrgToken string `json:"orgToken"` + OrgSessionID string `json:"orgSessionId"` + Org DashboardSwitchOrgOrg `json:"org"` } // DashboardConfig holds dashboard authentication settings. From 3a57d4e796ea6032c6239279142db620c72fcee8 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 26 Mar 2026 00:12:44 -0400 Subject: [PATCH 4/5] [TW-4719] chore: remove unused errSecretStore test helper --- internal/app/dashboard/auth_service_test.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/internal/app/dashboard/auth_service_test.go b/internal/app/dashboard/auth_service_test.go index 4195908..902f713 100644 --- a/internal/app/dashboard/auth_service_test.go +++ b/internal/app/dashboard/auth_service_test.go @@ -45,23 +45,6 @@ func (m *memSecretStore) IsAvailable() bool { return true } func (m *memSecretStore) Name() string { return "mem" } -// errSecretStore is a secret store that always returns an error on Set, -// used to test secret-persistence failure paths. -type errSecretStore struct { - memSecretStore - setErr error -} - -func newErrSecretStore(setErr error) *errSecretStore { - return &errSecretStore{ - memSecretStore: memSecretStore{data: make(map[string]string)}, - setErr: setErr, - } -} - -func (e *errSecretStore) Set(key, value string) error { - return e.setErr -} // seedTokens pre-populates userToken (and optionally orgToken) so that // loadTokens() succeeds without going through a full Login flow. From a7fa78dc61bbca17095cf56f4e3ce89762bed5fc Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 26 Mar 2026 00:14:02 -0400 Subject: [PATCH 5/5] [TW-4719] chore: fix trailing blank line in test file --- internal/app/dashboard/auth_service_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/app/dashboard/auth_service_test.go b/internal/app/dashboard/auth_service_test.go index 902f713..43b0274 100644 --- a/internal/app/dashboard/auth_service_test.go +++ b/internal/app/dashboard/auth_service_test.go @@ -45,7 +45,6 @@ func (m *memSecretStore) IsAvailable() bool { return true } func (m *memSecretStore) Name() string { return "mem" } - // seedTokens pre-populates userToken (and optionally orgToken) so that // loadTokens() succeeds without going through a full Login flow. func seedTokens(s ports.SecretStore, userToken, orgToken string) {