diff --git a/CLAUDE.md b/CLAUDE.md index 0ac4c3f..865f35e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,117 @@ On each run (`main.go:run()`): - OAuth errors parsed from JSON response: `error` and `error_description` fields - HTTP retry client (`github.com/appleboy/go-httpretry`) wraps all OAuth requests +### Terminal UI Architecture + +**TUI Package (`tui/`)** + +The CLI features an interactive Terminal User Interface (TUI) built with Bubble Tea, providing visual feedback during OAuth flows. + +**Manager Interface Pattern:** + +```go +type Manager interface { + ShowHeader(clientMode, serverURL, clientID string) + ShowFlowSelection(method string) + RunBrowserFlow(ctx context.Context, perform BrowserFlowFunc) (*TokenStorage, bool, error) + RunDeviceFlow(ctx context.Context, perform DeviceFlowFunc) (*TokenStorage, error) + ShowTokenInfo(storage *TokenStorage) + // ... other display methods +} +``` + +**Two Implementations:** + +1. **SimplePrintManager** (`tui/simple_manager.go`): + - Uses `fmt.Printf` for simple, parseable output + - Preserves original CLI behavior (backward compatible) + - Used in CI environments, piped output, small terminals + - No dependencies beyond standard library + +2. **BubbleTeaManager** (`tui/bubbletea_manager.go`): + - Rich interactive TUI with Bubble Tea + - Animated spinners, progress bars, timers + - Visual step indicators and status updates + - Gracefully falls back to SimplePrintManager on error + +**Automatic UI Selection:** + +The CLI auto-detects the environment (`tui/manager.go:shouldUseSimpleUI()`): + +- **Simple Mode**: CI environments (GITHUB_ACTIONS, GITLAB_CI, etc.), non-TTY output, TERM=dumb, small terminals (<60x20) +- **Interactive Mode**: Normal terminals with sufficient size and TTY support +- **Fully Automatic**: No flags needed, environment detection handles everything + +**Message Passing Architecture:** + +OAuth flow functions send progress updates through channels: + +```go +type FlowUpdate struct { + Type FlowUpdateType // StepStart, StepProgress, StepComplete, etc. + Step int // Current step (1-indexed) + TotalSteps int // Total steps in flow + Message string // Human-readable status + Progress float64 // 0.0 to 1.0 for progress bars + Data map[string]any // Additional contextual data +} +``` + +**Flow Wrapper Pattern:** + +Original OAuth functions remain unchanged. Wrapper functions add progress reporting: + +- `performBrowserFlowWithUpdates()` wraps `performBrowserFlow()` +- `performDeviceFlowWithUpdates()` wraps `performDeviceFlow()` +- Wrappers send FlowUpdate messages through channels +- TUI models subscribe to these channels and update UI + +**Bubble Tea Models:** + +- **BrowserModel** (`tui/browser_model.go`, `tui/browser_view.go`): + - Displays step progress, countdown timer, progress bar + - Shows authorization URL in step 1 + - Animated spinner during callback wait + - Handles timeout and error states + +- **DeviceModel** (`tui/device_model.go`, `tui/device_view.go`): + - Shows device code in bordered box + - Displays verification URL and user code + - Live polling status with count and interval + - Backoff warnings when server requests slower polling + - Elapsed time counter + +**Reusable Components (`tui/components/`):** + +- `StepIndicator`: Multi-step progress (●/○ symbols) +- `Timer`: Countdown or elapsed time display +- `ProgressBar`: Visual progress bar with percentage +- `InfoBox`: Bordered information display + +**Styling System (`tui/styles.go`):** + +- Consistent color palette (primary, secondary, success, error, warning, info) +- Predefined styles for all UI elements +- Helper functions: `RenderBox()`, `RenderError()`, `RenderSuccess()`, etc. +- Uses Lipgloss for terminal styling + +**Integration Flow:** + +1. `main.go:main()` calls `tui.SelectManager()` based on environment +2. Manager passed to `run()` and `authenticate()` +3. OAuth flow functions use wrapper versions that send updates +4. Manager runs Bubble Tea program consuming update channel +5. On completion, Manager returns OAuth tokens to caller + +**Design Benefits:** + +- **No Breaking Changes**: Original OAuth logic untouched +- **Clean Separation**: UI and business logic decoupled +- **Easy Testing**: Can test OAuth without UI +- **Flexible**: Easy to add new UI modes +- **Environment Adaptive**: Auto-selects appropriate mode +- **User Control**: Flags override detection + ## Key Implementation Details ### Public vs. Confidential Clients diff --git a/README.md b/README.md index 7c06ecf..5f79e74 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This mirrors the authentication strategy used by **GitHub CLI**, **Azure CLI**, - [Why This CLI?](#why-this-cli) - [Quick Start](#quick-start) - [How It Works](#how-it-works) +- [Interactive Terminal UI](#interactive-terminal-ui) - [Configuration](#configuration) - [Authentication Flows](#authentication-flows) - [Token Storage](#token-storage) @@ -154,6 +155,90 @@ On each run the CLI follows this order: --- +## Interactive Terminal UI + +Authgate CLI features a rich **interactive Terminal User Interface (TUI)** built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), providing visual feedback during OAuth authentication flows. + +### Features + +The TUI provides: + +- **Visual Progress Indicators**: Step-by-step progress with animated spinners +- **Real-time Timers**: Countdown for browser flow, elapsed time for device flow +- **Progress Bars**: Visual representation of callback timeout +- **Polling Status**: Live updates showing device flow polling count and intervals +- **Backoff Warnings**: Clear notifications when server requests slower polling +- **Clean Layout**: Bordered boxes, color-coded messages, and structured information + +### Browser Flow (Authorization Code + PKCE) + +``` +╭─────────────────────────────────────────────────╮ +│ Authorization Code Flow with PKCE │ +├─────────────────────────────────────────────────┤ +│ │ +│ ● Step 1/3: Opening browser ✓ │ +│ ● Step 2/3: Waiting for callback ◐ │ +│ ○ Step 3/3: Exchanging tokens │ +│ │ +│ Time remaining: 1:23 / 2:00 │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░ 48% │ +│ │ +│ ◐ Please complete authorization in your browser │ +│ │ +│ Press Ctrl+C to cancel │ +╰─────────────────────────────────────────────────╯ +``` + +### Device Flow (Device Authorization Grant) + +``` +╭─────────────────────────────────────────────────╮ +│ Device Authorization Grant Flow │ +├─────────────────────────────────────────────────┤ +│ │ +│ ╔═══════════════════════════════════════════╗ │ +│ ║ Device Authorization ║ │ +│ ║ ║ │ +│ ║ Visit: https://auth.example.com/device ║ │ +│ ║ ?user_code=ABCD-EFGH ║ │ +│ ║ ║ │ +│ ║ Or go to: https://auth.example.com ║ │ +│ ║ And enter: ABCD-EFGH ║ │ +│ ╚═══════════════════════════════════════════╝ │ +│ │ +│ ◐ Waiting for authorization... (poll #8, 5s) │ +│ │ +│ Elapsed: 0:43 │ +│ │ +│ Press Ctrl+C to cancel │ +╰─────────────────────────────────────────────────╯ +``` + +### UI Mode Selection + +The CLI automatically chooses the appropriate UI mode: + +**Interactive TUI Mode** (default): + +- Normal terminal with sufficient size (≥60x20) +- TTY detected +- TERM environment variable set (not "dumb") + +**Simple Printf Mode** (automatic fallback): + +- CI environments (GitHub Actions, GitLab CI, CircleCI, etc.) +- Output piped to file or another command +- Terminal too small (< 60 columns or < 20 rows) +- `TERM=dumb` or TERM unset +- SSH session without display forwarding + +### Note on UI Selection + +The CLI automatically detects the environment and selects the appropriate UI mode. No configuration or flags are needed - it just works. + +--- + ## Configuration Configuration is resolved in priority order: **CLI flag → environment variable → default**. diff --git a/browser_flow.go b/browser_flow.go index 3ac73f3..f8efb21 100644 --- a/browser_flow.go +++ b/browser_flow.go @@ -10,6 +10,8 @@ import ( "net/url" "strings" "time" + + "github.com/go-authgate/cli/tui" ) // performBrowserFlow runs the Authorization Code Flow with PKCE. @@ -162,3 +164,133 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*TokenStorage ClientID: clientID, }, nil } + +// performBrowserFlowWithUpdates runs the Authorization Code Flow with PKCE +// and sends progress updates through the provided channel. +// +// Returns: +// - (storage, true, nil) on success +// - (nil, false, nil) when openBrowser() fails — caller should fall back to Device Code Flow +// - (nil, false, err) on a hard error (CSRF mismatch, token exchange failure, etc.) +func performBrowserFlowWithUpdates( + ctx context.Context, + updates chan<- tui.FlowUpdate, +) (*tui.TokenStorage, bool, error) { + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 1, + TotalSteps: 3, + Message: "Generating PKCE parameters", + } + + state, err := generateState() + if err != nil { + return nil, false, fmt.Errorf("failed to generate state: %w", err) + } + + pkce, err := GeneratePKCE() + if err != nil { + return nil, false, fmt.Errorf("failed to generate PKCE: %w", err) + } + + authURL := buildAuthURL(state, pkce) + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 1, + TotalSteps: 3, + Message: "Opening browser", + Data: map[string]interface{}{ + "url": authURL, + }, + } + + if err := openBrowser(ctx, authURL); err != nil { + // Browser failed to open — signal the caller to fall back immediately. + updates <- tui.FlowUpdate{ + Type: tui.StepError, + Message: fmt.Sprintf("Could not open browser: %v", err), + } + return nil, false, nil + } + + updates <- tui.FlowUpdate{Type: tui.BrowserOpened} + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 2, + TotalSteps: 3, + Message: "Waiting for callback", + Data: map[string]interface{}{ + "port": callbackPort, + }, + } + + // Start goroutine to send timer updates + done := make(chan struct{}) + defer close(done) + + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + startTime := time.Now() + + for { + select { + case <-done: + return + case <-ticker.C: + elapsed := time.Since(startTime) + progress := float64(elapsed) / float64(callbackTimeout) + if progress > 1.0 { + progress = 1.0 + } + updates <- tui.FlowUpdate{ + Type: tui.TimerTick, + Progress: progress, + Data: map[string]interface{}{ + "elapsed": elapsed, + "timeout": callbackTimeout, + }, + } + } + } + }() + + storage, err := startCallbackServer(ctx, callbackPort, state, + func(callbackCtx context.Context, code string) (*TokenStorage, error) { + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 3, + TotalSteps: 3, + Message: "Exchanging tokens", + } + return exchangeCode(callbackCtx, code, pkce.Verifier) + }) + if err != nil { + if errors.Is(err, ErrCallbackTimeout) { + updates <- tui.FlowUpdate{ + Type: tui.StepError, + Message: "Browser authorization timed out", + } + return nil, false, nil + } + return nil, false, fmt.Errorf("authentication failed: %w", err) + } + + updates <- tui.FlowUpdate{Type: tui.CallbackReceived} + storage.Flow = "browser" + + if err := saveTokens(storage); err != nil { + updates <- tui.FlowUpdate{ + Type: tui.StepError, + Message: fmt.Sprintf("Warning: Failed to save tokens: %v", err), + } + } + + updates <- tui.FlowUpdate{ + Type: tui.StepComplete, + Step: 3, + TotalSteps: 3, + } + + return toTUITokenStorage(storage), true, nil +} diff --git a/device_flow.go b/device_flow.go index 1a87034..3f08d5b 100644 --- a/device_flow.go +++ b/device_flow.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/go-authgate/cli/tui" "golang.org/x/oauth2" ) @@ -287,3 +288,185 @@ func exchangeDeviceCode( Expiry: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), }, nil } + +// performDeviceFlowWithUpdates runs the OAuth 2.0 Device Authorization Grant +// and sends progress updates through the provided channel. +func performDeviceFlowWithUpdates( + ctx context.Context, + updates chan<- tui.FlowUpdate, +) (*tui.TokenStorage, error) { + config := &oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: serverURL + "/oauth/device/code", + TokenURL: serverURL + "/oauth/token", + }, + Scopes: strings.Fields(scope), + } + + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 1, + TotalSteps: 2, + Message: "Requesting device code", + } + + deviceAuth, err := requestDeviceCode(ctx) + if err != nil { + return nil, fmt.Errorf("device code request failed: %w", err) + } + + updates <- tui.FlowUpdate{ + Type: tui.DeviceCodeReceived, + Step: 1, + TotalSteps: 2, + Data: map[string]interface{}{ + "user_code": deviceAuth.UserCode, + "verification_uri": deviceAuth.VerificationURI, + "verification_uri_complete": deviceAuth.VerificationURIComplete, + }, + } + + updates <- tui.FlowUpdate{ + Type: tui.StepStart, + Step: 2, + TotalSteps: 2, + Message: "Waiting for authorization", + } + + token, err := pollForTokenWithUpdates(ctx, config, deviceAuth, updates) + if err != nil { + return nil, fmt.Errorf("token poll failed: %w", err) + } + + updates <- tui.FlowUpdate{ + Type: tui.StepComplete, + Step: 2, + TotalSteps: 2, + } + + storage := &TokenStorage{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.Type(), + ExpiresAt: token.Expiry, + ClientID: clientID, + Flow: "device", + } + + if err := saveTokens(storage); err != nil { + updates <- tui.FlowUpdate{ + Type: tui.StepError, + Message: fmt.Sprintf("Warning: Failed to save tokens: %v", err), + } + } + + return toTUITokenStorage(storage), nil +} + +// pollForTokenWithUpdates polls for a token while sending progress updates. +// Implements exponential backoff for slow_down errors per RFC 8628. +func pollForTokenWithUpdates( + ctx context.Context, + config *oauth2.Config, + deviceAuth *oauth2.DeviceAuthResponse, + updates chan<- tui.FlowUpdate, +) (*oauth2.Token, error) { + interval := deviceAuth.Interval + if interval == 0 { + interval = 5 + } + + pollInterval := time.Duration(interval) * time.Second + backoffMultiplier := 1.0 + pollCount := 0 + startTime := time.Now() + + pollTicker := time.NewTicker(pollInterval) + defer pollTicker.Stop() + + uiTicker := time.NewTicker(500 * time.Millisecond) + defer uiTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-pollTicker.C: + pollCount++ + updates <- tui.FlowUpdate{ + Type: tui.PollingUpdate, + Message: "Polling authorization server", + Data: map[string]interface{}{ + "poll_count": pollCount, + "interval": pollInterval, + "elapsed": time.Since(startTime), + }, + } + + token, err := exchangeDeviceCode( + ctx, + config.Endpoint.TokenURL, + config.ClientID, + deviceAuth.DeviceCode, + ) + if err != nil { + var oauthErr *oauth2.RetrieveError + if errors.As(err, &oauthErr) { + var errResp ErrorResponse + if jsonErr := json.Unmarshal(oauthErr.Body, &errResp); jsonErr == nil { + switch errResp.Error { + case "authorization_pending": + continue + + case "slow_down": + oldInterval := pollInterval + backoffMultiplier *= 1.5 + newInterval := time.Duration(float64(pollInterval) * backoffMultiplier) + if newInterval > 60*time.Second { + newInterval = 60 * time.Second + } + pollInterval = newInterval + pollTicker.Reset(pollInterval) + + updates <- tui.FlowUpdate{ + Type: tui.BackoffChanged, + Message: "Server requested slower polling", + Data: map[string]interface{}{ + "old_interval": oldInterval, + "new_interval": newInterval, + }, + } + continue + + case "expired_token": + return nil, fmt.Errorf("device code expired, please restart the flow") + + case "access_denied": + return nil, fmt.Errorf("user denied authorization") + + default: + return nil, fmt.Errorf( + "authorization failed: %s - %s", + errResp.Error, + errResp.ErrorDescription, + ) + } + } + } + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return token, nil + + case <-uiTicker.C: + updates <- tui.FlowUpdate{ + Type: tui.TimerTick, + Data: map[string]interface{}{ + "elapsed": time.Since(startTime), + }, + } + } + } +} diff --git a/go.mod b/go.mod index 2ee8d6c..6ed1caa 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,37 @@ module github.com/go-authgate/cli -go 1.24.0 +go 1.24.2 require ( github.com/appleboy/go-httpretry v0.11.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/mattn/go-isatty v0.0.20 golang.org/x/oauth2 v0.35.0 + golang.org/x/term v0.40.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 066abad..db9bab9 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,60 @@ github.com/appleboy/go-httpretry v0.11.0 h1:LI2kFDBI9ghxIip9dJz3uRMEVEwSSOC1bjS177QCi+w= github.com/appleboy/go-httpretry v0.11.0/go.mod h1:96v1IO6wg1+S10iFbOM3O8rn2vkFw8+uH4mDPhGoz+E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go index d78814b..6a021f5 100644 --- a/main.go +++ b/main.go @@ -12,55 +12,55 @@ import ( "strings" "syscall" "time" + + "github.com/go-authgate/cli/tui" ) func main() { initConfig() + // Select UI manager based on environment detection + uiManager := tui.SelectManager() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - exitCode := run(ctx) + exitCode := run(ctx, uiManager) stop() os.Exit(exitCode) } -func run(ctx context.Context) int { +func run(ctx context.Context, ui tui.Manager) int { clientMode := "public (PKCE)" if !isPublicClient() { clientMode = "confidential" } - fmt.Printf("=== AuthGate Hybrid CLI (Browser + Device Code Flow) ===\n") - fmt.Printf("Client mode : %s\n", clientMode) - fmt.Printf("Server URL : %s\n", serverURL) - fmt.Printf("Client ID : %s\n", clientID) - fmt.Println() + ui.ShowHeader(clientMode, serverURL, clientID) var storage *TokenStorage // Try to reuse or refresh existing tokens. existing, err := loadTokens() if err == nil && existing != nil { - fmt.Println("Found existing tokens.") + ui.ShowExistingTokens() if time.Now().Before(existing.ExpiresAt) { - fmt.Println("Access token is still valid, using it.") + ui.ShowTokenStillValid() storage = existing } else { - fmt.Println("Access token expired, attempting refresh...") + ui.ShowTokenExpired() newStorage, err := refreshAccessToken(ctx, existing.RefreshToken) if err != nil { - fmt.Printf("Refresh failed: %v\n", err) - fmt.Println("Starting new authentication flow...") + ui.ShowRefreshFailed(err) } else { storage = newStorage - fmt.Println("Token refreshed successfully.") + ui.ShowRefreshSuccess() } } } else { - fmt.Println("No existing tokens found, starting authentication flow...") + ui.ShowNoExistingTokens() } // No valid tokens — select and run the appropriate flow. if storage == nil { - storage, err = authenticate(ctx) + storage, err = authenticate(ctx, ui) if err != nil { fmt.Fprintf(os.Stderr, "Authentication failed: %v\n", err) return 1 @@ -68,43 +68,31 @@ func run(ctx context.Context) int { } // Display token info. - fmt.Printf("\n========================================\n") - fmt.Printf("Current Token Info:\n") - preview := storage.AccessToken - if len(preview) > 50 { - preview = preview[:50] - } - fmt.Printf("Access Token : %s...\n", preview) - fmt.Printf("Token Type : %s\n", storage.TokenType) - fmt.Printf("Expires In : %s\n", time.Until(storage.ExpiresAt).Round(time.Second)) - if storage.Flow != "" { - fmt.Printf("Auth Flow : %s\n", storage.Flow) - } - fmt.Printf("========================================\n") + ui.ShowTokenInfo(toTUITokenStorage(storage)) // Verify token against server. - fmt.Println("\nVerifying token with server...") - if err := verifyToken(ctx, storage.AccessToken); err != nil { - fmt.Printf("Token verification failed: %v\n", err) + info, err := verifyToken(ctx, storage.AccessToken) + if err != nil { + ui.ShowVerification(false, err.Error()) } else { - fmt.Println("Token verified successfully.") + ui.ShowVerification(true, info) } // Demonstrate auto-refresh on 401. - fmt.Println("\nDemonstrating automatic refresh on API call...") - if err := makeAPICallWithAutoRefresh(ctx, storage); err != nil { + ui.ShowAutoRefreshDemo() + if err := makeAPICallWithAutoRefresh(ctx, storage, ui); err != nil { if err == ErrRefreshTokenExpired { - fmt.Println("Refresh token expired, re-authenticating...") - storage, err = authenticate(ctx) + ui.ShowRefreshTokenExpired() + storage, err = authenticate(ctx, ui) if err != nil { fmt.Fprintf(os.Stderr, "Re-authentication failed: %v\n", err) return 1 } - if err := makeAPICallWithAutoRefresh(ctx, storage); err != nil { + if err := makeAPICallWithAutoRefresh(ctx, storage, ui); err != nil { fmt.Fprintf(os.Stderr, "API call failed after re-authentication: %v\n", err) return 1 } - fmt.Println("API call successful after re-authentication.") + ui.ShowReAuthSuccess() } else { fmt.Fprintf(os.Stderr, "API call failed: %v\n", err) } @@ -118,29 +106,32 @@ func run(ctx context.Context) int { // 2. Environment signals (SSH, no display, port busy) → Device Code Flow // 3. Browser available → Authorization Code Flow with PKCE // - openBrowser() error → immediate fallback to Device Code Flow -func authenticate(ctx context.Context) (*TokenStorage, error) { +func authenticate(ctx context.Context, ui tui.Manager) (*TokenStorage, error) { if forceDevice { - fmt.Println("Auth method : Device Code Flow (forced via flag)") - return performDeviceFlow(ctx) + ui.ShowFlowSelection("Device Code Flow (forced via flag)") + tuiStorage, err := ui.RunDeviceFlow(ctx, performDeviceFlowWithUpdates) + return fromTUITokenStorage(tuiStorage), err } avail := checkBrowserAvailability(ctx, callbackPort) if !avail.Available { - fmt.Printf("Auth method : Device Code Flow (%s)\n", avail.Reason) - return performDeviceFlow(ctx) + ui.ShowFlowSelection(fmt.Sprintf("Device Code Flow (%s)", avail.Reason)) + tuiStorage, err := ui.RunDeviceFlow(ctx, performDeviceFlowWithUpdates) + return fromTUITokenStorage(tuiStorage), err } - fmt.Println("Auth method : Authorization Code Flow (browser)") - storage, ok, err := performBrowserFlow(ctx) + ui.ShowFlowSelection("Authorization Code Flow (browser)") + tuiStorage, ok, err := ui.RunBrowserFlow(ctx, performBrowserFlowWithUpdates) if err != nil { return nil, err } if !ok { // openBrowser() failed; fall back to Device Code Flow immediately. - fmt.Println("Auth method : Device Code Flow (browser unavailable)") - return performDeviceFlow(ctx) + ui.ShowFlowSelection("Device Code Flow (browser unavailable)") + tuiStorage, err := ui.RunDeviceFlow(ctx, performDeviceFlowWithUpdates) + return fromTUITokenStorage(tuiStorage), err } - return storage, nil + return fromTUITokenStorage(tuiStorage), nil } // ----------------------------------------------------------------------- @@ -234,41 +225,40 @@ func refreshAccessToken(ctx context.Context, refreshToken string) (*TokenStorage // Token verification / API demo // ----------------------------------------------------------------------- -func verifyToken(ctx context.Context, accessToken string) error { +func verifyToken(ctx context.Context, accessToken string) (string, error) { ctx, cancel := context.WithTimeout(ctx, tokenVerificationTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := retryClient.DoWithContext(ctx, req) if err != nil { - return fmt.Errorf("request failed: %w", err) + return "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return "", fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { var errResp ErrorResponse if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil { - return fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription) + return "", fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription) } - return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) } - fmt.Printf("Token Info: %s\n", string(body)) - return nil + return string(body), nil } // makeAPICallWithAutoRefresh demonstrates the 401 → refresh → retry pattern. -func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage) error { +func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage, ui tui.Manager) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -282,7 +272,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage) erro defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - fmt.Println("Access token rejected (401), refreshing...") + ui.ShowAccessTokenRejected() newStorage, err := refreshAccessToken(ctx, storage.RefreshToken) if err != nil { @@ -296,7 +286,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage) erro storage.RefreshToken = newStorage.RefreshToken storage.ExpiresAt = newStorage.ExpiresAt - fmt.Println("Token refreshed, retrying API call...") + ui.ShowTokenRefreshedRetrying() req, err = http.NewRequestWithContext( ctx, @@ -325,6 +315,6 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage) erro return fmt.Errorf("API call failed with status %d: %s", resp.StatusCode, string(body)) } - fmt.Println("API call successful!") + ui.ShowAPICallSuccess() return nil } diff --git a/tui/browser_model.go b/tui/browser_model.go new file mode 100644 index 0000000..4821a8e --- /dev/null +++ b/tui/browser_model.go @@ -0,0 +1,208 @@ +package tui + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/go-authgate/cli/tui/components" +) + +// BrowserModel represents the state of the browser OAuth flow TUI. +type BrowserModel struct { + currentStep int + totalSteps int + stepNames []string + authURL string + elapsed time.Duration + timeout time.Duration + progress float64 + spinner spinner.Model + stepIndicator *components.StepIndicator + timer *components.Timer + progressBar *components.ProgressBar + status string + done bool + success bool + storage *TokenStorage + ok bool + err error + updatesCh <-chan FlowUpdate + width int + height int + cancel context.CancelFunc + userCancelled bool +} + +// NewBrowserModel creates a new browser flow TUI model. +func NewBrowserModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *BrowserModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = SpinnerStyle + + return &BrowserModel{ + totalSteps: 3, + stepNames: []string{"Opening browser", "Waiting for callback", "Exchanging tokens"}, + spinner: s, + stepIndicator: components.NewStepIndicator( + 3, + []string{"Opening browser", "Waiting for callback", "Exchanging tokens"}, + ), + timer: components.NewCountdownTimer(2 * time.Minute), + progressBar: components.NewProgressBar(40), + timeout: 2 * time.Minute, + updatesCh: updatesCh, + status: "Initializing...", + width: 80, + height: 24, + cancel: cancel, + userCancelled: false, + } +} + +// Init initializes the model. +func (m *BrowserModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + waitForUpdate(m.updatesCh), + ) +} + +// Update handles messages and updates the model state. +func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + // Cancel the OAuth flow + if m.cancel != nil { + m.cancel() + } + m.userCancelled = true + m.status = "Cancelled by user" + // Give a brief moment for the cancellation to propagate + time.Sleep(100 * time.Millisecond) + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // Update progress bar width based on available space + if m.width > 50 { + m.progressBar.Width = m.width - 30 + } + return m, nil + + case FlowUpdate: + cmd := m.handleFlowUpdate(msg) + return m, cmd + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case errorMsg: + m.err = msg.err + m.done = true + m.success = false + return m, tea.Quit + + case successMsg: + m.storage = msg.storage + m.ok = msg.ok + m.done = true + m.success = true + return m, tea.Quit + } + + return m, nil +} + +// handleFlowUpdate processes FlowUpdate messages. +func (m *BrowserModel) handleFlowUpdate(update FlowUpdate) tea.Cmd { + switch update.Type { + case StepStart: + m.currentStep = update.Step + m.stepIndicator.SetCurrentStep(update.Step) + m.status = update.Message + if m.currentStep == 2 { + // Starting callback wait + m.timer = components.NewCountdownTimer(m.timeout) + } + if url := update.GetString("url"); url != "" { + m.authURL = url + } + + case StepProgress: + m.status = update.Message + + case StepComplete: + m.currentStep = update.Step + 1 + m.stepIndicator.SetCurrentStep(update.Step + 1) + if update.Step == m.totalSteps { + // All steps complete + return func() tea.Msg { + return successMsg{storage: m.storage, ok: true} + } + } + + case StepError: + m.err = fmt.Errorf("%s", update.Message) + if update.Message == "Browser authorization timed out" || + update.Message == "Could not open browser: exit status 1" { + // Fallback to device flow + return func() tea.Msg { + return successMsg{storage: nil, ok: false} + } + } + return func() tea.Msg { + return errorMsg{err: m.err} + } + + case TimerTick: + m.elapsed = update.GetDuration("elapsed") + m.progress = update.Progress + m.timer.Update(m.elapsed) + m.progressBar.SetProgress(m.progress) + + case BrowserOpened: + m.status = "Browser opened successfully" + + case CallbackReceived: + m.status = "Callback received, exchanging tokens..." + } + + return waitForUpdate(m.updatesCh) +} + +// View renders the model. +func (m *BrowserModel) View() string { + // Let browser_view.go handle the rendering + return renderBrowserView(m) +} + +// Helper message types for internal communication +type errorMsg struct { + err error +} + +type successMsg struct { + storage *TokenStorage + ok bool +} + +// waitForUpdate waits for the next update from the channel. +func waitForUpdate(ch <-chan FlowUpdate) tea.Cmd { + return func() tea.Msg { + update, ok := <-ch + if !ok { + // Channel closed + return nil + } + return update + } +} diff --git a/tui/browser_view.go b/tui/browser_view.go new file mode 100644 index 0000000..0b66254 --- /dev/null +++ b/tui/browser_view.go @@ -0,0 +1,111 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// renderBrowserView renders the browser OAuth flow view. +func renderBrowserView(m *BrowserModel) string { + if m.done { + return renderBrowserComplete(m) + } + + var b strings.Builder + + // Title + titleBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(0, 2). + Width(m.width - 4). + Align(lipgloss.Center) + + b.WriteString(titleBox.Render(TitleStyle.Render("Authorization Code Flow with PKCE"))) + b.WriteString("\n\n") + + // Step indicator + b.WriteString(m.stepIndicator.View()) + b.WriteString("\n\n") + + // Show URL when available (step 1) + if m.authURL != "" && m.currentStep == 1 { + urlBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(colorInfo). + Padding(0, 1). + Foreground(colorInfo) + b.WriteString(urlBox.Render(fmt.Sprintf("🌐 %s", m.authURL))) + b.WriteString("\n\n") + } + + // Timer and progress bar (step 2 - waiting for callback) + if m.currentStep == 2 { + b.WriteString(m.timer.View()) + b.WriteString("\n") + b.WriteString(m.progressBar.View()) + b.WriteString("\n\n") + } + + // Status with spinner + statusLine := lipgloss.NewStyle(). + Foreground(colorSecondary). + Render(fmt.Sprintf("%s %s", m.spinner.View(), m.status)) + b.WriteString(statusLine) + b.WriteString("\n\n") + + // Help text + helpText := HelpStyle.Render("Press Ctrl+C to cancel") + b.WriteString(helpText) + b.WriteString("\n") + + return AppContainerStyle.Render(b.String()) +} + +// renderBrowserComplete renders the completion state (success or error). +func renderBrowserComplete(m *BrowserModel) string { + var b strings.Builder + + if m.err != nil { + // Error state + b.WriteString("\n") + b.WriteString(RenderError(fmt.Sprintf("Authentication failed: %v", m.err))) + b.WriteString("\n\n") + } else if !m.ok { + // Fallback to device flow + b.WriteString("\n") + b.WriteString( + RenderWarning("Browser flow unavailable, falling back to Device Code Flow..."), + ) + b.WriteString("\n\n") + } else { + // Success state + b.WriteString("\n") + successBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorSuccess). + Padding(1, 2). + Foreground(colorSuccess) + + b.WriteString(successBox.Render("✓ Authentication successful!")) + b.WriteString("\n\n") + + if m.storage != nil { + // Show token preview + infoStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + + preview := m.storage.AccessToken + if len(preview) > 50 { + preview = preview[:50] + "..." + } + + b.WriteString(infoStyle.Render(fmt.Sprintf("Access Token: %s", preview))) + b.WriteString("\n") + } + } + + return b.String() +} diff --git a/tui/bubbletea_manager.go b/tui/bubbletea_manager.go new file mode 100644 index 0000000..e2945b8 --- /dev/null +++ b/tui/bubbletea_manager.go @@ -0,0 +1,218 @@ +package tui + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" +) + +// BubbleTeaManager implements Manager using Bubble Tea for interactive TUI. +// This provides a rich, interactive terminal UI with progress indicators, +// timers, and visual feedback. +type BubbleTeaManager struct{} + +// NewBubbleTeaManager creates a new BubbleTeaManager. +func NewBubbleTeaManager() *BubbleTeaManager { + return &BubbleTeaManager{} +} + +// For non-flow methods, delegate to SimpleManager to keep output consistent. +func (m *BubbleTeaManager) ShowHeader(clientMode, serverURL, clientID string) { + simple := NewSimpleManager() + simple.ShowHeader(clientMode, serverURL, clientID) +} + +func (m *BubbleTeaManager) ShowFlowSelection(method string) { + simple := NewSimpleManager() + simple.ShowFlowSelection(method) +} + +// RunBrowserFlow executes the browser OAuth flow with an interactive TUI. +func (m *BubbleTeaManager) RunBrowserFlow( + ctx context.Context, + perform BrowserFlowFunc, +) (*TokenStorage, bool, 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 + } + resultCh := make(chan result, 1) + + // Start OAuth flow in a goroutine + go func() { + storage, ok, err := perform(flowCtx, updates) + resultCh <- result{storage, ok, err} + close(updates) + }() + + // Create and run Bubble Tea program + model := NewBrowserModel(updates, cancel) + p := tea.NewProgram(model) + + finalModel, err := p.Run() + if err != nil { + // If TUI fails, cancel OAuth flow and fall back to simple mode + cancel() + simple := NewSimpleManager() + return simple.RunBrowserFlow(ctx, perform) + } + + // Wait for OAuth flow to complete or timeout + select { + case res := <-resultCh: + // Extract final state from model + if bm, ok := finalModel.(*BrowserModel); ok { + // Check if user cancelled + if bm.userCancelled { + return nil, false, context.Canceled + } + if bm.storage != nil { + return bm.storage, bm.ok, nil + } + } + return res.storage, res.ok, res.err + case <-ctx.Done(): + // Parent context cancelled + return nil, false, ctx.Err() + } +} + +// RunDeviceFlow executes the device code OAuth flow with an interactive TUI. +func (m *BubbleTeaManager) RunDeviceFlow( + ctx context.Context, + perform DeviceFlowFunc, +) (*TokenStorage, 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 + err error + } + resultCh := make(chan result, 1) + + // Start OAuth flow in a goroutine + go func() { + storage, err := perform(flowCtx, updates) + resultCh <- result{storage, err} + close(updates) + }() + + // Create and run Bubble Tea program + model := NewDeviceModel(updates, cancel) + p := tea.NewProgram(model) + + finalModel, err := p.Run() + if err != nil { + // If TUI fails, cancel OAuth flow and fall back to simple mode + cancel() + simple := NewSimpleManager() + return simple.RunDeviceFlow(ctx, perform) + } + + // Wait for OAuth flow to complete or timeout + select { + case res := <-resultCh: + // Extract final state from model + if dm, ok := finalModel.(*DeviceModel); ok { + // Check if user cancelled + if dm.userCancelled { + return nil, context.Canceled + } + if dm.storage != nil { + return dm.storage, nil + } + } + return res.storage, res.err + case <-ctx.Done(): + // Parent context cancelled + return nil, ctx.Err() + } +} + +func (m *BubbleTeaManager) ShowTokenInfo(storage *TokenStorage) { + simple := NewSimpleManager() + simple.ShowTokenInfo(storage) +} + +func (m *BubbleTeaManager) ShowVerification(success bool, info string) { + simple := NewSimpleManager() + simple.ShowVerification(success, info) +} + +func (m *BubbleTeaManager) ShowExistingTokens() { + simple := NewSimpleManager() + simple.ShowExistingTokens() +} + +func (m *BubbleTeaManager) ShowTokenStillValid() { + simple := NewSimpleManager() + simple.ShowTokenStillValid() +} + +func (m *BubbleTeaManager) ShowTokenExpired() { + simple := NewSimpleManager() + simple.ShowTokenExpired() +} + +func (m *BubbleTeaManager) ShowRefreshSuccess() { + simple := NewSimpleManager() + simple.ShowRefreshSuccess() +} + +func (m *BubbleTeaManager) ShowRefreshFailed(err error) { + simple := NewSimpleManager() + simple.ShowRefreshFailed(err) +} + +func (m *BubbleTeaManager) ShowNewAuthFlow() { + simple := NewSimpleManager() + simple.ShowNewAuthFlow() +} + +func (m *BubbleTeaManager) ShowNoExistingTokens() { + simple := NewSimpleManager() + simple.ShowNoExistingTokens() +} + +func (m *BubbleTeaManager) ShowAutoRefreshDemo() { + simple := NewSimpleManager() + simple.ShowAutoRefreshDemo() +} + +func (m *BubbleTeaManager) ShowAccessTokenRejected() { + simple := NewSimpleManager() + simple.ShowAccessTokenRejected() +} + +func (m *BubbleTeaManager) ShowTokenRefreshedRetrying() { + simple := NewSimpleManager() + simple.ShowTokenRefreshedRetrying() +} + +func (m *BubbleTeaManager) ShowAPICallSuccess() { + simple := NewSimpleManager() + simple.ShowAPICallSuccess() +} + +func (m *BubbleTeaManager) ShowRefreshTokenExpired() { + simple := NewSimpleManager() + simple.ShowRefreshTokenExpired() +} + +func (m *BubbleTeaManager) ShowReAuthSuccess() { + simple := NewSimpleManager() + simple.ShowReAuthSuccess() +} diff --git a/tui/components/components_test.go b/tui/components/components_test.go new file mode 100644 index 0000000..794b1ab --- /dev/null +++ b/tui/components/components_test.go @@ -0,0 +1,199 @@ +package components + +import ( + "strings" + "testing" + "time" +) + +func TestStepIndicator(t *testing.T) { + indicator := NewStepIndicator(3, []string{"Step One", "Step Two", "Step Three"}) + + if indicator.CurrentStep != 0 { + t.Errorf("Expected initial step to be 0, got %d", indicator.CurrentStep) + } + + if indicator.TotalSteps != 3 { + t.Errorf("Expected 3 total steps, got %d", indicator.TotalSteps) + } + + // Test setting current step + indicator.SetCurrentStep(2) + if indicator.CurrentStep != 2 { + t.Errorf("Expected current step to be 2, got %d", indicator.CurrentStep) + } + + // Test view renders + view := indicator.View() + 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) { + t.Run("Elapsed Timer", func(t *testing.T) { + timer := NewElapsedTimer() + + if timer.isCountdown { + t.Error("Elapsed timer should not be countdown") + } + + // Update with elapsed time + timer.Update(5 * time.Second) + + view := timer.View() + 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) { + timer := NewCountdownTimer(2 * time.Minute) + + if !timer.isCountdown { + t.Error("Countdown timer should be countdown") + } + + if timer.totalDuration != 2*time.Minute { + t.Errorf("Expected total duration 2m, got %v", timer.totalDuration) + } + + // Update with elapsed time + timer.Update(30 * time.Second) + + view := timer.View() + if !strings.Contains(view, "remaining") { + t.Error("Countdown timer view should contain 'remaining'") + } + }) +} + +func TestProgressBar(t *testing.T) { + bar := NewProgressBar(40) + + if bar.Progress != 0.0 { + t.Errorf("Expected initial progress to be 0.0, got %f", bar.Progress) + } + + if bar.Width != 40 { + t.Errorf("Expected width to be 40, got %d", bar.Width) + } + + // Test setting progress + bar.SetProgress(0.5) + if bar.Progress != 0.5 { + t.Errorf("Expected progress to be 0.5, got %f", bar.Progress) + } + + // Test clamping to 0-1 range + bar.SetProgress(-0.1) + if bar.Progress != 0.0 { + t.Errorf("Expected progress to be clamped to 0.0, got %f", bar.Progress) + } + + bar.SetProgress(1.5) + if bar.Progress != 1.0 { + t.Errorf("Expected progress to be clamped to 1.0, got %f", bar.Progress) + } + + // Test view renders + view := bar.View() + if view == "" { + t.Error("View should not be empty") + } + + // Test compact view + compact := bar.ViewCompact() + if compact == "" { + t.Error("ViewCompact should not be empty") + } +} + +func TestInfoBox(t *testing.T) { + box := NewInfoBox("Test Title", 60) + + if box.Title != "Test Title" { + t.Errorf("Expected title 'Test Title', got '%s'", box.Title) + } + + if box.Width != 60 { + t.Errorf("Expected width 60, got %d", box.Width) + } + + if len(box.Content) != 0 { + t.Errorf("Expected empty content, got %d lines", len(box.Content)) + } + + // Test adding lines + box.AddLine("Line 1") + box.AddLine("Line 2") + + if len(box.Content) != 2 { + t.Errorf("Expected 2 lines, got %d", len(box.Content)) + } + + // Test setting content + box.SetContent([]string{"New 1", "New 2", "New 3"}) + if len(box.Content) != 3 { + t.Errorf("Expected 3 lines, got %d", len(box.Content)) + } + + // Test clear + box.Clear() + if len(box.Content) != 0 { + t.Errorf("Expected empty content after clear, got %d lines", len(box.Content)) + } + + // Test view renders + box.AddLine("Test content") + view := box.View() + if view == "" { + t.Error("View should not be empty") + } + + simpleView := box.ViewSimple() + if simpleView == "" { + t.Error("ViewSimple should not be empty") + } +} + +func TestFormatPercentage(t *testing.T) { + tests := []struct { + progress float64 + expected string + }{ + {0.0, "0%"}, + {0.5, "50%"}, + {1.0, "100%"}, + {0.33, "33%"}, + {0.99, "99%"}, + } + + for _, tt := range tests { + result := formatPercentage(tt.progress) + if result != tt.expected { + t.Errorf( + "formatPercentage(%f): expected '%s', got '%s'", + tt.progress, + tt.expected, + result, + ) + } + } +} diff --git a/tui/components/info_box.go b/tui/components/info_box.go new file mode 100644 index 0000000..e13ac77 --- /dev/null +++ b/tui/components/info_box.go @@ -0,0 +1,92 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// InfoBox displays information in a bordered box +type InfoBox struct { + Title string + Content []string + Width int +} + +// NewInfoBox creates a new info box +func NewInfoBox(title string, width int) *InfoBox { + return &InfoBox{ + Title: title, + Content: []string{}, + Width: width, + } +} + +// SetContent sets the content lines +func (i *InfoBox) SetContent(lines []string) { + i.Content = lines +} + +// AddLine adds a line to the content +func (i *InfoBox) AddLine(line string) { + i.Content = append(i.Content, line) +} + +// Clear clears all content +func (i *InfoBox) Clear() { + i.Content = []string{} +} + +// View renders the info box +func (i *InfoBox) View() string { + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(1, 2). + Width(i.Width) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")) + + contentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + + var content strings.Builder + if i.Title != "" { + content.WriteString(titleStyle.Render(i.Title)) + content.WriteString("\n\n") + } + + for idx, line := range i.Content { + content.WriteString(contentStyle.Render(line)) + if idx < len(i.Content)-1 { + content.WriteString("\n") + } + } + + 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 new file mode 100644 index 0000000..dad3fc5 --- /dev/null +++ b/tui/components/progress_bar.go @@ -0,0 +1,67 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProgressBar displays a visual progress bar +type ProgressBar struct { + Progress float64 // 0.0 to 1.0 + Width int +} + +// NewProgressBar creates a new progress bar +func NewProgressBar(width int) *ProgressBar { + return &ProgressBar{ + Progress: 0.0, + Width: width, + } +} + +// SetProgress updates the progress (0.0 to 1.0) +func (p *ProgressBar) SetProgress(progress float64) { + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + p.Progress = progress +} + +// View renders the progress bar +func (p *ProgressBar) View() string { + filledStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#7D56F4")). + Foreground(lipgloss.Color("#FFFFFF")) + + emptyStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#3E3E3E")). + Foreground(lipgloss.Color("#888888")) + + filled := int(float64(p.Width) * p.Progress) + empty := p.Width - filled + + bar := filledStyle.Render(strings.Repeat("▓", filled)) + + emptyStyle.Render(strings.Repeat("░", empty)) + + percentage := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Render(" " + formatPercentage(p.Progress)) + + return bar + percentage +} + +// ViewCompact renders a compact version of the progress bar +func (p *ProgressBar) ViewCompact() string { + return formatPercentage(p.Progress) +} + +// formatPercentage formats progress as a percentage +func formatPercentage(progress float64) string { + percentage := int(progress * 100) + return fmt.Sprintf("%d%%", percentage) +} diff --git a/tui/components/step_indicator.go b/tui/components/step_indicator.go new file mode 100644 index 0000000..01e009b --- /dev/null +++ b/tui/components/step_indicator.go @@ -0,0 +1,102 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// StepIndicator displays progress through a multi-step process +type StepIndicator struct { + CurrentStep int + TotalSteps int + StepNames []string +} + +// NewStepIndicator creates a new step indicator +func NewStepIndicator(totalSteps int, stepNames []string) *StepIndicator { + return &StepIndicator{ + CurrentStep: 0, + TotalSteps: totalSteps, + StepNames: stepNames, + } +} + +// SetCurrentStep updates the current step +func (s *StepIndicator) SetCurrentStep(step int) { + s.CurrentStep = step +} + +// View renders the step indicator +func (s *StepIndicator) View() string { + var parts []string + + completedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00C853")). + Bold(true) + + currentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true) + + pendingStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) + + for i := 1; i <= s.TotalSteps; i++ { + var symbol string + var style lipgloss.Style + var label string + + if i < len(s.StepNames)+1 { + label = s.StepNames[i-1] + } else { + label = fmt.Sprintf("Step %d", i) + } + + if i < s.CurrentStep { + // Completed step + symbol = "●" + style = completedStyle + } else if i == s.CurrentStep { + // Current step + symbol = "●" + style = currentStyle + } else { + // Pending step + symbol = "○" + style = pendingStyle + } + + stepText := fmt.Sprintf("%s Step %d/%d: %s", symbol, i, s.TotalSteps, label) + parts = append(parts, style.Render(stepText)) + } + + 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++ { + if i < s.CurrentStep { + symbols = append(symbols, completedStyle.Render("●")) + } else if i == s.CurrentStep { + symbols = append(symbols, currentStyle.Render("●")) + } else { + symbols = append(symbols, pendingStyle.Render("○")) + } + } + + return strings.Join(symbols, " ") +} diff --git a/tui/components/timer.go b/tui/components/timer.go new file mode 100644 index 0000000..36d1d34 --- /dev/null +++ b/tui/components/timer.go @@ -0,0 +1,77 @@ +package components + +import ( + "fmt" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// Timer displays elapsed time or countdown +type Timer struct { + startTime time.Time + elapsed time.Duration + isCountdown bool + totalDuration time.Duration +} + +// NewElapsedTimer creates a new elapsed time timer +func NewElapsedTimer() *Timer { + return &Timer{ + startTime: time.Now(), + isCountdown: false, + } +} + +// NewCountdownTimer creates a new countdown timer +func NewCountdownTimer(duration time.Duration) *Timer { + return &Timer{ + startTime: time.Now(), + totalDuration: duration, + isCountdown: true, + } +} + +// Update updates the timer with the current elapsed time +func (t *Timer) Update(elapsed time.Duration) { + t.elapsed = elapsed +} + +// View renders the timer +func (t *Timer) View() string { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#5A8FE8")). + Bold(true) + + if t.isCountdown { + remaining := t.totalDuration - t.elapsed + if remaining < 0 { + remaining = 0 + } + return style.Render(fmt.Sprintf("Time remaining: %s / %s", + formatDuration(remaining), + formatDuration(t.totalDuration))) + } + + return style.Render(fmt.Sprintf("Elapsed: %s", formatDuration(t.elapsed))) +} + +// ViewCompact renders a compact version of the timer +func (t *Timer) ViewCompact() string { + if t.isCountdown { + remaining := t.totalDuration - t.elapsed + if remaining < 0 { + remaining = 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) + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%d:%02d", minutes, seconds) +} diff --git a/tui/device_model.go b/tui/device_model.go new file mode 100644 index 0000000..9ba6395 --- /dev/null +++ b/tui/device_model.go @@ -0,0 +1,192 @@ +package tui + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/go-authgate/cli/tui/components" +) + +// DeviceModel represents the state of the device code OAuth flow TUI. +type DeviceModel struct { + currentStep int + totalSteps int + stepNames []string + userCode string + verificationURI string + verificationURIComplete string + elapsed time.Duration + pollCount int + pollInterval time.Duration + oldInterval time.Duration + spinner spinner.Model + stepIndicator *components.StepIndicator + timer *components.Timer + infoBox *components.InfoBox + status string + done bool + success bool + storage *TokenStorage + err error + updatesCh <-chan FlowUpdate + width int + height int + showBackoffWarning bool + cancel context.CancelFunc + userCancelled bool +} + +// NewDeviceModel creates a new device flow TUI model. +func NewDeviceModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *DeviceModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = SpinnerStyle + + return &DeviceModel{ + totalSteps: 2, + stepNames: []string{"Requesting device code", "Waiting for authorization"}, + spinner: s, + stepIndicator: components.NewStepIndicator( + 2, + []string{"Requesting device code", "Waiting for authorization"}, + ), + timer: components.NewElapsedTimer(), + infoBox: components.NewInfoBox("Device Authorization", 60), + updatesCh: updatesCh, + status: "Initializing...", + width: 80, + height: 24, + pollInterval: 5 * time.Second, + cancel: cancel, + userCancelled: false, + } +} + +// Init initializes the model. +func (m *DeviceModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + waitForUpdate(m.updatesCh), + ) +} + +// Update handles messages and updates the model state. +func (m *DeviceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + // Cancel the OAuth flow + if m.cancel != nil { + m.cancel() + } + m.userCancelled = true + m.status = "Cancelled by user" + // Give a brief moment for the cancellation to propagate + time.Sleep(100 * time.Millisecond) + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // Update info box width based on available space + if m.width > 65 { + m.infoBox.Width = m.width - 20 + } + return m, nil + + case FlowUpdate: + cmd := m.handleFlowUpdate(msg) + return m, cmd + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case errorMsg: + m.err = msg.err + m.done = true + m.success = false + return m, tea.Quit + + case successMsg: + m.storage = msg.storage + m.done = true + m.success = true + return m, tea.Quit + } + + return m, nil +} + +// handleFlowUpdate processes FlowUpdate messages. +func (m *DeviceModel) handleFlowUpdate(update FlowUpdate) tea.Cmd { + switch update.Type { + case StepStart: + m.currentStep = update.Step + m.stepIndicator.SetCurrentStep(update.Step) + m.status = update.Message + + case StepProgress: + m.status = update.Message + + case StepComplete: + m.currentStep = update.Step + 1 + m.stepIndicator.SetCurrentStep(update.Step + 1) + if update.Step == m.totalSteps { + // All steps complete + return func() tea.Msg { + return successMsg{storage: m.storage, ok: true} + } + } + + case StepError: + m.err = fmt.Errorf("%s", update.Message) + return func() tea.Msg { + return errorMsg{err: m.err} + } + + case DeviceCodeReceived: + m.userCode = update.GetString("user_code") + m.verificationURI = update.GetString("verification_uri") + m.verificationURIComplete = update.GetString("verification_uri_complete") + + // Populate info box + m.infoBox.Clear() + m.infoBox.AddLine("Please authorize this device:") + m.infoBox.AddLine("") + m.infoBox.AddLine(fmt.Sprintf("Visit: %s", m.verificationURIComplete)) + m.infoBox.AddLine("") + m.infoBox.AddLine(fmt.Sprintf("Or go to: %s", m.verificationURI)) + m.infoBox.AddLine(fmt.Sprintf("And enter: %s", m.userCode)) + + case PollingUpdate: + m.pollCount = update.GetInt("poll_count") + m.elapsed = update.GetDuration("elapsed") + m.timer.Update(m.elapsed) + m.status = "Waiting for authorization..." + m.showBackoffWarning = false + + case BackoffChanged: + m.oldInterval = update.GetDuration("old_interval") + m.pollInterval = update.GetDuration("new_interval") + m.showBackoffWarning = true + + case TimerTick: + m.elapsed = update.GetDuration("elapsed") + m.timer.Update(m.elapsed) + } + + return waitForUpdate(m.updatesCh) +} + +// View renders the model. +func (m *DeviceModel) View() string { + // Let device_view.go handle the rendering + return renderDeviceView(m) +} diff --git a/tui/device_view.go b/tui/device_view.go new file mode 100644 index 0000000..c0ce5a8 --- /dev/null +++ b/tui/device_view.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// renderDeviceView renders the device code OAuth flow view. +func renderDeviceView(m *DeviceModel) string { + if m.done { + return renderDeviceComplete(m) + } + + var b strings.Builder + + // Title + titleBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(0, 2). + Width(m.width - 4). + Align(lipgloss.Center) + + b.WriteString(titleBox.Render(TitleStyle.Render("Device Authorization Grant Flow"))) + b.WriteString("\n\n") + + // Step indicator + b.WriteString(m.stepIndicator.View()) + b.WriteString("\n\n") + + // Device code info box (when available) + if m.userCode != "" { + b.WriteString(m.infoBox.View()) + b.WriteString("\n\n") + } + + // Status with spinner and poll info + if m.currentStep == 2 { + // Show polling status + statusStyle := lipgloss.NewStyle(). + Foreground(colorSecondary) + + pollInfo := "" + if m.pollCount > 0 { + pollInfo = fmt.Sprintf(" (poll #%d, interval: %s)", + m.pollCount, + formatInterval(m.pollInterval)) + } + + b.WriteString(statusStyle.Render(fmt.Sprintf("%s %s%s", + m.spinner.View(), + m.status, + pollInfo))) + b.WriteString("\n\n") + + // Show backoff warning if applicable + if m.showBackoffWarning { + warningBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(colorWarning). + Padding(0, 1). + Foreground(colorWarning) + + backoffMsg := fmt.Sprintf("⚠ Server requested slower polling: %s → %s", + formatInterval(m.oldInterval), + formatInterval(m.pollInterval)) + + b.WriteString(warningBox.Render(backoffMsg)) + b.WriteString("\n\n") + } + + // Elapsed time + b.WriteString(m.timer.View()) + b.WriteString("\n\n") + } else { + // Simple status for step 1 + statusStyle := lipgloss.NewStyle(). + Foreground(colorSecondary) + b.WriteString(statusStyle.Render(fmt.Sprintf("%s %s", m.spinner.View(), m.status))) + b.WriteString("\n\n") + } + + // Help text + helpText := HelpStyle.Render("Press Ctrl+C to cancel") + b.WriteString(helpText) + b.WriteString("\n") + + return AppContainerStyle.Render(b.String()) +} + +// renderDeviceComplete renders the completion state (success or error). +func renderDeviceComplete(m *DeviceModel) string { + var b strings.Builder + + if m.err != nil { + // Error state + b.WriteString("\n") + b.WriteString(RenderError(fmt.Sprintf("Authentication failed: %v", m.err))) + b.WriteString("\n\n") + } else { + // Success state + b.WriteString("\n") + successBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorSuccess). + Padding(1, 2). + Foreground(colorSuccess) + + b.WriteString(successBox.Render("✓ Authorization successful!")) + b.WriteString("\n\n") + + if m.storage != nil { + // Show token preview + infoStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + + preview := m.storage.AccessToken + if len(preview) > 50 { + preview = preview[:50] + "..." + } + + b.WriteString(infoStyle.Render(fmt.Sprintf("Access Token: %s", preview))) + b.WriteString("\n") + } + } + + 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 = seconds % 60 + if seconds == 0 { + return fmt.Sprintf("%dm", minutes) + } + return fmt.Sprintf("%dm%ds", minutes, seconds) +} diff --git a/tui/manager.go b/tui/manager.go new file mode 100644 index 0000000..31bcab7 --- /dev/null +++ b/tui/manager.go @@ -0,0 +1,151 @@ +package tui + +import ( + "context" + "os" + + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// Manager is the interface for managing UI output in the CLI. +// It abstracts the presentation layer, allowing for different implementations: +// - SimplePrintManager: Uses fmt.Printf (current behavior, for CI/pipelines) +// - BubbleTeaManager: Uses Bubble Tea for interactive TUI +type Manager interface { + // ShowHeader displays the initial application header with configuration info. + ShowHeader(clientMode, serverURL, clientID string) + + // ShowFlowSelection displays which OAuth flow was selected and why. + ShowFlowSelection(method string) + + // RunBrowserFlow executes the browser-based OAuth flow with UI updates. + // Returns (storage, ok, error) where ok=false indicates fallback is needed. + RunBrowserFlow(ctx context.Context, perform BrowserFlowFunc) (*TokenStorage, bool, error) + + // RunDeviceFlow executes the device code OAuth flow with UI updates. + RunDeviceFlow(ctx context.Context, perform DeviceFlowFunc) (*TokenStorage, error) + + // ShowTokenInfo displays information about the current access token. + ShowTokenInfo(storage *TokenStorage) + + // ShowVerification displays the result of token verification. + ShowVerification(success bool, info string) + + // ShowExistingTokens indicates that existing tokens were found. + ShowExistingTokens() + + // ShowTokenStillValid indicates the access token is still valid. + ShowTokenStillValid() + + // ShowTokenExpired indicates the access token expired. + ShowTokenExpired() + + // ShowRefreshSuccess indicates token refresh was successful. + ShowRefreshSuccess() + + // ShowRefreshFailed indicates token refresh failed. + ShowRefreshFailed(err error) + + // ShowNewAuthFlow indicates starting a new authentication flow. + ShowNewAuthFlow() + + // ShowNoExistingTokens indicates no existing tokens were found. + ShowNoExistingTokens() + + // ShowAutoRefreshDemo indicates starting the auto-refresh demonstration. + ShowAutoRefreshDemo() + + // ShowAccessTokenRejected indicates the access token was rejected (401). + ShowAccessTokenRejected() + + // ShowTokenRefreshedRetrying indicates token was refreshed, retrying API call. + ShowTokenRefreshedRetrying() + + // ShowAPICallSuccess indicates the API call was successful. + ShowAPICallSuccess() + + // ShowRefreshTokenExpired indicates the refresh token expired, need re-auth. + ShowRefreshTokenExpired() + + // ShowReAuthSuccess indicates re-authentication was successful. + ShowReAuthSuccess() +} + +// BrowserFlowFunc is a function that performs the browser OAuth flow and sends +// progress updates through the provided channel. +type BrowserFlowFunc func(ctx context.Context, updates chan<- FlowUpdate) (*TokenStorage, bool, error) + +// DeviceFlowFunc is a function that performs the device code OAuth flow and sends +// progress updates through the provided channel. +type DeviceFlowFunc func(ctx context.Context, updates chan<- FlowUpdate) (*TokenStorage, error) + +// TokenStorage represents the stored OAuth tokens. +// This is a placeholder - the actual type should match the one in the main package. +type TokenStorage struct { + AccessToken string + RefreshToken string + TokenType string + ExpiresAt interface{} // time.Time, but avoiding import cycle + ClientID string + Flow string +} + +// SelectManager chooses the appropriate UI manager based on environment detection. +// Returns SimplePrintManager for CI/pipelines, BubbleTeaManager for interactive terminals. +func SelectManager() Manager { + if shouldUseSimpleUI() { + return NewSimpleManager() + } + return NewBubbleTeaManager() +} + +// shouldUseSimpleUI determines if we should use simple printf-based UI instead of TUI. +// Returns true for CI environments, non-TTY output, small terminals, or dumb terminals. +func shouldUseSimpleUI() bool { + // CI environment detection + if isCIEnvironment() { + return true + } + + // Output is not a terminal (piped to file, etc.) + if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { + return true + } + + // Terminal is too small + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width < 60 || height < 20 { + return true + } + + // TERM is not set or is "dumb" + termType := os.Getenv("TERM") + if termType == "" || termType == "dumb" { + return true + } + + return false +} + +// isCIEnvironment checks if we're running in a CI environment. +func isCIEnvironment() bool { + ciEnvVars := []string{ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "JENKINS_URL", + "TRAVIS", + "BUILDKITE", + "DRONE", + "TEAMCITY_VERSION", + "TF_BUILD", // Azure Pipelines + } + for _, envVar := range ciEnvVars { + if os.Getenv(envVar) != "" { + return true + } + } + return false +} diff --git a/tui/manager_test.go b/tui/manager_test.go new file mode 100644 index 0000000..2e10697 --- /dev/null +++ b/tui/manager_test.go @@ -0,0 +1,150 @@ +package tui + +import ( + "os" + "testing" +) + +func TestShouldUseSimpleUI(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + desc string + }{ + { + name: "CI environment - GitHub Actions", + envVars: map[string]string{"GITHUB_ACTIONS": "true"}, + expected: true, + desc: "Should use simple UI in GitHub Actions", + }, + { + name: "CI environment - GitLab CI", + envVars: map[string]string{"GITLAB_CI": "true"}, + expected: true, + desc: "Should use simple UI in GitLab CI", + }, + { + name: "CI environment - CircleCI", + envVars: map[string]string{"CIRCLECI": "true"}, + expected: true, + desc: "Should use simple UI in CircleCI", + }, + { + name: "TERM=dumb", + envVars: map[string]string{"TERM": "dumb"}, + expected: true, + desc: "Should use simple UI when TERM=dumb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save current env vars + savedEnv := make(map[string]string) + for key := range tt.envVars { + savedEnv[key] = os.Getenv(key) + } + + // Set test env vars + for key, val := range tt.envVars { + os.Setenv(key, val) + } + + // Test + result := isCIEnvironment() || os.Getenv("TERM") == "dumb" + + // Restore env vars + for key, val := range savedEnv { + if val == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, val) + } + } + + if result != tt.expected { + t.Errorf("%s: expected %v, got %v", tt.desc, tt.expected, result) + } + }) + } +} + +func TestSelectManager(t *testing.T) { + t.Run("Returns valid manager", func(t *testing.T) { + manager := SelectManager() + if manager == nil { + t.Error("Expected non-nil manager") + } + // Verify it's one of our known types + switch manager.(type) { + case *SimpleManager, *BubbleTeaManager: + // Good + default: + t.Errorf("Unexpected manager type: %T", manager) + } + }) +} + +func TestFlowUpdateHelpers(t *testing.T) { + update := FlowUpdate{ + Type: StepStart, + Step: 1, + Message: "Testing", + Data: map[string]interface{}{ + "string_val": "hello", + "int_val": 42, + "float_val": 3.14, + "duration_val": 5000000000, // 5 seconds in nanoseconds + }, + } + + // Test GetString + if got := update.GetString("string_val"); got != "hello" { + t.Errorf("GetString: expected 'hello', got '%s'", got) + } + + // Test GetInt + if got := update.GetInt("int_val"); got != 42 { + t.Errorf("GetInt: expected 42, got %d", got) + } + + // Test GetFloat64 + if got := update.GetFloat64("float_val"); got != 3.14 { + t.Errorf("GetFloat64: expected 3.14, got %f", got) + } + + // Test missing keys return zero values + if got := update.GetString("missing"); got != "" { + t.Errorf("GetString (missing): expected empty string, got '%s'", got) + } + if got := update.GetInt("missing"); got != 0 { + t.Errorf("GetInt (missing): expected 0, got %d", got) + } +} + +func TestFlowUpdateTypeString(t *testing.T) { + tests := []struct { + updateType FlowUpdateType + expected string + }{ + {StepStart, "StepStart"}, + {StepProgress, "StepProgress"}, + {StepComplete, "StepComplete"}, + {StepError, "StepError"}, + {TimerTick, "TimerTick"}, + {BrowserOpened, "BrowserOpened"}, + {CallbackReceived, "CallbackReceived"}, + {DeviceCodeReceived, "DeviceCodeReceived"}, + {PollingUpdate, "PollingUpdate"}, + {BackoffChanged, "BackoffChanged"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.updateType.String(); got != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, got) + } + }) + } +} diff --git a/tui/messages.go b/tui/messages.go new file mode 100644 index 0000000..316286c --- /dev/null +++ b/tui/messages.go @@ -0,0 +1,103 @@ +package tui + +import "time" + +// FlowUpdateType represents the type of progress update from an OAuth flow. +type FlowUpdateType int + +const ( + StepStart FlowUpdateType = iota + StepProgress + StepComplete + StepError + TimerTick // For countdown/elapsed time updates + BrowserOpened // Browser successfully opened + CallbackReceived // Callback received from browser + DeviceCodeReceived // Device code received from server + PollingUpdate // Polling status update + BackoffChanged // Slow_down interval changed +) + +// FlowUpdate represents a progress update message from an OAuth flow. +type FlowUpdate struct { + Type FlowUpdateType + Step int // Current step number (1-indexed) + TotalSteps int // Total number of steps + Message string // Human-readable message + Progress float64 // Progress percentage (0.0 to 1.0) + Data map[string]interface{} // Additional data for specific update types +} + +// String returns a human-readable representation of the FlowUpdateType. +func (t FlowUpdateType) String() string { + switch t { + case StepStart: + return "StepStart" + case StepProgress: + return "StepProgress" + case StepComplete: + return "StepComplete" + case StepError: + return "StepError" + case TimerTick: + return "TimerTick" + case BrowserOpened: + return "BrowserOpened" + case CallbackReceived: + return "CallbackReceived" + case DeviceCodeReceived: + return "DeviceCodeReceived" + case PollingUpdate: + return "PollingUpdate" + case BackoffChanged: + return "BackoffChanged" + default: + return "Unknown" + } +} + +// Helper functions to extract data from FlowUpdate.Data + +// GetString safely extracts a string value from Data. +func (u *FlowUpdate) GetString(key string) string { + if u.Data == nil { + return "" + } + if val, ok := u.Data[key].(string); ok { + return val + } + return "" +} + +// GetInt safely extracts an int value from Data. +func (u *FlowUpdate) GetInt(key string) int { + if u.Data == nil { + return 0 + } + if val, ok := u.Data[key].(int); ok { + return val + } + return 0 +} + +// GetDuration safely extracts a time.Duration value from Data. +func (u *FlowUpdate) GetDuration(key string) time.Duration { + if u.Data == nil { + return 0 + } + if val, ok := u.Data[key].(time.Duration); ok { + return val + } + return 0 +} + +// GetFloat64 safely extracts a float64 value from Data. +func (u *FlowUpdate) GetFloat64(key string) float64 { + if u.Data == nil { + return 0 + } + if val, ok := u.Data[key].(float64); ok { + return val + } + return 0 +} diff --git a/tui/simple_manager.go b/tui/simple_manager.go new file mode 100644 index 0000000..76b3beb --- /dev/null +++ b/tui/simple_manager.go @@ -0,0 +1,274 @@ +package tui + +import ( + "context" + "fmt" + "time" +) + +// SimpleManager implements Manager using simple fmt.Printf output. +// This preserves the current CLI behavior for backwards compatibility +// and is used in CI environments, pipelines, and non-interactive sessions. +type SimpleManager struct{} + +// NewSimpleManager creates a new SimplePrintManager. +func NewSimpleManager() *SimpleManager { + return &SimpleManager{} +} + +func (m *SimpleManager) ShowHeader(clientMode, serverURL, clientID string) { + fmt.Printf("=== AuthGate Hybrid CLI (Browser + Device Code Flow) ===\n") + fmt.Printf("Client mode : %s\n", clientMode) + fmt.Printf("Server URL : %s\n", serverURL) + fmt.Printf("Client ID : %s\n", clientID) + fmt.Println() +} + +func (m *SimpleManager) ShowFlowSelection(method string) { + fmt.Printf("Auth method : %s\n", method) +} + +func (m *SimpleManager) RunBrowserFlow( + ctx context.Context, + perform BrowserFlowFunc, +) (*TokenStorage, bool, error) { + // Create a channel for updates (but we'll ignore them for simple mode) + updates := make(chan FlowUpdate, 10) + + // Run the flow in a goroutine + type result struct { + storage *TokenStorage + ok bool + err error + } + resultCh := make(chan result, 1) + + go func() { + storage, ok, err := perform(ctx, updates) + resultCh <- result{storage, ok, err} + close(updates) + }() + + // Drain updates and print simple messages + for { + select { + case update, ok := <-updates: + if !ok { + // Channel closed, wait for result + res := <-resultCh + return res.storage, res.ok, res.err + } + m.handleBrowserUpdate(update) + + case res := <-resultCh: + // Drain remaining updates + for update := range updates { + m.handleBrowserUpdate(update) + } + return res.storage, res.ok, res.err + } + } +} + +func (m *SimpleManager) handleBrowserUpdate(update FlowUpdate) { + switch update.Type { + case StepStart: + if update.Step == 1 { + fmt.Println("Step 1: Opening browser for authorization...") + if url := update.GetString("url"); url != "" { + fmt.Printf("\n %s\n\n", url) + } + } else if update.Step == 2 { + fmt.Println("Browser opened. Please complete authorization in your browser.") + port := update.GetInt("port") + if port == 0 { + port = 8888 // default + } + fmt.Printf("Step 2: Waiting for callback on http://localhost:%d/callback ...\n", port) + } else if update.Step == 3 { + fmt.Println("Step 3: Exchanging authorization code for tokens...") + } + + case BrowserOpened: + // Already printed in StepStart for step 2 + return + + case StepError: + if update.Message != "" { + fmt.Println(update.Message) + } + + case CallbackReceived: + // Token exchange message printed in StepStart for step 3 + return + } +} + +func (m *SimpleManager) RunDeviceFlow( + ctx context.Context, + perform DeviceFlowFunc, +) (*TokenStorage, error) { + // Create a channel for updates + updates := make(chan FlowUpdate, 10) + + // Run the flow in a goroutine + type result struct { + storage *TokenStorage + err error + } + resultCh := make(chan result, 1) + + go func() { + storage, err := perform(ctx, updates) + resultCh <- result{storage, err} + close(updates) + }() + + // Drain updates and print simple messages + for { + select { + case update, ok := <-updates: + if !ok { + // Channel closed, wait for result + res := <-resultCh + return res.storage, res.err + } + m.handleDeviceUpdate(update) + + case res := <-resultCh: + // Drain remaining updates + for update := range updates { + m.handleDeviceUpdate(update) + } + return res.storage, res.err + } + } +} + +func (m *SimpleManager) handleDeviceUpdate(update FlowUpdate) { + switch update.Type { + case StepStart: + if update.Step == 1 { + fmt.Println("Step 1: Requesting device code...") + } else if update.Step == 2 { + fmt.Println("Step 2: Waiting for authorization...") + } + + case DeviceCodeReceived: + userCode := update.GetString("user_code") + verificationURI := update.GetString("verification_uri") + verificationURIComplete := update.GetString("verification_uri_complete") + + fmt.Printf("\n----------------------------------------\n") + fmt.Printf("Please open this link to authorize:\n%s\n", verificationURIComplete) + fmt.Printf("\nOr visit : %s\n", verificationURI) + fmt.Printf("And enter: %s\n", userCode) + fmt.Printf("----------------------------------------\n\n") + + case PollingUpdate: + // Print a dot every 2 seconds (simple progress indicator) + fmt.Print(".") + + case BackoffChanged: + // Don't print anything for backoff changes in simple mode + return + + case StepComplete: + if update.Step == 2 { + fmt.Println("\nAuthorization successful!") + } + + case StepError: + if update.Message != "" { + fmt.Println() + fmt.Println(update.Message) + } + } +} + +func (m *SimpleManager) ShowTokenInfo(storage *TokenStorage) { + fmt.Printf("\n========================================\n") + fmt.Printf("Current Token Info:\n") + preview := storage.AccessToken + if len(preview) > 50 { + preview = preview[:50] + } + fmt.Printf("Access Token : %s...\n", preview) + fmt.Printf("Token Type : %s\n", storage.TokenType) + + // Handle ExpiresAt being either time.Time or interface{} + switch expiresAt := storage.ExpiresAt.(type) { + case time.Time: + fmt.Printf("Expires In : %s\n", time.Until(expiresAt).Round(time.Second)) + } + + if storage.Flow != "" { + fmt.Printf("Auth Flow : %s\n", storage.Flow) + } + fmt.Printf("========================================\n") +} + +func (m *SimpleManager) ShowVerification(success bool, info string) { + fmt.Println("\nVerifying token with server...") + if success { + fmt.Println("Token verified successfully.") + if info != "" { + fmt.Printf("Token Info: %s\n", info) + } + } else { + fmt.Printf("Token verification failed: %s\n", info) + } +} + +func (m *SimpleManager) ShowExistingTokens() { + fmt.Println("Found existing tokens.") +} + +func (m *SimpleManager) ShowTokenStillValid() { + fmt.Println("Access token is still valid, using it.") +} + +func (m *SimpleManager) ShowTokenExpired() { + fmt.Println("Access token expired, attempting refresh...") +} + +func (m *SimpleManager) ShowRefreshSuccess() { + fmt.Println("Token refreshed successfully.") +} + +func (m *SimpleManager) ShowRefreshFailed(err error) { + fmt.Printf("Refresh failed: %v\n", err) + fmt.Println("Starting new authentication flow...") +} + +func (m *SimpleManager) ShowNewAuthFlow() { + fmt.Println("No existing tokens found, starting authentication flow...") +} + +func (m *SimpleManager) ShowNoExistingTokens() { + fmt.Println("No existing tokens found, starting authentication flow...") +} + +func (m *SimpleManager) ShowAutoRefreshDemo() { + fmt.Println("\nDemonstrating automatic refresh on API call...") +} + +func (m *SimpleManager) ShowAccessTokenRejected() { + fmt.Println("Access token rejected (401), refreshing...") +} + +func (m *SimpleManager) ShowTokenRefreshedRetrying() { + fmt.Println("Token refreshed, retrying API call...") +} + +func (m *SimpleManager) ShowAPICallSuccess() { + fmt.Println("API call successful!") +} + +func (m *SimpleManager) ShowRefreshTokenExpired() { + fmt.Println("Refresh token expired, re-authenticating...") +} + +func (m *SimpleManager) ShowReAuthSuccess() { + fmt.Println("API call successful after re-authentication.") +} diff --git a/tui/styles.go b/tui/styles.go new file mode 100644 index 0000000..b6311a0 --- /dev/null +++ b/tui/styles.go @@ -0,0 +1,156 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Color palette +var ( + // Primary colors + colorPrimary = lipgloss.Color("#7D56F4") + colorSecondary = lipgloss.Color("#5A8FE8") + colorSuccess = lipgloss.Color("#00C853") + colorError = lipgloss.Color("#D32F2F") + colorWarning = lipgloss.Color("#FFA726") + colorInfo = lipgloss.Color("#29B6F6") + + // Neutral colors + colorSubtle = lipgloss.Color("#888888") + colorBorder = lipgloss.Color("#3E3E3E") + colorMuted = lipgloss.Color("#666666") + colorBright = lipgloss.Color("#FFFFFF") +) + +// Base styles +var ( + // TitleStyle is used for section titles + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary). + MarginBottom(1) + + // SubtleStyle is used for secondary text + SubtleStyle = lipgloss.NewStyle(). + Foreground(colorSubtle) + + // ErrorStyle is used for error messages + ErrorStyle = lipgloss.NewStyle(). + Foreground(colorError). + Bold(true) + + // SuccessStyle is used for success messages + SuccessStyle = lipgloss.NewStyle(). + Foreground(colorSuccess). + Bold(true) + + // WarningStyle is used for warning messages + WarningStyle = lipgloss.NewStyle(). + Foreground(colorWarning) + + // InfoStyle is used for informational messages + InfoStyle = lipgloss.NewStyle(). + Foreground(colorInfo) + + // BoxStyle is used for bordered boxes + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2) + + // HighlightStyle is used for highlighted text + HighlightStyle = lipgloss.NewStyle(). + Background(colorPrimary). + Foreground(colorBright). + Bold(true). + Padding(0, 1) + + // StepStyle is used for step indicators + StepStyle = lipgloss.NewStyle(). + Foreground(colorSecondary). + Bold(true) + + // CompletedStepStyle is used for completed steps + CompletedStepStyle = lipgloss.NewStyle(). + Foreground(colorSuccess). + Bold(true) + + // CurrentStepStyle is used for the current step + CurrentStepStyle = lipgloss.NewStyle(). + Foreground(colorPrimary). + Bold(true) + + // SpinnerStyle is used for spinner text + SpinnerStyle = lipgloss.NewStyle(). + Foreground(colorPrimary) + + // ProgressBarFilledStyle is used for filled progress bar + ProgressBarFilledStyle = lipgloss.NewStyle(). + Background(colorPrimary) + + // ProgressBarEmptyStyle is used for empty progress bar + ProgressBarEmptyStyle = lipgloss.NewStyle(). + Background(colorMuted) + + // CodeStyle is used for code/URLs + CodeStyle = lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true) + + // HelpStyle is used for help text + HelpStyle = lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true) +) + +// Container styles +var ( + // AppContainerStyle is the main container for the entire app + AppContainerStyle = lipgloss.NewStyle(). + Margin(1, 2) + + // SectionStyle is used for sections within the app + SectionStyle = lipgloss.NewStyle(). + MarginBottom(1) +) + +// Helper functions + +// RenderBox renders content in a bordered box +func RenderBox(content string) string { + return BoxStyle.Render(content) +} + +// RenderTitle renders a title +func RenderTitle(title string) string { + return TitleStyle.Render(title) +} + +// RenderError renders an error message +func RenderError(message string) string { + return ErrorStyle.Render("✗ " + message) +} + +// RenderSuccess renders a success message +func RenderSuccess(message string) string { + return SuccessStyle.Render("✓ " + message) +} + +// RenderWarning renders a warning message +func RenderWarning(message string) string { + return WarningStyle.Render("⚠ " + message) +} + +// RenderInfo renders an informational message +func RenderInfo(message string) string { + return InfoStyle.Render("ℹ " + message) +} + +// RenderCode renders code or URLs +func RenderCode(code string) string { + return CodeStyle.Render(code) +} + +// RenderHelp renders help text +func RenderHelp(text string) string { + return HelpStyle.Render(text) +} diff --git a/tui_adapter.go b/tui_adapter.go new file mode 100644 index 0000000..192a7b2 --- /dev/null +++ b/tui_adapter.go @@ -0,0 +1,47 @@ +package main + +import ( + "time" + + "github.com/go-authgate/cli/tui" +) + +// toTUITokenStorage converts main.TokenStorage to tui.TokenStorage. +func toTUITokenStorage(storage *TokenStorage) *tui.TokenStorage { + if storage == nil { + return nil + } + return &tui.TokenStorage{ + AccessToken: storage.AccessToken, + RefreshToken: storage.RefreshToken, + TokenType: storage.TokenType, + ExpiresAt: storage.ExpiresAt, + ClientID: storage.ClientID, + Flow: storage.Flow, + } +} + +// fromTUITokenStorage converts tui.TokenStorage to main.TokenStorage. +func fromTUITokenStorage(tuiStorage *tui.TokenStorage) *TokenStorage { + if tuiStorage == nil { + return nil + } + + // Convert ExpiresAt from interface{} to time.Time + var expiresAt time.Time + switch v := tuiStorage.ExpiresAt.(type) { + case time.Time: + expiresAt = v + default: + expiresAt = time.Time{} + } + + return &TokenStorage{ + AccessToken: tuiStorage.AccessToken, + RefreshToken: tuiStorage.RefreshToken, + TokenType: tuiStorage.TokenType, + ExpiresAt: expiresAt, + ClientID: tuiStorage.ClientID, + Flow: tuiStorage.Flow, + } +}