diff --git a/browser_flow.go b/browser_flow.go index 342b8d8..9170458 100644 --- a/browser_flow.go +++ b/browser_flow.go @@ -2,16 +2,11 @@ package main import ( "context" - "encoding/json" "errors" "fmt" - "io" - "net/http" "net/url" - "strings" "time" - retry "github.com/appleboy/go-httpretry" "github.com/go-authgate/cli/tui" "github.com/go-authgate/sdk-go/credstore" ) @@ -39,59 +34,18 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*credstore.To data.Set("code", code) data.Set("redirect_uri", redirectURI) data.Set("client_id", clientID) + data.Set("code_verifier", codeVerifier) - if isPublicClient() { - data.Set("code_verifier", codeVerifier) - } else { + if !isPublicClient() { data.Set("client_secret", clientSecret) - data.Set("code_verifier", codeVerifier) } - resp, err := retryClient.Post(ctx, serverURL+"/oauth/token", - retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())), - ) + tokenResp, err := doTokenExchange(ctx, serverURL+"/oauth/token", data, nil) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, err } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error != "" { - return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription) - } - return nil, fmt.Errorf( - "token exchange failed with status %d: %s", - resp.StatusCode, - string(body), - ) - } - - var tokenResp tokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("failed to parse token response: %w", err) - } - - if err := validateTokenResponse( - tokenResp.AccessToken, - tokenResp.TokenType, - tokenResp.ExpiresIn, - ); err != nil { - return nil, fmt.Errorf("invalid token response: %w", err) - } - - return &credstore.Token{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - TokenType: tokenResp.TokenType, - ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - ClientID: clientID, - }, nil + return tokenResponseToCredstore(tokenResp), nil } // performBrowserFlowWithUpdates runs the Authorization Code Flow with PKCE @@ -181,13 +135,15 @@ func performBrowserFlowWithUpdates( select { case <-done: return + case <-ctx.Done(): + return case <-ticker.C: elapsed := time.Since(startTime) progress := float64(elapsed) / float64(callbackTimeout) if progress > 1.0 { progress = 1.0 } - updates <- tui.FlowUpdate{ + update := tui.FlowUpdate{ Type: tui.TimerTick, Progress: progress, Data: map[string]any{ @@ -195,6 +151,13 @@ func performBrowserFlowWithUpdates( "timeout": callbackTimeout, }, } + select { + case updates <- update: + case <-done: + return + case <-ctx.Done(): + return + } } } }() diff --git a/error_sanitizer.go b/error_sanitizer.go index 1384de4..c648ce9 100644 --- a/error_sanitizer.go +++ b/error_sanitizer.go @@ -1,24 +1,31 @@ package main +import "github.com/go-authgate/cli/tui" + +// browserErrorMessages overrides the TUI messages with shorter, browser-safe versions. +// Only codes that need different wording for the browser are listed here. +var browserErrorMessages = map[string]string{ + "access_denied": "Authorization was denied. You may close this window.", + "invalid_request": "Invalid request. Please contact support.", + "unauthorized_client": "Client is not authorized.", + "server_error": "Server error. Please try again later.", + "temporarily_unavailable": "Service is temporarily unavailable. Please try again later.", +} + // sanitizeOAuthError maps standard OAuth error codes to user-friendly messages // that are safe to display in the browser. This prevents information disclosure // while maintaining a good user experience. // The errorDescription parameter is intentionally ignored to prevent leaking details. func sanitizeOAuthError(errorCode, _ string) string { - switch errorCode { - case "access_denied": - return "Authorization was denied. You may close this window." - case "invalid_request": - return "Invalid request. Please contact support." - case "unauthorized_client": - return "Client is not authorized." - case "server_error": - return "Server error. Please try again later." - case "temporarily_unavailable": - return "Service is temporarily unavailable. Please try again later." - default: - return "Authentication failed. Please check your terminal for details." + // Check browser-specific overrides first + if msg, ok := browserErrorMessages[errorCode]; ok { + return msg + } + // Fall back to the shared TUI error map + if msg, ok := tui.OAuthErrorMessage(errorCode); ok { + return msg } + return "Authentication failed. Please check your terminal for details." } // sanitizeTokenExchangeError sanitizes backend token exchange errors to prevent diff --git a/main.go b/main.go index f74dcfa..6ba5da5 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,6 @@ import ( "net/url" "os" "os/signal" - "strings" "syscall" "time" @@ -175,55 +174,23 @@ func refreshAccessToken(ctx context.Context, refreshToken string) (*credstore.To data.Set("client_secret", clientSecret) } - resp, err := retryClient.Post(ctx, serverURL+"/oauth/token", - retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())), - ) - if err != nil { - return nil, fmt.Errorf("refresh request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil { + tokenResp, err := doTokenExchange(ctx, serverURL+"/oauth/token", data, + func(errResp ErrorResponse, _ []byte) error { if errResp.Error == "invalid_grant" || errResp.Error == "invalid_token" { - return nil, ErrRefreshTokenExpired + return ErrRefreshTokenExpired } - return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription) - } - return nil, fmt.Errorf("refresh failed with status %d: %s", resp.StatusCode, string(body)) - } - - var tokenResp tokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("failed to parse token response: %w", err) + return nil // fall through to default error formatting + }, + ) + if err != nil { + return nil, err } - if err := validateTokenResponse( - tokenResp.AccessToken, - tokenResp.TokenType, - tokenResp.ExpiresIn, - ); err != nil { - return nil, fmt.Errorf("invalid token response: %w", err) - } + storage := tokenResponseToCredstore(tokenResp) // Preserve the old refresh token in fixed-mode (server may not return a new one). - newRefreshToken := tokenResp.RefreshToken - if newRefreshToken == "" { - newRefreshToken = refreshToken - } - - storage := &credstore.Token{ - AccessToken: tokenResp.AccessToken, - RefreshToken: newRefreshToken, - TokenType: tokenResp.TokenType, - ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - ClientID: clientID, + if storage.RefreshToken == "" { + storage.RefreshToken = refreshToken } if err := tokenStore.Save(clientID, *storage); err != nil { diff --git a/tokens.go b/tokens.go index 3ef1198..117f73a 100644 --- a/tokens.go +++ b/tokens.go @@ -1,8 +1,18 @@ package main import ( + "context" + "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + retry "github.com/appleboy/go-httpretry" + "github.com/go-authgate/sdk-go/credstore" ) // ErrorResponse is an OAuth error payload. @@ -14,6 +24,80 @@ type ErrorResponse struct { // ErrRefreshTokenExpired indicates the refresh token has expired or is invalid. var ErrRefreshTokenExpired = errors.New("refresh token expired or invalid") +// doTokenExchange performs a standard OAuth 2.0 token POST and returns the +// parsed tokenResponse on success. On non-200 responses it returns a formatted +// error including the OAuth error code/description when available. +// The optional errHook is called with the parsed ErrorResponse before the +// default error formatting, allowing callers to handle specific error codes +// (e.g., invalid_grant → ErrRefreshTokenExpired). If errHook returns a +// non-nil error, that error is returned directly. +func doTokenExchange( + ctx context.Context, + tokenURL string, + data url.Values, + errHook func(errResp ErrorResponse, body []byte) error, +) (*tokenResponse, error) { + resp, err := retryClient.Post(ctx, tokenURL, + retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())), + ) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error != "" { + if errHook != nil { + if hookErr := errHook(errResp, body); hookErr != nil { + return nil, hookErr + } + } + desc := strings.TrimSpace(errResp.ErrorDescription) + if desc == "" { + return nil, fmt.Errorf("%s", errResp.Error) + } + return nil, fmt.Errorf("%s: %s", errResp.Error, desc) + } + return nil, fmt.Errorf( + "token exchange failed with status %d: %s", + resp.StatusCode, + string(body), + ) + } + + var tokenResp tokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + if err := validateTokenResponse( + tokenResp.AccessToken, + tokenResp.TokenType, + tokenResp.ExpiresIn, + ); err != nil { + return nil, fmt.Errorf("invalid token response: %w", err) + } + + return &tokenResp, nil +} + +// tokenResponseToCredstore converts a tokenResponse to a credstore.Token. +func tokenResponseToCredstore(tr *tokenResponse) *credstore.Token { + return &credstore.Token{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + TokenType: tr.TokenType, + ExpiresAt: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second), + ClientID: clientID, + } +} + // validateTokenResponse performs basic sanity checks on a token response. func validateTokenResponse(accessToken, tokenType string, expiresIn int) error { if accessToken == "" { diff --git a/tui/bubbletea_manager.go b/tui/bubbletea_manager.go index 17a4ff2..e06af54 100644 --- a/tui/bubbletea_manager.go +++ b/tui/bubbletea_manager.go @@ -77,140 +77,170 @@ func (m *BubbleTeaManager) refreshDisplay() { } } -// RunBrowserFlow executes the browser OAuth flow with unified TUI rendering. -func (m *BubbleTeaManager) RunBrowserFlow( +// runFlow is the shared event loop for both browser and device flows. +// It launches the flow function in a goroutine, processes updates via the +// provided handler, animates the spinner, and returns the final result. +func (m *BubbleTeaManager) runFlow( ctx context.Context, - perform BrowserFlowFunc, -) (*TokenStorage, bool, error) { - // If no renderer, fall back to simple mode - if m.renderer == nil { - return m.simple.RunBrowserFlow(ctx, perform) - } - - // Add flow steps - m.addStep("Open browser") - m.addStep("Wait for browser callback") - m.addStep("Exchange tokens") - + startFlow func(ctx context.Context, updates chan<- FlowUpdate) (any, error), + handleUpdate func(FlowUpdate), +) (any, error) { updates := make(chan FlowUpdate, 10) - // Create a cancellable context for the OAuth flow flowCtx, cancel := context.WithCancel(ctx) defer cancel() - // Result channel to receive the OAuth flow outcome type result struct { - storage *TokenStorage - ok bool - err error + val any + err error } resultCh := make(chan result, 1) - // Start OAuth flow in a goroutine go func() { - storage, ok, err := perform(flowCtx, updates) - resultCh <- result{storage, ok, err} + val, err := startFlow(flowCtx, updates) + resultCh <- result{val, err} close(updates) }() - // Process updates and render in unified view - ticker := time.NewTicker(100 * time.Millisecond) // Spinner animation + ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case update, ok := <-updates: if !ok { - // Channel closed, wait for result res := <-resultCh - return res.storage, res.ok, res.err + return res.val, res.err } - m.handleBrowserFlowUpdate(update) + handleUpdate(update) m.refreshDisplay() case <-ticker.C: - // Update spinner animation m.renderer.NextSpinner() m.refreshDisplay() case res := <-resultCh: - // Drain remaining updates for update := range updates { - m.handleBrowserFlowUpdate(update) + handleUpdate(update) } m.refreshDisplay() - return res.storage, res.ok, res.err + return res.val, res.err case <-ctx.Done(): - return nil, false, ctx.Err() + // Cancel the flow goroutine and drain updates to prevent leaks. + cancel() + for { + select { + case _, ok := <-updates: + if !ok { + <-resultCh + return nil, ctx.Err() + } + case <-resultCh: + return nil, ctx.Err() + } + } } } } -// handleBrowserFlowUpdate processes FlowUpdate messages for browser flow -func (m *BubbleTeaManager) handleBrowserFlowUpdate(update FlowUpdate) { +// handleFlowUpdate processes a FlowUpdate using the given step-name mapping +// and flow-specific extra handler. The stepNames map maps step numbers (1-based) +// to step names. The extraHandler processes flow-specific update types. +func (m *BubbleTeaManager) handleFlowUpdate( + update FlowUpdate, + stepNames map[int]string, + extraHandler func(FlowUpdate), +) { switch update.Type { case StepStart: - // Map step numbers to step names - stepName := "" - switch update.Step { - case 1: - stepName = "Open browser" - case 2: - stepName = "Wait for browser callback" - case 3: - stepName = "Exchange tokens" + if name, ok := stepNames[update.Step]; ok { + m.updateStep(name, StepInProgress, "") } - if stepName != "" { - m.updateStep(stepName, StepInProgress, "") - } - - case BrowserOpened: - m.updateStep("Open browser", StepCompleted, "Browser opened") - - case TimerTick: - // Update countdown for "Wait for browser callback" step - elapsed := update.GetDuration("elapsed") - timeout := update.GetDuration("timeout") - remaining := max(0, timeout-elapsed) - - // Format remaining time - remainingStr := formatRemainingTime(remaining) - m.updateStep("Wait for browser callback", StepInProgress, remainingStr+" remaining") - - case CallbackReceived: - m.updateStep("Wait for browser callback", StepCompleted, "Authorization complete") - m.updateStep("Exchange tokens", StepInProgress, "") case StepComplete: - // Final step completion - if update.Step == 3 { - m.updateStep("Exchange tokens", StepCompleted, "Tokens retrieved") + if extraHandler != nil { + extraHandler(update) } case StepError: - // Parse and display enhanced error message parsed := parseError(errors.New(update.Message)) - errorDisplay := parsed.UserFriendlyMessage - - // Determine which step failed - if update.Step > 0 && update.Step <= 3 { - stepName := "" - switch update.Step { - case 1: - stepName = "Open browser" - case 2: - stepName = "Wait for browser callback" - case 3: - stepName = "Exchange tokens" - } - if stepName != "" { - m.updateStep(stepName, StepFailed, errorDisplay) - } + if name, ok := stepNames[update.Step]; ok { + m.updateStep(name, StepFailed, parsed.UserFriendlyMessage) + } + + default: + if extraHandler != nil { + extraHandler(update) } } } +// RunBrowserFlow executes the browser OAuth flow with unified TUI rendering. +func (m *BubbleTeaManager) RunBrowserFlow( + ctx context.Context, + perform BrowserFlowFunc, +) (*TokenStorage, bool, error) { + if m.renderer == nil { + return m.simple.RunBrowserFlow(ctx, perform) + } + + m.addStep("Open browser") + m.addStep("Wait for browser callback") + m.addStep("Exchange tokens") + + stepNames := map[int]string{ + 1: "Open browser", + 2: "Wait for browser callback", + 3: "Exchange tokens", + } + + type browserResult struct { + storage *TokenStorage + ok bool + } + + val, err := m.runFlow(ctx, + func(flowCtx context.Context, updates chan<- FlowUpdate) (any, error) { + storage, ok, err := perform(flowCtx, updates) + return browserResult{storage, ok}, err + }, + func(update FlowUpdate) { + m.handleFlowUpdate(update, stepNames, func(u FlowUpdate) { + switch u.Type { + case BrowserOpened: + m.updateStep("Open browser", StepCompleted, "Browser opened") + + case TimerTick: + elapsed := u.GetDuration("elapsed") + timeout := u.GetDuration("timeout") + remaining := max(0, timeout-elapsed) + m.updateStep("Wait for browser callback", StepInProgress, + formatRemainingTime(remaining)+" remaining") + + case CallbackReceived: + m.updateStep( + "Wait for browser callback", + StepCompleted, + "Authorization complete", + ) + m.updateStep("Exchange tokens", StepInProgress, "") + + case StepComplete: + if u.Step == 3 { + m.updateStep("Exchange tokens", StepCompleted, "Tokens retrieved") + } + } + }) + }, + ) + if err != nil { + return nil, false, err + } + res := val.(browserResult) + return res.storage, res.ok, nil +} + // formatRemainingTime formats a duration as "Xm Ys" or "Xs" func formatRemainingTime(d time.Duration) string { d = d.Round(time.Second) @@ -228,136 +258,60 @@ func (m *BubbleTeaManager) RunDeviceFlow( ctx context.Context, perform DeviceFlowFunc, ) (*TokenStorage, error) { - // If no renderer, fall back to simple mode if m.renderer == nil { return m.simple.RunDeviceFlow(ctx, perform) } - // Add flow steps m.addStep("Request device code") m.addStep("Wait for authorization") m.addStep("Exchange tokens") - updates := make(chan FlowUpdate, 10) - - // Create a cancellable context for the OAuth flow - flowCtx, cancel := context.WithCancel(ctx) - defer cancel() - - // Result channel to receive the OAuth flow outcome - type result struct { - storage *TokenStorage - err error + stepNames := map[int]string{ + 1: "Request device code", + 2: "Wait for authorization", + 3: "Exchange tokens", } - resultCh := make(chan result, 1) - - // Start OAuth flow in a goroutine - go func() { - storage, err := perform(flowCtx, updates) - resultCh <- result{storage, err} - close(updates) - }() - - // Process updates and render in unified view - ticker := time.NewTicker(100 * time.Millisecond) // Spinner animation - defer ticker.Stop() - - for { - select { - case update, ok := <-updates: - if !ok { - // Channel closed, wait for result - res := <-resultCh - return res.storage, res.err - } - m.handleDeviceFlowUpdate(update) - m.refreshDisplay() - case <-ticker.C: - // Update spinner animation - m.renderer.NextSpinner() - m.refreshDisplay() - - case res := <-resultCh: - // Drain remaining updates - for update := range updates { - m.handleDeviceFlowUpdate(update) - } - m.refreshDisplay() - return res.storage, res.err - - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -// handleDeviceFlowUpdate processes FlowUpdate messages for device flow -func (m *BubbleTeaManager) handleDeviceFlowUpdate(update FlowUpdate) { - switch update.Type { - case StepStart: - // Map step numbers to step names - stepName := "" - switch update.Step { - case 1: - stepName = "Request device code" - case 2: - stepName = "Wait for authorization" - case 3: - stepName = "Exchange tokens" - } - if stepName != "" { - m.updateStep(stepName, StepInProgress, "") - } - - case DeviceCodeReceived: - userCode := update.GetString("user_code") - verificationURI := update.GetString("verification_uri") - verificationURIComplete := update.GetString("verification_uri_complete") - m.updateStep("Request device code", StepCompleted, "Code received") - - // Show device code info in prominent box - if userCode != "" && verificationURI != "" { - m.renderer.SetDeviceCode(userCode, verificationURI, verificationURIComplete) - m.updateStep("Wait for authorization", StepInProgress, "") - } - - case PollingUpdate: - pollCount := update.GetInt("poll_count") - m.updateStep( - "Wait for authorization", - StepInProgress, - fmt.Sprintf("Polling... (attempt %d)", pollCount), - ) - - case StepComplete: - // Final step completion - if update.Step == 2 { - m.updateStep("Wait for authorization", StepCompleted, "Authorization complete") - m.updateStep("Exchange tokens", StepCompleted, "Tokens retrieved") - } - - case StepError: - // Parse and display enhanced error message - parsed := parseError(errors.New(update.Message)) - errorDisplay := parsed.UserFriendlyMessage - - // Determine which step failed - if update.Step > 0 && update.Step <= 3 { - stepName := "" - switch update.Step { - case 1: - stepName = "Request device code" - case 2: - stepName = "Wait for authorization" - case 3: - stepName = "Exchange tokens" - } - if stepName != "" { - m.updateStep(stepName, StepFailed, errorDisplay) - } - } + val, err := m.runFlow(ctx, + func(flowCtx context.Context, updates chan<- FlowUpdate) (any, error) { + return perform(flowCtx, updates) + }, + func(update FlowUpdate) { + m.handleFlowUpdate(update, stepNames, func(u FlowUpdate) { + switch u.Type { + case DeviceCodeReceived: + userCode := u.GetString("user_code") + verificationURI := u.GetString("verification_uri") + verificationURIComplete := u.GetString("verification_uri_complete") + m.updateStep("Request device code", StepCompleted, "Code received") + + if userCode != "" && verificationURI != "" { + m.renderer.SetDeviceCode(userCode, verificationURI, verificationURIComplete) + m.updateStep("Wait for authorization", StepInProgress, "") + } + + case PollingUpdate: + pollCount := u.GetInt("poll_count") + m.updateStep("Wait for authorization", StepInProgress, + fmt.Sprintf("Polling... (attempt %d)", pollCount)) + + case StepComplete: + if u.Step == 2 { + m.updateStep( + "Wait for authorization", + StepCompleted, + "Authorization complete", + ) + m.updateStep("Exchange tokens", StepCompleted, "Tokens retrieved") + } + } + }) + }, + ) + if err != nil { + return nil, err } + return val.(*TokenStorage), nil } func (m *BubbleTeaManager) ShowTokenInfo(storage *TokenStorage) { diff --git a/tui/components/components_test.go b/tui/components/components_test.go index e6dc0a9..fe43704 100644 --- a/tui/components/components_test.go +++ b/tui/components/components_test.go @@ -28,17 +28,6 @@ func TestStepIndicator(t *testing.T) { if view == "" { t.Error("View should not be empty") } - - // Test compact view - compact := indicator.ViewCompact() - if compact == "" { - t.Error("ViewCompact should not be empty") - } - - // Compact view should contain step symbols - if !strings.Contains(compact, "●") && !strings.Contains(compact, "○") { - t.Error("ViewCompact should contain step symbols") - } } func TestTimer(t *testing.T) { @@ -56,11 +45,6 @@ func TestTimer(t *testing.T) { if !strings.Contains(view, "Elapsed") { t.Error("Elapsed timer view should contain 'Elapsed'") } - - compact := timer.ViewCompact() - if compact == "" { - t.Error("ViewCompact should not be empty") - } }) t.Run("Countdown Timer", func(t *testing.T) { @@ -119,12 +103,6 @@ func TestProgressBar(t *testing.T) { if view == "" { t.Error("View should not be empty after changing width") } - - // Test compact view - compact := bar.ViewCompact() - if compact == "" { - t.Error("ViewCompact should not be empty") - } } func TestInfoBox(t *testing.T) { @@ -168,11 +146,6 @@ func TestInfoBox(t *testing.T) { if view == "" { t.Error("View should not be empty") } - - simpleView := box.ViewSimple() - if simpleView == "" { - t.Error("ViewSimple should not be empty") - } } // TestFormatPercentage removed - formatPercentage is now internal to bubbles/progress diff --git a/tui/components/help_view.go b/tui/components/help_view.go index 5990337..93da4a9 100644 --- a/tui/components/help_view.go +++ b/tui/components/help_view.go @@ -83,11 +83,6 @@ func (h *HelpView) Toggle() { h.help.ShowAll = !h.hidden } -// IsHidden returns whether the help is hidden -func (h *HelpView) IsHidden() bool { - return h.hidden -} - // View renders the help view func (h *HelpView) View() string { if h.hidden { diff --git a/tui/components/info_box.go b/tui/components/info_box.go index 70537b9..8eac6a1 100644 --- a/tui/components/info_box.go +++ b/tui/components/info_box.go @@ -67,26 +67,3 @@ func (i *InfoBox) View() string { return borderStyle.Render(content.String()) } - -// ViewSimple renders the info box without borders -func (i *InfoBox) ViewSimple() string { - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7D56F4")) - - contentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) - - var result strings.Builder - if i.Title != "" { - result.WriteString(titleStyle.Render(i.Title)) - result.WriteString("\n") - } - - for _, line := range i.Content { - result.WriteString(contentStyle.Render(line)) - result.WriteString("\n") - } - - return result.String() -} diff --git a/tui/components/progress_bar.go b/tui/components/progress_bar.go index 4ee1af0..8524c02 100644 --- a/tui/components/progress_bar.go +++ b/tui/components/progress_bar.go @@ -55,11 +55,6 @@ func (p *ProgressBar) View() string { return bar + " " + percentage } -// ViewCompact renders a compact version of the progress bar -func (p *ProgressBar) ViewCompact() string { - return p.model.ViewAs(p.model.Percent()) -} - // SetWidth updates the progress bar width func (p *ProgressBar) SetWidth(width int) { p.model.SetWidth(width) diff --git a/tui/components/step_indicator.go b/tui/components/step_indicator.go index 15f8f8c..c3d5aa6 100644 --- a/tui/components/step_indicator.go +++ b/tui/components/step_indicator.go @@ -75,30 +75,3 @@ func (s *StepIndicator) View() string { return strings.Join(parts, "\n") } - -// ViewCompact renders a compact version of the step indicator -func (s *StepIndicator) ViewCompact() string { - completedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00C853")) - - currentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true) - - pendingStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")) - - var symbols []string - for i := 1; i <= s.TotalSteps; i++ { - switch { - case i < s.CurrentStep: - symbols = append(symbols, completedStyle.Render("●")) - case i == s.CurrentStep: - symbols = append(symbols, currentStyle.Render("●")) - default: - symbols = append(symbols, pendingStyle.Render("○")) - } - } - - return strings.Join(symbols, " ") -} diff --git a/tui/components/timer.go b/tui/components/timer.go index f65e604..dc22684 100644 --- a/tui/components/timer.go +++ b/tui/components/timer.go @@ -53,15 +53,6 @@ func (t *Timer) View() string { return style.Render("Elapsed: " + formatDuration(t.elapsed)) } -// ViewCompact renders a compact version of the timer -func (t *Timer) ViewCompact() string { - if t.isCountdown { - remaining := max(t.totalDuration-t.elapsed, 0) - return formatDuration(remaining) - } - return formatDuration(t.elapsed) -} - // formatDuration formats a duration in MM:SS format func formatDuration(d time.Duration) string { d = d.Round(time.Second) diff --git a/tui/device_view.go b/tui/device_view.go index 9e1c03e..5e95fee 100644 --- a/tui/device_view.go +++ b/tui/device_view.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "strings" - "time" "charm.land/lipgloss/v2" "github.com/go-authgate/cli/tui/components" @@ -49,7 +48,7 @@ func renderDeviceView(m *DeviceModel) string { if m.pollCount > 0 { pollInfo = fmt.Sprintf(" (poll #%d, interval: %s)", m.pollCount, - formatInterval(m.pollInterval)) + FormatInterval(m.pollInterval)) } b.WriteString(statusStyle.Render(fmt.Sprintf("%s %s%s", @@ -67,8 +66,8 @@ func renderDeviceView(m *DeviceModel) string { Foreground(colorWarning) backoffMsg := fmt.Sprintf("⚠ Server requested slower polling: %s → %s", - formatInterval(m.oldInterval), - formatInterval(m.pollInterval)) + FormatInterval(m.oldInterval), + FormatInterval(m.pollInterval)) b.WriteString(warningBox.Render(backoffMsg)) b.WriteString("\n\n") @@ -143,17 +142,3 @@ func renderDeviceComplete(m *DeviceModel) string { return b.String() } - -// formatInterval formats a time.Duration as a human-readable interval. -func formatInterval(d time.Duration) string { - seconds := int(d.Seconds()) - if seconds < 60 { - return fmt.Sprintf("%ds", seconds) - } - minutes := seconds / 60 - seconds %= 60 - if seconds == 0 { - return fmt.Sprintf("%dm", minutes) - } - return fmt.Sprintf("%dm%ds", minutes, seconds) -} diff --git a/tui/error_parser.go b/tui/error_parser.go index 7134a2c..41039b6 100644 --- a/tui/error_parser.go +++ b/tui/error_parser.go @@ -110,34 +110,35 @@ func isErrorCode(s string) bool { return slices.Contains(oauthErrorCodes, s) } +// oauthErrorMessages maps standard OAuth error codes to user-friendly TUI messages. +// This is the authoritative map; other packages should delegate here via OAuthErrorMessage. +var oauthErrorMessages = map[string]string{ + "access_denied": "Authorization was denied. You may have cancelled the request or don't have permission to access this resource.", + "invalid_request": "The authorization request was invalid. Please check your configuration.", + "unauthorized_client": "The client is not authorized to use this authorization method. Please verify your client ID and permissions.", + "invalid_client": "Client authentication failed. Please check your client ID and secret.", + "invalid_grant": "The authorization code or refresh token is invalid or expired.", + "unsupported_grant_type": "The authorization grant type is not supported by this server.", + "invalid_scope": "One or more requested scopes are invalid or not allowed.", + "server_error": "The authorization server encountered an error. Please try again later.", + "temporarily_unavailable": "The authorization server is temporarily unavailable. Please try again in a few moments.", +} + +// OAuthErrorMessage returns the user-friendly TUI message for a given OAuth error code. +func OAuthErrorMessage(code string) (string, bool) { + msg, ok := oauthErrorMessages[code] + return msg, ok +} + // sanitizeOAuthErrorForTUI converts OAuth error codes to user-friendly messages. -// This is a TUI-specific version that provides more detailed guidance. func sanitizeOAuthErrorForTUI(errorCode, errorDescription string) string { - switch errorCode { - case "access_denied": - return "Authorization was denied. You may have cancelled the request or don't have permission to access this resource." - case "invalid_request": - return "The authorization request was invalid. Please check your configuration." - case "unauthorized_client": - return "The client is not authorized to use this authorization method. Please verify your client ID and permissions." - case "invalid_client": - return "Client authentication failed. Please check your client ID and secret." - case "invalid_grant": - return "The authorization code or refresh token is invalid or expired." - case "unsupported_grant_type": - return "The authorization grant type is not supported by this server." - case "invalid_scope": - return "One or more requested scopes are invalid or not allowed." - case "server_error": - return "The authorization server encountered an error. Please try again later." - case "temporarily_unavailable": - return "The authorization server is temporarily unavailable. Please try again in a few moments." - default: - if errorDescription != "" { - return errorDescription - } - return "Authentication failed. Please check your configuration and try again." + if msg, ok := oauthErrorMessages[errorCode]; ok { + return msg + } + if errorDescription != "" { + return errorDescription } + return "Authentication failed. Please check your configuration and try again." } // extractCleanMessage extracts a clean, user-facing message from an error chain. diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go index 8482305..516355a 100644 --- a/tui/flow_renderer.go +++ b/tui/flow_renderer.go @@ -394,7 +394,7 @@ func (r *FlowRenderer) renderTokenInfo() string { remaining := time.Until(r.model.tokenStorage.ExpiresAt) content.WriteString(labelStyle.Render("Expires In:")) content.WriteString(" ") - content.WriteString(valueStyle.Render(formatDuration(remaining))) + content.WriteString(valueStyle.Render(FormatDurationHuman(remaining))) } return boxStyle.Render(content.String()) diff --git a/tui/simple_manager.go b/tui/simple_manager.go index 929a529..42c899f 100644 --- a/tui/simple_manager.go +++ b/tui/simple_manager.go @@ -212,12 +212,12 @@ func (m *SimpleManager) ShowTokenInfo(storage *TokenStorage) { fmt.Printf("🔒 Full tokens stored in: %s\n\n", formatStorageLocation(storage.StorageBackend)) // Access Token (masked) - maskedAccess := maskTokenSimple(storage.AccessToken) + maskedAccess := maskTokenPreview(storage.AccessToken) fmt.Printf("Access Token : %s\n", maskedAccess) // Refresh Token (masked, if present) if storage.RefreshToken != "" { - maskedRefresh := maskTokenSimple(storage.RefreshToken) + maskedRefresh := maskTokenPreview(storage.RefreshToken) fmt.Printf("Refresh Token: %s\n", maskedRefresh) } @@ -230,17 +230,6 @@ func (m *SimpleManager) ShowTokenInfo(storage *TokenStorage) { fmt.Printf("========================================\n") } -// maskTokenSimple masks sensitive token data for simple output -func maskTokenSimple(token string) string { - if len(token) <= 16 { - if len(token) <= 8 { - return token[:min(len(token), 4)] + "..." - } - return token[:4] + "..." + token[len(token)-4:] - } - return token[:8] + "..." + token[len(token)-4:] -} - func (m *SimpleManager) ShowVerification(success bool, info string) { fmt.Println("\nVerifying token with server...") if success { diff --git a/tui/styles.go b/tui/styles.go index 90c8cae..e51a101 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "time" + "charm.land/lipgloss/v2" ) @@ -155,6 +158,62 @@ func RenderHelp(text string) string { return HelpStyle.Render(text) } +// FormatDurationCompact formats a duration in MM:SS format (e.g., "2:05"). +// Negative durations are formatted with a leading "-" (e.g., "-1:05"). +func FormatDurationCompact(d time.Duration) string { + sign := "" + if d < 0 { + sign = "-" + d = -d + } + d = d.Round(time.Second) + totalSeconds := int(d.Seconds()) + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + return fmt.Sprintf("%s%d:%02d", sign, minutes, seconds) +} + +// FormatDurationHuman formats a duration in human-readable format (e.g., "1h 30m", "5m", "30s"). +func FormatDurationHuman(d time.Duration) string { + if d < 0 { + return "expired" + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + + if hours > 24 { + days := hours / 24 + hours %= 24 + return fmt.Sprintf("%dd %dh", days, hours) + } + + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + + if minutes > 0 { + return fmt.Sprintf("%dm", minutes) + } + + seconds := int(d.Seconds()) + return fmt.Sprintf("%ds", seconds) +} + +// FormatInterval formats a duration as a compact interval string (e.g., "5s", "2m30s"). +func FormatInterval(d time.Duration) string { + seconds := int(d.Seconds()) + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) + } + minutes := seconds / 60 + seconds %= 60 + if seconds == 0 { + return fmt.Sprintf("%dm", minutes) + } + return fmt.Sprintf("%dm%ds", minutes, seconds) +} + // maskTokenPreview masks token for preview display (shows first 8 and last 4 chars) func maskTokenPreview(token string) string { if len(token) <= 16 { diff --git a/tui/token_view.go b/tui/token_view.go index e893d8a..488e771 100644 --- a/tui/token_view.go +++ b/tui/token_view.go @@ -119,18 +119,6 @@ func (m *TokenViewModel) View() tea.View { return tea.NewView(AppContainerStyle.Render(view)) } -// maskToken masks sensitive token data, showing only first 8 and last 4 characters -func maskToken(token string) string { - if len(token) <= 16 { - // Too short to mask meaningfully, show first 4 and last 4 - if len(token) <= 8 { - return token[:min(len(token), 4)] + "..." - } - return token[:4] + "..." + token[len(token)-4:] - } - return token[:8] + "..." + token[len(token)-4:] -} - // getTokenFilePath returns the token file path from environment or default func getTokenFilePath() string { if path := os.Getenv("TOKEN_FILE"); path != "" { @@ -142,12 +130,11 @@ func getTokenFilePath() string { // formatStorageLocation returns a human-readable storage location from the backend string. // The backend string is in the form "keyring: " or "file: ". func formatStorageLocation(backend string) string { - if strings.HasPrefix(backend, "keyring:") { - service := strings.TrimSpace(strings.TrimPrefix(backend, "keyring:")) - return "OS keyring (service: " + service + ")" + if service, ok := strings.CutPrefix(backend, "keyring:"); ok { + return "OS keyring (service: " + strings.TrimSpace(service) + ")" } - if strings.HasPrefix(backend, "file:") { - return strings.TrimSpace(strings.TrimPrefix(backend, "file:")) + if path, ok := strings.CutPrefix(backend, "file:"); ok { + return strings.TrimSpace(path) } if backend != "" { return backend @@ -181,7 +168,7 @@ func formatTokenDetails(storage *TokenStorage) string { b.WriteString("\n") b.WriteString(lipgloss.NewStyle(). Foreground(colorSubtle). - Render(maskToken(storage.AccessToken))) + Render(maskTokenPreview(storage.AccessToken))) b.WriteString("\n\n") // Refresh Token (masked) @@ -193,7 +180,7 @@ func formatTokenDetails(storage *TokenStorage) string { b.WriteString("\n") b.WriteString(lipgloss.NewStyle(). Foreground(colorSubtle). - Render(maskToken(storage.RefreshToken))) + Render(maskTokenPreview(storage.RefreshToken))) b.WriteString("\n\n") } @@ -219,7 +206,7 @@ func formatTokenDetails(storage *TokenStorage) string { expiry := storage.ExpiresAt.Format(time.RFC3339) remaining := time.Until(storage.ExpiresAt) - expiryText := fmt.Sprintf("%s (in %s)", expiry, formatDurationHuman(remaining)) + expiryText := fmt.Sprintf("%s (in %s)", expiry, FormatDurationHuman(remaining)) b.WriteString(lipgloss.NewStyle(). Foreground(colorBright). Render(expiryText)) @@ -252,30 +239,3 @@ func formatTokenDetails(storage *TokenStorage) string { return b.String() } - -// formatDurationHuman formats a duration in human-readable format -func formatDurationHuman(d time.Duration) string { - if d < 0 { - return "expired" - } - - hours := int(d.Hours()) - minutes := int(d.Minutes()) % 60 - - if hours > 24 { - days := hours / 24 - hours %= 24 - return fmt.Sprintf("%dd %dh", days, hours) - } - - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, minutes) - } - - if minutes > 0 { - return fmt.Sprintf("%dm", minutes) - } - - seconds := int(d.Seconds()) - return fmt.Sprintf("%ds", seconds) -} diff --git a/tui/unified_flow_view.go b/tui/unified_flow_view.go index bfb331d..abacfa5 100644 --- a/tui/unified_flow_view.go +++ b/tui/unified_flow_view.go @@ -309,24 +309,8 @@ func (m *UnifiedFlowModel) renderTokenInfo() string { remaining := time.Until(m.tokenStorage.ExpiresAt) content.WriteString(labelStyle.Render("Expires In:")) content.WriteString(" ") - content.WriteString(valueStyle.Render(formatDuration(remaining))) + content.WriteString(valueStyle.Render(FormatDurationHuman(remaining))) } return boxStyle.Render(content.String()) } - -// formatDuration formats duration in a human-readable way -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := int(d.Hours()) - m := int(d.Minutes()) % 60 - s := int(d.Seconds()) % 60 - - if h > 0 { - return fmt.Sprintf("%dh%dm%ds", h, m, s) - } - if m > 0 { - return fmt.Sprintf("%dm%ds", m, s) - } - return fmt.Sprintf("%ds", s) -}