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
23 changes: 23 additions & 0 deletions internal/adapters/dashboard/account_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
63 changes: 63 additions & 0 deletions internal/adapters/dashboard/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions internal/adapters/dashboard/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions internal/app/dashboard/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading