diff --git a/go.mod b/go.mod index 7bbb5ee..d0422f2 100644 --- a/go.mod +++ b/go.mod @@ -3,34 +3,33 @@ module github.com/go-authgate/cli go 1.24.2 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.0 + charm.land/lipgloss/v2 v2.0.0 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 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 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.2 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // 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/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.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.20 // 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/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index bae4d32..2770287 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,33 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= +charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= +charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= 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/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= +github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= 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/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -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= @@ -30,29 +36,24 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ 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.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/tui/browser_model.go b/tui/browser_model.go index ee5b21e..59629d6 100644 --- a/tui/browser_model.go +++ b/tui/browser_model.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "github.com/go-authgate/cli/tui/components" ) @@ -23,6 +23,8 @@ type BrowserModel struct { stepIndicator *components.StepIndicator timer *components.Timer progressBar *components.ProgressBar + helpView *components.HelpView + config Config status string done bool success bool @@ -36,12 +38,24 @@ type BrowserModel struct { userCancelled bool } -// NewBrowserModel creates a new browser flow TUI model. +// NewBrowserModel creates a new browser flow TUI model with default config. func NewBrowserModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *BrowserModel { + return NewBrowserModelWithConfig(updatesCh, cancel, LoadConfig()) +} + +// NewBrowserModelWithConfig creates a new browser flow TUI model with custom config. +func NewBrowserModelWithConfig( + updatesCh <-chan FlowUpdate, + cancel context.CancelFunc, + config Config, +) *BrowserModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = SpinnerStyle + helpView := components.NewHelpView() + helpView.SetWidth(80) + return &BrowserModel{ totalSteps: 3, stepNames: []string{"Opening browser", "Waiting for callback", "Exchanging tokens"}, @@ -52,6 +66,8 @@ func NewBrowserModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *Br ), timer: components.NewCountdownTimer(2 * time.Minute), progressBar: components.NewProgressBar(40), + helpView: helpView, + config: config, timeout: 2 * time.Minute, updatesCh: updatesCh, status: "Initializing...", @@ -73,10 +89,10 @@ func (m *BrowserModel) Init() tea.Cmd { // 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: - //nolint:exhaustive // We only handle Ctrl+C, other keys are ignored - switch msg.Type { - case tea.KeyCtrlC: + case tea.KeyPressMsg: + // Check for Ctrl+C + key := msg.Key() + if key.Code == 'c' && key.Mod == tea.ModCtrl { // Cancel the OAuth flow if m.cancel != nil { m.cancel() @@ -86,8 +102,12 @@ func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Give a brief moment for the cancellation to propagate time.Sleep(100 * time.Millisecond) return m, tea.Quit - default: - // Ignore other keys + } + + // Toggle help with '?' + if key.Code == '?' { + m.helpView.Toggle() + return m, nil } case tea.WindowSizeMsg: @@ -95,8 +115,10 @@ func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height // Update progress bar width based on available space if m.width > 50 { - m.progressBar.Width = m.width - 30 + m.progressBar.SetWidth(m.width - 30) } + // Update help view width + m.helpView.SetWidth(m.width - 4) return m, nil case FlowUpdate: @@ -108,6 +130,13 @@ func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) return m, cmd + default: + // Pass other messages to progress bar for animation + if m.progressBar != nil { + cmd := m.progressBar.Update(msg) + return m, cmd + } + case errorMsg: m.err = msg.err m.done = true @@ -186,9 +215,9 @@ func (m *BrowserModel) handleFlowUpdate(update FlowUpdate) tea.Cmd { } // View renders the model. -func (m *BrowserModel) View() string { +func (m *BrowserModel) View() tea.View { // Let browser_view.go handle the rendering - return renderBrowserView(m) + return tea.NewView(renderBrowserView(m)) } // Helper message types for internal communication diff --git a/tui/browser_view.go b/tui/browser_view.go index 7ac8482..f542865 100644 --- a/tui/browser_view.go +++ b/tui/browser_view.go @@ -4,7 +4,8 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" + "github.com/go-authgate/cli/tui/components" ) // renderBrowserView renders the browser OAuth flow view. @@ -56,9 +57,8 @@ func renderBrowserView(m *BrowserModel) string { b.WriteString(statusLine) b.WriteString("\n\n") - // Help text - helpText := HelpStyle.Render("Press Ctrl+C to cancel") - b.WriteString(helpText) + // Help view + b.WriteString(m.helpView.View()) b.WriteString("\n") return AppContainerStyle.Render(b.String()) @@ -70,9 +70,12 @@ func renderBrowserComplete(m *BrowserModel) string { switch { case m.err != nil: - // Error state + // Error state with enhanced display b.WriteString("\n") - b.WriteString(RenderError(m.err.Error())) + errView := components.NewErrorView("Authentication Failed", m.err.Error()). + WithRecommendations(components.GetRecommendationsForError(m.err)...). + WithRetryable(true) + b.WriteString(errView.View()) b.WriteString("\n\n") case !m.ok: // Fallback to device flow @@ -94,16 +97,13 @@ func renderBrowserComplete(m *BrowserModel) string { b.WriteString("\n\n") if m.storage != nil { - // Show token preview + // Show masked token preview infoStyle := lipgloss.NewStyle(). Foreground(colorSubtle) - preview := m.storage.AccessToken - if len(preview) > 50 { - preview = preview[:50] + "..." - } - - b.WriteString(infoStyle.Render("Access Token: " + preview)) + // Mask the token for security + masked := maskTokenPreview(m.storage.AccessToken) + b.WriteString(infoStyle.Render("Access Token: " + masked)) b.WriteString("\n") } } diff --git a/tui/bubbletea_manager.go b/tui/bubbletea_manager.go index bc429f8..4eafa4d 100644 --- a/tui/bubbletea_manager.go +++ b/tui/bubbletea_manager.go @@ -2,38 +2,106 @@ package tui import ( "context" + "fmt" + "os" + "strings" + "time" - tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" ) // 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 { - simple *SimpleManager + simple *SimpleManager + config Config + renderer *FlowRenderer + stepMap map[string]int // maps step names to indices for updates } -// NewBubbleTeaManager creates a new BubbleTeaManager. +// NewBubbleTeaManager creates a new BubbleTeaManager with default config. func NewBubbleTeaManager() *BubbleTeaManager { return &BubbleTeaManager{ - simple: NewSimpleManager(), + simple: NewSimpleManager(), + config: LoadConfig(), + stepMap: make(map[string]int), } } -// For non-flow methods, delegate to the shared SimpleManager instance. +// NewBubbleTeaManagerWithConfig creates a new BubbleTeaManager with custom config. +func NewBubbleTeaManagerWithConfig(config Config) *BubbleTeaManager { + return &BubbleTeaManager{ + simple: NewSimpleManager(), + config: config, + stepMap: make(map[string]int), + } +} + +// ShowHeader initializes and displays the header func (m *BubbleTeaManager) ShowHeader(clientMode, serverURL, clientID string) { - m.simple.ShowHeader(clientMode, serverURL, clientID) + // Initialize flow renderer + flowType := "OAuth 2.0 Authorization Code Flow" + m.renderer = NewFlowRenderer(flowType, clientMode, serverURL) + + // Add HTTP warning if needed + if strings.HasPrefix(strings.ToLower(serverURL), "http://") { + m.renderer.AddWarning( + "Using HTTP instead of HTTPS. Tokens will be transmitted in plaintext!", + ) + m.renderer.AddWarning("This is only safe for local development. Use HTTPS in production.") + } + + // Add initial steps + m.addStep("Check existing tokens") + + // Render header + m.renderer.RenderHeader() } func (m *BubbleTeaManager) ShowFlowSelection(method string) { - m.simple.ShowFlowSelection(method) + m.addStep("Set up authorization flow") + m.updateStep("Set up authorization flow", StepCompleted, method) + m.refreshDisplay() +} + +// Helper methods for managing steps +func (m *BubbleTeaManager) addStep(name string) { + if m.renderer != nil { + idx := m.renderer.AddStep(name) + m.stepMap[name] = idx + } } -// RunBrowserFlow executes the browser OAuth flow with an interactive TUI. +func (m *BubbleTeaManager) updateStep(name string, status FlowStepStatus, message string) { + if m.renderer != nil { + if idx, ok := m.stepMap[name]; ok { + m.renderer.UpdateStep(idx, status, message) + } + } +} + +func (m *BubbleTeaManager) refreshDisplay() { + if m.renderer != nil { + m.renderer.UpdateDisplay() + } +} + +// RunBrowserFlow executes the browser OAuth flow with unified TUI rendering. func (m *BubbleTeaManager) RunBrowserFlow( 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") + updates := make(chan FlowUpdate, 10) // Create a cancellable context for the OAuth flow @@ -55,42 +123,127 @@ func (m *BubbleTeaManager) RunBrowserFlow( close(updates) }() - // Create and run Bubble Tea program - model := NewBrowserModel(updates, cancel) - p := tea.NewProgram(model) + // 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.ok, res.err + } + m.handleBrowserFlowUpdate(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) + } + m.refreshDisplay() + return res.storage, res.ok, res.err - finalModel, err := p.Run() - if err != nil { - // If TUI fails, cancel OAuth flow and fall back to simple mode - cancel() - return m.simple.RunBrowserFlow(ctx, perform) + case <-ctx.Done(): + return nil, false, ctx.Err() + } } +} + +// handleBrowserFlowUpdate processes FlowUpdate messages for browser flow +func (m *BubbleTeaManager) handleBrowserFlowUpdate(update 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 stepName != "" { + m.updateStep(stepName, StepInProgress, "") + } - // 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 + 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") + } + + case StepError: + // 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 bm.storage != nil { - return bm.storage, bm.ok, nil + if stepName != "" { + m.updateStep(stepName, StepFailed, update.Message) } } - 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. +// formatRemainingTime formats a duration as "Xm Ys" or "Xs" +func formatRemainingTime(d time.Duration) string { + d = d.Round(time.Second) + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + + if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +// RunDeviceFlow executes the device code OAuth flow with unified TUI rendering. 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 @@ -111,97 +264,254 @@ func (m *BubbleTeaManager) RunDeviceFlow( close(updates) }() - // Create and run Bubble Tea program - model := NewDeviceModel(updates, cancel) - p := tea.NewProgram(model) + // 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 - finalModel, err := p.Run() - if err != nil { - // If TUI fails, cancel OAuth flow and fall back to simple mode - cancel() - return m.simple.RunDeviceFlow(ctx, perform) + 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") + } - // 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 + case StepError: + // 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 dm.storage != nil { - return dm.storage, nil + if stepName != "" { + m.updateStep(stepName, StepFailed, update.Message) } } - return res.storage, res.err - case <-ctx.Done(): - // Parent context cancelled - return nil, ctx.Err() } } func (m *BubbleTeaManager) ShowTokenInfo(storage *TokenStorage) { - m.simple.ShowTokenInfo(storage) + if m.renderer != nil { + // Set token info in the renderer + m.renderer.SetTokenInfo(storage) + m.refreshDisplay() + } else { + // Fall back to simple manager + m.simple.ShowTokenInfo(storage) + } } func (m *BubbleTeaManager) ShowVerification(success bool, info string) { - m.simple.ShowVerification(success, info) + if m.renderer != nil { + m.addStep("Verify token") + if success { + m.updateStep("Verify token", StepCompleted, "Token valid") + } else { + m.updateStep("Verify token", StepFailed, info) + } + m.refreshDisplay() + } else { + m.simple.ShowVerification(success, info) + } } func (m *BubbleTeaManager) ShowExistingTokens() { - m.simple.ShowExistingTokens() + if m.renderer != nil { + m.updateStep("Check existing tokens", StepCompleted, "Existing tokens found") + m.refreshDisplay() + } else { + m.simple.ShowExistingTokens() + } } func (m *BubbleTeaManager) ShowTokenStillValid() { - m.simple.ShowTokenStillValid() + // Token is still valid - this is shown via verification step } func (m *BubbleTeaManager) ShowTokenExpired() { - m.simple.ShowTokenExpired() + if m.renderer != nil { + m.addStep("Refresh expired token") + m.updateStep("Refresh expired token", StepInProgress, "") + m.refreshDisplay() + } else { + m.simple.ShowTokenExpired() + } } func (m *BubbleTeaManager) ShowRefreshSuccess() { - m.simple.ShowRefreshSuccess() + if m.renderer != nil { + m.updateStep("Refresh expired token", StepCompleted, "Token refreshed") + m.refreshDisplay() + } else { + m.simple.ShowRefreshSuccess() + } } func (m *BubbleTeaManager) ShowRefreshFailed(err error) { - m.simple.ShowRefreshFailed(err) + if m.renderer != nil { + m.updateStep("Refresh expired token", StepFailed, err.Error()) + m.refreshDisplay() + } else { + m.simple.ShowRefreshFailed(err) + } } func (m *BubbleTeaManager) ShowNewAuthFlow() { - m.simple.ShowNewAuthFlow() + // Starting new auth flow - this is shown via setup step } func (m *BubbleTeaManager) ShowNoExistingTokens() { - m.simple.ShowNoExistingTokens() + if m.renderer != nil { + m.updateStep("Check existing tokens", StepCompleted, "No existing tokens") + m.refreshDisplay() + } else { + m.simple.ShowNoExistingTokens() + } } func (m *BubbleTeaManager) ShowAutoRefreshDemo() { - m.simple.ShowAutoRefreshDemo() + if m.renderer != nil { + m.addStep("API call") + m.updateStep("API call", StepInProgress, "Testing auto-refresh") + m.refreshDisplay() + } else { + m.simple.ShowAutoRefreshDemo() + } } func (m *BubbleTeaManager) ShowAccessTokenRejected() { - m.simple.ShowAccessTokenRejected() + if m.renderer != nil { + m.updateStep("API call", StepInProgress, "Access token rejected, refreshing...") + m.refreshDisplay() + } else { + m.simple.ShowAccessTokenRejected() + } } func (m *BubbleTeaManager) ShowTokenRefreshedRetrying() { - m.simple.ShowTokenRefreshedRetrying() + if m.renderer != nil { + m.updateStep("API call", StepInProgress, "Token refreshed, retrying...") + m.refreshDisplay() + } else { + m.simple.ShowTokenRefreshedRetrying() + } } func (m *BubbleTeaManager) ShowAPICallSuccess() { - m.simple.ShowAPICallSuccess() + if m.renderer != nil { + m.updateStep("API call", StepCompleted, "API call successful") + m.refreshDisplay() + } else { + m.simple.ShowAPICallSuccess() + } } func (m *BubbleTeaManager) ShowRefreshTokenExpired() { - m.simple.ShowRefreshTokenExpired() + if m.renderer != nil { + m.addStep("Re-authenticate") + m.updateStep("Re-authenticate", StepInProgress, "Refresh token expired") + m.refreshDisplay() + } else { + m.simple.ShowRefreshTokenExpired() + } } func (m *BubbleTeaManager) ShowReAuthSuccess() { - m.simple.ShowReAuthSuccess() + if m.renderer != nil { + m.updateStep("Re-authenticate", StepCompleted, "Re-authentication successful") + m.refreshDisplay() + } else { + m.simple.ShowReAuthSuccess() + } } func (m *BubbleTeaManager) ShowUserInfo(success bool, info string) { - m.simple.ShowUserInfo(success, info) + // UserInfo is an additional feature, not shown in main flow + // Could add as a separate step if needed + if m.renderer == nil { + m.simple.ShowUserInfo(success, info) + } +} + +// getTerminalSize returns the width and height of the terminal. +// Returns default values (80, 24) if unable to determine size. +func getTerminalSize() (width, height int) { + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + // Default fallback + return 80, 24 + } + return width, height } diff --git a/tui/components/components_test.go b/tui/components/components_test.go index 794b1ab..e6dc0a9 100644 --- a/tui/components/components_test.go +++ b/tui/components/components_test.go @@ -87,35 +87,37 @@ func TestTimer(t *testing.T) { 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 view renders with initial progress + view := bar.View() + if view == "" { + t.Error("View should not be empty") } - // Test setting progress + // Test setting progress - verify it doesn't panic bar.SetProgress(0.5) - if bar.Progress != 0.5 { - t.Errorf("Expected progress to be 0.5, got %f", bar.Progress) + view = bar.View() + if view == "" { + t.Error("View should not be empty after setting progress") } - // Test clamping to 0-1 range + // Test clamping to 0-1 range - verify it doesn't panic bar.SetProgress(-0.1) - if bar.Progress != 0.0 { - t.Errorf("Expected progress to be clamped to 0.0, got %f", bar.Progress) + view = bar.View() + if view == "" { + t.Error("View should not be empty after setting negative progress") } bar.SetProgress(1.5) - if bar.Progress != 1.0 { - t.Errorf("Expected progress to be clamped to 1.0, got %f", bar.Progress) + view = bar.View() + if view == "" { + t.Error("View should not be empty after setting progress > 1.0") } - // Test view renders - view := bar.View() + // Test width change + bar.SetWidth(60) + view = bar.View() if view == "" { - t.Error("View should not be empty") + t.Error("View should not be empty after changing width") } // Test compact view @@ -173,27 +175,4 @@ func TestInfoBox(t *testing.T) { } } -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, - ) - } - } -} +// TestFormatPercentage removed - formatPercentage is now internal to bubbles/progress diff --git a/tui/components/error_view.go b/tui/components/error_view.go new file mode 100644 index 0000000..74d31f3 --- /dev/null +++ b/tui/components/error_view.go @@ -0,0 +1,185 @@ +package components + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// ErrorView displays rich error information with recommendations +type ErrorView struct { + Title string + Message string + Details string + Recommendations []string + Retryable bool +} + +// NewErrorView creates a new error view +func NewErrorView(title, message string) *ErrorView { + return &ErrorView{ + Title: title, + Message: message, + } +} + +// WithDetails adds details to the error view +func (e *ErrorView) WithDetails(details string) *ErrorView { + e.Details = details + return e +} + +// WithRecommendations adds recommendations to the error view +func (e *ErrorView) WithRecommendations(recs ...string) *ErrorView { + e.Recommendations = recs + return e +} + +// WithRetryable marks the error as retryable +func (e *ErrorView) WithRetryable(retryable bool) *ErrorView { + e.Retryable = retryable + return e +} + +// View renders the error view +func (e *ErrorView) View() string { + var b strings.Builder + + // Title with error icon + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#D32F2F")). + Background(lipgloss.Color("#3E3E3E")). + Padding(0, 2) + + b.WriteString(titleStyle.Render("✗ " + e.Title)) + b.WriteString("\n\n") + + // Error message + messageStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#D32F2F")). + Bold(true) + + b.WriteString(messageStyle.Render(e.Message)) + b.WriteString("\n") + + // Details (if provided) + if e.Details != "" { + b.WriteString("\n") + detailsStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Italic(true) + + b.WriteString(detailsStyle.Render(e.Details)) + b.WriteString("\n") + } + + // Recommendations + if len(e.Recommendations) > 0 { + b.WriteString("\n") + recTitleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#29B6F6")) + + b.WriteString(recTitleStyle.Render("💡 Suggestions:")) + b.WriteString("\n\n") + + recStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + + for i, rec := range e.Recommendations { + bullet := fmt.Sprintf(" %d. ", i+1) + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#29B6F6")). + Render(bullet)) + b.WriteString(recStyle.Render(rec)) + b.WriteString("\n") + } + } + + // Retry hint + if e.Retryable { + b.WriteString("\n") + retryStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFA726")). + Italic(true) + + b.WriteString(retryStyle.Render("↻ You can try authenticating again")) + b.WriteString("\n") + } + + // Wrap in box + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#D32F2F")). + Padding(1, 2). + Width(70) + + return boxStyle.Render(b.String()) +} + +// GetRecommendationsForError returns appropriate recommendations for common errors +func GetRecommendationsForError(err error) []string { + errMsg := err.Error() + + // Network errors + if strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "no such host") || + strings.Contains(errMsg, "network") { + return []string{ + "Check your internet connection", + "Verify the server URL is correct", + "Check if the server is running", + "Try disabling VPN or proxy if enabled", + } + } + + // Timeout errors + if strings.Contains(errMsg, "timeout") || + strings.Contains(errMsg, "deadline exceeded") { + return []string{ + "Check your internet connection speed", + "The server might be slow to respond - try again", + "Increase timeout if using custom configuration", + } + } + + // Authentication errors + if strings.Contains(errMsg, "unauthorized") || + strings.Contains(errMsg, "forbidden") || + strings.Contains(errMsg, "access_denied") { + return []string{ + "Verify your client ID is correct", + "Ensure you have permission to access this resource", + "Check if your account has the required scopes", + } + } + + // Browser errors + if strings.Contains(errMsg, "browser") || + strings.Contains(errMsg, "open") { + return []string{ + "Ensure a web browser is installed", + "Try running in a different environment", + "Use device flow with --device flag instead", + } + } + + // Token errors + if strings.Contains(errMsg, "token") || + strings.Contains(errMsg, "invalid_grant") { + return []string{ + "Try authenticating again", + "Clear any cached tokens", + "Verify the authorization code hasn't expired", + } + } + + // Default recommendations + return []string{ + "Try authenticating again", + "Check the error message for more details", + "Contact your administrator if the problem persists", + } +} diff --git a/tui/components/help_view.go b/tui/components/help_view.go new file mode 100644 index 0000000..5990337 --- /dev/null +++ b/tui/components/help_view.go @@ -0,0 +1,97 @@ +package components + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" +) + +// KeyMap defines the key bindings for the TUI +type KeyMap struct { + Cancel key.Binding + Help key.Binding + Quit key.Binding + Up key.Binding + Down key.Binding +} + +// ShortHelp returns a short help text for the key map +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Cancel, k.Help} +} + +// FullHelp returns the full help text for the key map +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Cancel, k.Help}, + {k.Up, k.Down, k.Quit}, + } +} + +// DefaultKeyMap returns the default key bindings +func DefaultKeyMap() KeyMap { + return KeyMap{ + Cancel: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "cancel authentication"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc"), + key.WithHelp("q/esc", "quit"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "scroll up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "scroll down"), + ), + } +} + +// HelpView wraps the bubbles help component +type HelpView struct { + help help.Model + keys KeyMap + width int + hidden bool +} + +// NewHelpView creates a new help view +func NewHelpView() *HelpView { + return &HelpView{ + help: help.New(), + keys: DefaultKeyMap(), + width: 80, + hidden: true, + } +} + +// SetWidth sets the width of the help view +func (h *HelpView) SetWidth(width int) { + h.width = width + h.help.SetWidth(width) +} + +// Toggle toggles the help visibility +func (h *HelpView) Toggle() { + h.hidden = !h.hidden + 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 { + return h.help.ShortHelpView(h.keys.ShortHelp()) + } + return h.help.View(h.keys) +} diff --git a/tui/components/info_box.go b/tui/components/info_box.go index e13ac77..70537b9 100644 --- a/tui/components/info_box.go +++ b/tui/components/info_box.go @@ -3,7 +3,7 @@ package components import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // InfoBox displays information in a bordered box diff --git a/tui/components/progress_bar.go b/tui/components/progress_bar.go index dad3fc5..4ee1af0 100644 --- a/tui/components/progress_bar.go +++ b/tui/components/progress_bar.go @@ -1,23 +1,27 @@ package components import ( - "fmt" - "strings" + "image/color" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/progress" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) -// ProgressBar displays a visual progress bar +// ProgressBar displays a visual progress bar using Bubbles v2 progress component type ProgressBar struct { - Progress float64 // 0.0 to 1.0 - Width int + model progress.Model } -// NewProgressBar creates a new progress bar +// NewProgressBar creates a new progress bar with gradient fill func NewProgressBar(width int) *ProgressBar { + p := progress.New( + progress.WithDefaultBlend(), // Purple haze to neon pink gradient + progress.WithWidth(width), + progress.WithoutPercentage(), // We'll show percentage separately + ) return &ProgressBar{ - Progress: 0.0, - Width: width, + model: p, } } @@ -29,39 +33,40 @@ func (p *ProgressBar) SetProgress(progress float64) { if progress > 1 { progress = 1 } - p.Progress = progress + p.model.SetPercent(progress) +} + +// Update handles progress bar messages (for animations) +func (p *ProgressBar) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.model, cmd = p.model.Update(msg) + return cmd } // 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)) + bar := p.model.View() + // Add percentage display percentage := lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). - Render(" " + formatPercentage(p.Progress)) + Render(p.model.ViewAs(p.model.Percent())) - return bar + percentage + return bar + " " + percentage } // ViewCompact renders a compact version of the progress bar func (p *ProgressBar) ViewCompact() string { - return formatPercentage(p.Progress) + return p.model.ViewAs(p.model.Percent()) +} + +// SetWidth updates the progress bar width +func (p *ProgressBar) SetWidth(width int) { + p.model.SetWidth(width) } -// formatPercentage formats progress as a percentage -func formatPercentage(progress float64) string { - percentage := int(progress * 100) - return fmt.Sprintf("%d%%", percentage) +// SetColors sets custom colors for the progress bar +func (p *ProgressBar) SetColors(fullColor, emptyColor color.Color) { + p.model.FullColor = fullColor + p.model.EmptyColor = emptyColor } diff --git a/tui/components/qrcode.go b/tui/components/qrcode.go new file mode 100644 index 0000000..3993852 --- /dev/null +++ b/tui/components/qrcode.go @@ -0,0 +1,75 @@ +package components + +import ( + "strings" + + "github.com/skip2/go-qrcode" +) + +// RenderQRCode generates a terminal-friendly QR code for the given URL. +// It uses Unicode half blocks (▀ ▄ █) to render the QR code in the terminal. +func RenderQRCode(url string) (string, error) { + // Generate QR code with medium error correction + q, err := qrcode.New(url, qrcode.Medium) + if err != nil { + return "", err + } + + // Get the bitmap representation + bitmap := q.Bitmap() + + var sb strings.Builder + + // Process bitmap in pairs of rows (using half blocks) + for y := 0; y < len(bitmap); y += 2 { + for x := 0; x < len(bitmap[y]); x++ { + top := bitmap[y][x] + bottom := false + if y+1 < len(bitmap) { + bottom = bitmap[y+1][x] + } + + // Choose character based on which pixels are filled + switch { + case top && bottom: + sb.WriteRune('█') // Full block + case top && !bottom: + sb.WriteRune('▀') // Upper half block + case !top && bottom: + sb.WriteRune('▄') // Lower half block + default: + sb.WriteRune(' ') // Empty space + } + } + sb.WriteRune('\n') + } + + return sb.String(), nil +} + +// RenderQRCodeASCII generates an ASCII-only QR code (for terminals without Unicode support). +func RenderQRCodeASCII(url string) (string, error) { + // Generate QR code with medium error correction + q, err := qrcode.New(url, qrcode.Medium) + if err != nil { + return "", err + } + + // Get the bitmap representation + bitmap := q.Bitmap() + + var sb strings.Builder + + for y := range len(bitmap) { + for x := range len(bitmap[y]) { + if bitmap[y][x] { + sb.WriteString("##") + } else { + sb.WriteString(" ") + } + } + sb.WriteRune('\n') + } + + return sb.String(), nil +} diff --git a/tui/components/step_indicator.go b/tui/components/step_indicator.go index fb638c8..15f8f8c 100644 --- a/tui/components/step_indicator.go +++ b/tui/components/step_indicator.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // StepIndicator displays progress through a multi-step process diff --git a/tui/components/timer.go b/tui/components/timer.go index ea74e89..f65e604 100644 --- a/tui/components/timer.go +++ b/tui/components/timer.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // Timer displays elapsed time or countdown diff --git a/tui/config.go b/tui/config.go new file mode 100644 index 0000000..9b08e7b --- /dev/null +++ b/tui/config.go @@ -0,0 +1,79 @@ +package tui + +import ( + "os" + "strings" +) + +// Config holds configuration options for the TUI +type Config struct { + EnableAnimations bool + EnableQRCodes bool + Theme ThemePreset + UseUnicode bool +} + +// ThemePreset represents a color theme +type ThemePreset int + +const ( + // ThemeDefault uses the standard color palette + ThemeDefault ThemePreset = iota + // ThemeHighContrast uses high contrast colors with bold/underline + ThemeHighContrast + // ThemeColorBlindFriendly uses shapes + colors for accessibility + ThemeColorBlindFriendly +) + +// DefaultConfig returns the default configuration +func DefaultConfig() Config { + return Config{ + EnableAnimations: true, + EnableQRCodes: true, + Theme: ThemeDefault, + UseUnicode: true, + } +} + +// LoadConfig loads configuration from environment variables +func LoadConfig() Config { + config := DefaultConfig() + + // AUTHGATE_DISABLE_ANIMATIONS=true - disable all animations + if os.Getenv("AUTHGATE_DISABLE_ANIMATIONS") == "true" { + config.EnableAnimations = false + } + + // AUTHGATE_DISABLE_QRCODES=true - skip QR code generation + if os.Getenv("AUTHGATE_DISABLE_QRCODES") == "true" { + config.EnableQRCodes = false + } + + // AUTHGATE_NO_UNICODE=true - use ASCII only (no emoji, no blocks) + if os.Getenv("AUTHGATE_NO_UNICODE") == "true" { + config.UseUnicode = false + } + + // AUTHGATE_THEME= - set color theme + themeStr := strings.ToLower(os.Getenv("AUTHGATE_THEME")) + switch themeStr { + case "high-contrast", "highcontrast": + config.Theme = ThemeHighContrast + case "colorblind", "colorblind-friendly", "accessible": + config.Theme = ThemeColorBlindFriendly + default: + config.Theme = ThemeDefault + } + + return config +} + +// ShouldUseQRCode determines if QR codes should be shown based on config +func (c *Config) ShouldUseQRCode() bool { + return c.EnableQRCodes && c.UseUnicode +} + +// ShouldUseAnimations determines if animations should be shown +func (c *Config) ShouldUseAnimations() bool { + return c.EnableAnimations +} diff --git a/tui/device_model.go b/tui/device_model.go index 76f88a5..96d013b 100644 --- a/tui/device_model.go +++ b/tui/device_model.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "github.com/go-authgate/cli/tui/components" ) @@ -26,6 +26,8 @@ type DeviceModel struct { stepIndicator *components.StepIndicator timer *components.Timer infoBox *components.InfoBox + helpView *components.HelpView + config Config status string done bool success bool @@ -39,12 +41,24 @@ type DeviceModel struct { userCancelled bool } -// NewDeviceModel creates a new device flow TUI model. +// NewDeviceModel creates a new device flow TUI model with default config. func NewDeviceModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *DeviceModel { + return NewDeviceModelWithConfig(updatesCh, cancel, LoadConfig()) +} + +// NewDeviceModelWithConfig creates a new device flow TUI model with custom config. +func NewDeviceModelWithConfig( + updatesCh <-chan FlowUpdate, + cancel context.CancelFunc, + config Config, +) *DeviceModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = SpinnerStyle + helpView := components.NewHelpView() + helpView.SetWidth(80) + return &DeviceModel{ totalSteps: 2, stepNames: []string{"Requesting device code", "Waiting for authorization"}, @@ -55,6 +69,8 @@ func NewDeviceModel(updatesCh <-chan FlowUpdate, cancel context.CancelFunc) *Dev ), timer: components.NewElapsedTimer(), infoBox: components.NewInfoBox("Device Authorization", 60), + helpView: helpView, + config: config, updatesCh: updatesCh, status: "Initializing...", width: 80, @@ -76,10 +92,10 @@ func (m *DeviceModel) Init() tea.Cmd { // 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: - //nolint:exhaustive // We only handle Ctrl+C, other keys are ignored - switch msg.Type { - case tea.KeyCtrlC: + case tea.KeyPressMsg: + // Check for Ctrl+C + key := msg.Key() + if key.Code == 'c' && key.Mod == tea.ModCtrl { // Cancel the OAuth flow if m.cancel != nil { m.cancel() @@ -89,8 +105,12 @@ func (m *DeviceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Give a brief moment for the cancellation to propagate time.Sleep(100 * time.Millisecond) return m, tea.Quit - default: - // Ignore other keys + } + + // Toggle help with '?' + if key.Code == '?' { + m.helpView.Toggle() + return m, nil } case tea.WindowSizeMsg: @@ -100,6 +120,8 @@ func (m *DeviceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.width > 65 { m.infoBox.Width = m.width - 20 } + // Update help view width + m.helpView.SetWidth(m.width - 4) return m, nil case FlowUpdate: @@ -192,7 +214,7 @@ func (m *DeviceModel) handleFlowUpdate(update FlowUpdate) tea.Cmd { } // View renders the model. -func (m *DeviceModel) View() string { +func (m *DeviceModel) View() tea.View { // Let device_view.go handle the rendering - return renderDeviceView(m) + return tea.NewView(renderDeviceView(m)) } diff --git a/tui/device_view.go b/tui/device_view.go index 7450c54..f96ec37 100644 --- a/tui/device_view.go +++ b/tui/device_view.go @@ -5,7 +5,8 @@ import ( "strings" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" + "github.com/go-authgate/cli/tui/components" ) // renderDeviceView renders the device code OAuth flow view. @@ -32,7 +33,8 @@ func renderDeviceView(m *DeviceModel) string { b.WriteString("\n\n") // Device code info box (when available) - if m.userCode != "" { + if m.verificationURIComplete != "" { + // Show device code info box b.WriteString(m.infoBox.View()) b.WriteString("\n\n") } @@ -83,9 +85,8 @@ func renderDeviceView(m *DeviceModel) string { b.WriteString("\n\n") } - // Help text - helpText := HelpStyle.Render("Press Ctrl+C to cancel") - b.WriteString(helpText) + // Help view + b.WriteString(m.helpView.View()) b.WriteString("\n") return AppContainerStyle.Render(b.String()) @@ -96,9 +97,12 @@ func renderDeviceComplete(m *DeviceModel) string { var b strings.Builder if m.err != nil { - // Error state + // Error state with enhanced display b.WriteString("\n") - b.WriteString(RenderError(fmt.Sprintf("Authentication failed: %v", m.err))) + errView := components.NewErrorView("Authorization Failed", m.err.Error()). + WithRecommendations(components.GetRecommendationsForError(m.err)...). + WithRetryable(true) + b.WriteString(errView.View()) b.WriteString("\n\n") } else { // Success state @@ -113,16 +117,13 @@ func renderDeviceComplete(m *DeviceModel) string { b.WriteString("\n\n") if m.storage != nil { - // Show token preview + // Show masked token preview infoStyle := lipgloss.NewStyle(). Foreground(colorSubtle) - preview := m.storage.AccessToken - if len(preview) > 50 { - preview = preview[:50] + "..." - } - - b.WriteString(infoStyle.Render("Access Token: " + preview)) + // Mask the token for security + masked := maskTokenPreview(m.storage.AccessToken) + b.WriteString(infoStyle.Render("Access Token: " + masked)) b.WriteString("\n") } } diff --git a/tui/flow_renderer.go b/tui/flow_renderer.go new file mode 100644 index 0000000..2acb339 --- /dev/null +++ b/tui/flow_renderer.go @@ -0,0 +1,411 @@ +package tui + +import ( + "fmt" + "os" + "strings" + "time" + + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// FlowRenderer manages rendering of the unified flow view without running a full Bubble Tea program +type FlowRenderer struct { + model *UnifiedFlowModel + spinnerFrame int + spinnerChars []rune + lastContent string // Track last rendered content to avoid unnecessary redraws + contentDirty bool // Flag to indicate if content needs redraw + inProgressStepIdx int // Index of the in-progress step for spinner updates + hasInProgressStep bool // Whether there's a step in progress + deviceUserCode string // Device code to display + deviceVerificationURI string // Device verification URL + deviceVerificationURIComplete string // Complete URL with user code + showDeviceCode bool // Whether to show device code info +} + +// NewFlowRenderer creates a new flow renderer +func NewFlowRenderer(flowType, clientMode, serverURL string) *FlowRenderer { + // Get terminal size + width, height := getTerminalSize() + + model := NewUnifiedFlowModel(flowType, clientMode, serverURL) + model.width = width + model.height = height + + return &FlowRenderer{ + model: model, + spinnerFrame: 0, + spinnerChars: []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}, + } +} + +// AddWarning adds a warning message +func (r *FlowRenderer) AddWarning(warning string) { + r.model.AddWarning(warning) +} + +// AddStep adds a new step and returns its index +func (r *FlowRenderer) AddStep(name string) int { + r.contentDirty = true + return r.model.AddStep(name) +} + +// UpdateStep updates a step's status and message +func (r *FlowRenderer) UpdateStep(index int, status FlowStepStatus, message string) { + r.contentDirty = true + r.model.UpdateStep(index, status, message) + + // Track if this step is in progress for spinner animation + if status == StepInProgress { + r.hasInProgressStep = true + r.inProgressStepIdx = index + } else if r.inProgressStepIdx == index { + r.hasInProgressStep = false + } +} + +// SetTokenInfo sets the token information +func (r *FlowRenderer) SetTokenInfo(storage *TokenStorage) { + r.contentDirty = true + r.model.SetTokenInfo(storage) +} + +// SetDeviceCode sets the device code information +func (r *FlowRenderer) SetDeviceCode(userCode, verificationURI, verificationURIComplete string) { + r.contentDirty = true + r.deviceUserCode = userCode + r.deviceVerificationURI = verificationURI + r.deviceVerificationURIComplete = verificationURIComplete + r.showDeviceCode = true +} + +// NextSpinner advances the spinner animation and returns the current frame +func (r *FlowRenderer) NextSpinner() string { + r.spinnerFrame = (r.spinnerFrame + 1) % len(r.spinnerChars) + spinnerStyle := lipgloss.NewStyle().Foreground(colorPrimary) + return spinnerStyle.Render(string(r.spinnerChars[r.spinnerFrame])) +} + +// RenderHeader renders and prints the header +func (r *FlowRenderer) RenderHeader() { + // Clear screen and move to top + fmt.Print("\033[H\033[2J") + + var b strings.Builder + + // Header box + headerStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(0, 2). + Width(min(r.model.width-4, 70)) + + var content strings.Builder + + // Title line + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) + content.WriteString(titleStyle.Render(r.model.flowType)) + content.WriteString("\n") + + // Info line + infoStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + + info := fmt.Sprintf("Mode: %s Server: %s", r.model.clientMode, r.model.serverURL) + content.WriteString(infoStyle.Render(info)) + + b.WriteString(headerStyle.Render(content.String())) + b.WriteString("\n\n") + + // Warnings + if len(r.model.warnings) > 0 { + warningStyle := lipgloss.NewStyle(). + Foreground(colorWarning). + Bold(true) + + for _, warning := range r.model.warnings { + b.WriteString(" ") + b.WriteString(warningStyle.Render("WARNING: ")) + b.WriteString(warning) + b.WriteString("\n") + } + b.WriteString("\n") + } + + fmt.Print(b.String()) +} + +// UpdateDisplay updates the display with current state +func (r *FlowRenderer) UpdateDisplay() { + // If only spinner changed (not content), do a minimal update + if !r.contentDirty && r.hasInProgressStep { + r.updateSpinnerOnly() + return + } + + // Skip if nothing changed + if !r.contentDirty { + return + } + + // Build current content + var b strings.Builder + + // Steps + if len(r.model.steps) > 0 { + for _, step := range r.model.steps { + b.WriteString(" ") + + // Status icon + var icon string + iconStyle := lipgloss.NewStyle() + + switch step.Status { + case StepCompleted: + icon = "✓" + iconStyle = iconStyle.Foreground(colorSuccess).Bold(true) + case StepFailed: + icon = "✗" + iconStyle = iconStyle.Foreground(colorError).Bold(true) + case StepInProgress: + // Use animated spinner for in-progress steps + icon = string(r.spinnerChars[r.spinnerFrame]) + iconStyle = iconStyle.Foreground(colorPrimary) + case StepSkipped: + icon = "○" + iconStyle = iconStyle.Foreground(colorSubtle) + default: // StepPending + icon = "○" + iconStyle = iconStyle.Foreground(colorMuted) + } + + b.WriteString(iconStyle.Render(icon)) + b.WriteString(" ") + + // Step name + nameStyle := lipgloss.NewStyle() + if step.Status == StepInProgress { + nameStyle = nameStyle.Bold(true) + } + b.WriteString(nameStyle.Render(step.Name)) + + // Additional message + if step.Message != "" { + messageStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + b.WriteString(" ") + b.WriteString(messageStyle.Render(step.Message)) + } + + b.WriteString("\n") + } + b.WriteString("\n") + } + + // Device code info (for device flow) + if r.showDeviceCode { + b.WriteString(r.renderDeviceCodeInfo()) + b.WriteString("\n") + } + + // Token info + if r.model.showToken && r.model.tokenStorage != nil { + b.WriteString(r.renderTokenInfo()) + b.WriteString("\n") + } + + currentContent := b.String() + + // Move cursor to after header + headerLines := 5 + len(r.model.warnings) + + // Move to position after header + fmt.Printf("\033[%d;0H", headerLines) + // Clear from cursor to end of screen + fmt.Print("\033[J") + + // Print new content + fmt.Print(currentContent) + + r.lastContent = currentContent + r.contentDirty = false +} + +// updateSpinnerOnly updates only the spinner character without redrawing everything +func (r *FlowRenderer) updateSpinnerOnly() { + if !r.hasInProgressStep { + return + } + + // Calculate the line number for the in-progress step + headerLines := 5 + len(r.model.warnings) + stepLine := headerLines + r.inProgressStepIdx + + // Render the spinner character + spinnerStyle := lipgloss.NewStyle().Foreground(colorPrimary) + spinnerChar := spinnerStyle.Render(string(r.spinnerChars[r.spinnerFrame])) + + // Move cursor to the spinner position (after " ") + fmt.Printf("\033[%d;3H", stepLine) + // Print just the spinner character + fmt.Print(spinnerChar) + // Move cursor back to end (to avoid cursor showing) + fmt.Print("\033[999;999H") +} + +// RenderFinal renders the final complete view +func (r *FlowRenderer) RenderFinal() { + // Clear screen + fmt.Print("\033[H\033[2J") + + // Use the model's View method for final render + r.model.done = true + view := r.model.View() + fmt.Println(view.Content) +} + +// renderDeviceCodeInfo renders the device code information box +func (r *FlowRenderer) renderDeviceCodeInfo() string { + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorInfo). + Padding(1, 2). + Width(min(r.model.width-4, 80)) + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo) + content.WriteString(titleStyle.Render("Device Authorization")) + content.WriteString("\n\n") + + // User Code - Make it very prominent + userCodeLabelStyle := lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true) + userCodeStyle := lipgloss.NewStyle(). + Foreground(colorBright). + Bold(true). + Background(lipgloss.Color("#1a1a1a")). + Padding(0, 1) + + content.WriteString(userCodeLabelStyle.Render("User Code: ")) + content.WriteString(userCodeStyle.Render(r.deviceUserCode)) + content.WriteString("\n\n") + + // URL styles + urlLabelStyle := lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true) + urlStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#29B6F6")). + Underline(true) + + // Show complete URL if available (with user_code parameter) + if r.deviceVerificationURIComplete != "" { + content.WriteString(urlLabelStyle.Render("Complete URL (click to open): ")) + content.WriteString("\n") + content.WriteString(urlStyle.Render(r.deviceVerificationURIComplete)) + content.WriteString("\n\n") + + // Also show basic URL as fallback + content.WriteString(urlLabelStyle.Render("Or visit: ")) + content.WriteString(urlStyle.Render(r.deviceVerificationURI)) + content.WriteString("\n") + content.WriteString( + lipgloss.NewStyle().Foreground(colorSubtle).Render( + "(and enter code: " + r.deviceUserCode + ")", + ), + ) + } else { + // Only basic URL available + content.WriteString(urlLabelStyle.Render("URL: ")) + content.WriteString(urlStyle.Render(r.deviceVerificationURI)) + content.WriteString("\n\n") + + // Instructions + instructionStyle := lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true) + content.WriteString( + instructionStyle.Render( + "Visit the URL above and enter the user code to authorize this device.", + ), + ) + } + + return boxStyle.Render(content.String()) +} + +// renderTokenInfo renders the token information box +func (r *FlowRenderer) renderTokenInfo() string { + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorSuccess). + Padding(1, 2). + Width(min(r.model.width-4, 60)) + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(colorSuccess) + content.WriteString(titleStyle.Render("Token Info")) + content.WriteString("\n\n") + + // Security notice + noticeStyle := lipgloss.NewStyle(). + Foreground(colorWarning). + Italic(true). + Width(55) + + tokenFile := getTokenFilePath() + content.WriteString(noticeStyle.Render( + "🔒 Full tokens stored in: " + tokenFile, + )) + content.WriteString("\n\n") + + // Access Token (masked) + labelStyle := lipgloss.NewStyle(). + Foreground(colorInfo). + Width(18) + valueStyle := lipgloss.NewStyle(). + Foreground(colorBright) + + content.WriteString(labelStyle.Render("Access Token:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(maskTokenPreview(r.model.tokenStorage.AccessToken))) + content.WriteString("\n") + + // Token Type + content.WriteString(labelStyle.Render("Token Type:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(r.model.tokenStorage.TokenType)) + content.WriteString("\n") + + // Expires In + if !r.model.tokenStorage.ExpiresAt.IsZero() { + remaining := time.Until(r.model.tokenStorage.ExpiresAt) + content.WriteString(labelStyle.Render("Expires In:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(formatDuration(remaining))) + } + + return boxStyle.Render(content.String()) +} + +// IsTerminalInteractive checks if we should use the interactive renderer +func IsTerminalInteractive() bool { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + // Check if we're in CI environment + return !shouldUseSimpleUI() +} diff --git a/tui/simple_manager.go b/tui/simple_manager.go index 121afca..a11b5ce 100644 --- a/tui/simple_manager.go +++ b/tui/simple_manager.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "os" "time" ) @@ -200,13 +201,26 @@ func (m *SimpleManager) handleDeviceUpdate(update FlowUpdate) { 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("========================================\n") + + // Security notice + tokenFile := os.Getenv("TOKEN_FILE") + if tokenFile == "" { + tokenFile = ".authgate-tokens.json" + } + fmt.Printf("🔒 Full tokens stored in: %s\n\n", tokenFile) + + // Access Token (masked) + maskedAccess := maskTokenSimple(storage.AccessToken) + fmt.Printf("Access Token : %s\n", maskedAccess) + + // Refresh Token (masked, if present) + if storage.RefreshToken != "" { + maskedRefresh := maskTokenSimple(storage.RefreshToken) + fmt.Printf("Refresh Token: %s\n", maskedRefresh) } - fmt.Printf("Access Token : %s...\n", preview) - fmt.Printf("Token Type : %s\n", storage.TokenType) + fmt.Printf("Token Type : %s\n", storage.TokenType) fmt.Printf("Expires In : %s\n", time.Until(storage.ExpiresAt).Round(time.Second)) if storage.Flow != "" { @@ -215,6 +229,17 @@ 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 b6311a0..90c8cae 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // Color palette @@ -154,3 +154,14 @@ func RenderCode(code string) string { func RenderHelp(text string) string { return HelpStyle.Render(text) } + +// maskTokenPreview masks token for preview display (shows first 8 and last 4 chars) +func maskTokenPreview(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:] +} diff --git a/tui/token_view.go b/tui/token_view.go new file mode 100644 index 0000000..5766026 --- /dev/null +++ b/tui/token_view.go @@ -0,0 +1,263 @@ +package tui + +import ( + "fmt" + "os" + "strings" + "time" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// TokenViewModel displays token information in a scrollable viewport +type TokenViewModel struct { + viewport viewport.Model + storage *TokenStorage + ready bool + width int + height int +} + +// NewTokenViewModel creates a new token view model +func NewTokenViewModel(storage *TokenStorage, width, height int) *TokenViewModel { + // Reserve space for header, footer, and borders + contentHeight := max(height-6, 5) + + vp := viewport.New( + viewport.WithWidth(width-4), + viewport.WithHeight(contentHeight), + ) + vp.SetContent(formatTokenDetails(storage)) + + return &TokenViewModel{ + viewport: vp, + storage: storage, + ready: true, + width: width, + height: height, + } +} + +// Init initializes the model +func (m *TokenViewModel) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m *TokenViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyPressMsg: + key := msg.Key() + + // Quit on 'q' or Ctrl+C + if key.Code == 'q' || (key.Code == 'c' && key.Mod == tea.ModCtrl) { + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Recalculate viewport dimensions + contentHeight := max(m.height-6, 5) + + // Update viewport size using correct methods + m.viewport = viewport.New( + viewport.WithWidth(m.width-4), + viewport.WithHeight(contentHeight), + ) + // Re-set content after resizing + m.viewport.SetContent(formatTokenDetails(m.storage)) + } + + // Update viewport + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// View renders the token view +func (m *TokenViewModel) View() tea.View { + if !m.ready { + return tea.NewView("Loading...") + } + + // Title + title := lipgloss.NewStyle(). + Bold(true). + Foreground(colorSuccess). + Render("✓ Authentication Successful") + + // Viewport with border + viewportStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorSuccess). + Padding(0, 1) + + content := viewportStyle.Render(m.viewport.View()) + + // Help text + help := lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true). + Render("↑/↓: scroll • q: quit") + + // Combine all parts + view := lipgloss.JoinVertical( + lipgloss.Left, + "", + title, + "", + content, + "", + help, + ) + + 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 != "" { + return path + } + return ".authgate-tokens.json" +} + +// formatTokenDetails formats token information for display +func formatTokenDetails(storage *TokenStorage) string { + var b strings.Builder + + // Security notice + securityNoticeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFA726")). + Italic(true) + b.WriteString( + securityNoticeStyle.Render("🔒 Tokens are masked for security. Full tokens stored in:"), + ) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#29B6F6")). + Bold(true). + Render(" " + getTokenFilePath())) + b.WriteString("\n\n") + + // Access Token (masked) + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Access Token:")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle(). + Foreground(colorSubtle). + Render(maskToken(storage.AccessToken))) + b.WriteString("\n\n") + + // Refresh Token (masked) + if storage.RefreshToken != "" { + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Refresh Token:")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle(). + Foreground(colorSubtle). + Render(maskToken(storage.RefreshToken))) + b.WriteString("\n\n") + } + + // Token Type + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Token Type:")) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle(). + Foreground(colorBright). + Render(storage.TokenType)) + b.WriteString("\n\n") + + // Expiry + if !storage.ExpiresAt.IsZero() { + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Expires At:")) + b.WriteString(" ") + + expiry := storage.ExpiresAt.Format(time.RFC3339) + remaining := time.Until(storage.ExpiresAt) + + expiryText := fmt.Sprintf("%s (in %s)", expiry, formatDurationHuman(remaining)) + b.WriteString(lipgloss.NewStyle(). + Foreground(colorBright). + Render(expiryText)) + b.WriteString("\n\n") + } + + // Client ID + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Client ID:")) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle(). + Foreground(colorBright). + Render(storage.ClientID)) + b.WriteString("\n\n") + + // Flow Type + b.WriteString(lipgloss.NewStyle(). + Bold(true). + Foreground(colorInfo). + Render("Flow:")) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle(). + Foreground(colorBright). + Render(storage.Flow)) + b.WriteString("\n") + + 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 new file mode 100644 index 0000000..06488b0 --- /dev/null +++ b/tui/unified_flow_view.go @@ -0,0 +1,333 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// FlowStepStatus represents the status of a flow step +type FlowStepStatus int + +const ( + StepPending FlowStepStatus = iota + StepInProgress + StepCompleted + StepFailed + StepSkipped +) + +// FlowStep represents a single step in the auth flow +type FlowStep struct { + Name string + Status FlowStepStatus + Message string // Additional message shown after step name +} + +// UnifiedFlowModel is the main model for the unified TUI flow +type UnifiedFlowModel struct { + // Header info + flowType string // "OAuth 2.0 Authorization Code Flow" + clientMode string // "confidential" or "public" + serverURL string + + // Warnings + warnings []string + + // Steps + steps []*FlowStep + + // Token info (shown at the end) + tokenStorage *TokenStorage + showToken bool + + // Control + done bool + width int + height int +} + +// NewUnifiedFlowModel creates a new unified flow model +func NewUnifiedFlowModel(flowType, clientMode, serverURL string) *UnifiedFlowModel { + return &UnifiedFlowModel{ + flowType: flowType, + clientMode: clientMode, + serverURL: serverURL, + warnings: []string{}, + steps: []*FlowStep{}, + width: 80, + height: 24, + } +} + +// AddWarning adds a warning message +func (m *UnifiedFlowModel) AddWarning(warning string) { + m.warnings = append(m.warnings, warning) +} + +// AddStep adds a new step to the flow +func (m *UnifiedFlowModel) AddStep(name string) int { + step := &FlowStep{ + Name: name, + Status: StepPending, + } + m.steps = append(m.steps, step) + return len(m.steps) - 1 +} + +// UpdateStep updates a step's status and message +func (m *UnifiedFlowModel) UpdateStep(index int, status FlowStepStatus, message string) { + if index >= 0 && index < len(m.steps) { + m.steps[index].Status = status + m.steps[index].Message = message + } +} + +// SetTokenInfo sets the token information to display +func (m *UnifiedFlowModel) SetTokenInfo(storage *TokenStorage) { + m.tokenStorage = storage + m.showToken = true +} + +// SetDone marks the flow as complete +func (m *UnifiedFlowModel) SetDone() { + m.done = true +} + +// Init initializes the model +func (m *UnifiedFlowModel) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m *UnifiedFlowModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + key := msg.Key() + // Allow quit with 'q' or Ctrl+C when done + if m.done && (key.Code == 'q' || (key.Code == 'c' && key.Mod == tea.ModCtrl)) { + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + } + + return m, nil +} + +// View renders the unified flow view +func (m *UnifiedFlowModel) View() tea.View { + var b strings.Builder + + // Header box + b.WriteString(m.renderHeader()) + b.WriteString("\n\n") + + // Warnings + if len(m.warnings) > 0 { + b.WriteString(m.renderWarnings()) + b.WriteString("\n\n") + } + + // Steps + if len(m.steps) > 0 { + b.WriteString(m.renderSteps()) + b.WriteString("\n\n") + } + + // Token info + if m.showToken && m.tokenStorage != nil { + b.WriteString(m.renderTokenInfo()) + b.WriteString("\n") + } + + // Help text when done + if m.done { + helpStyle := lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true) + b.WriteString("\n") + b.WriteString(helpStyle.Render("Press q to quit")) + b.WriteString("\n") + } + + return tea.NewView(b.String()) +} + +// renderHeader renders the header box +func (m *UnifiedFlowModel) renderHeader() string { + headerStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(0, 2). + Width(min(m.width-4, 70)) + + var content strings.Builder + + // Title line + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) + content.WriteString(titleStyle.Render(m.flowType)) + content.WriteString("\n") + + // Info line + infoStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + + info := fmt.Sprintf("Mode: %s Server: %s", m.clientMode, m.serverURL) + content.WriteString(infoStyle.Render(info)) + + return headerStyle.Render(content.String()) +} + +// renderWarnings renders warning messages +func (m *UnifiedFlowModel) renderWarnings() string { + var b strings.Builder + + warningStyle := lipgloss.NewStyle(). + Foreground(colorWarning). + Bold(true) + + for _, warning := range m.warnings { + b.WriteString(" ") + b.WriteString(warningStyle.Render("WARNING: ")) + b.WriteString(warning) + b.WriteString("\n") + } + + return b.String() +} + +// renderSteps renders the step checklist +func (m *UnifiedFlowModel) renderSteps() string { + var b strings.Builder + + for _, step := range m.steps { + b.WriteString(" ") + + // Status icon + var icon string + iconStyle := lipgloss.NewStyle() + + switch step.Status { + case StepCompleted: + icon = "✓" + iconStyle = iconStyle.Foreground(colorSuccess).Bold(true) + case StepFailed: + icon = "✗" + iconStyle = iconStyle.Foreground(colorError).Bold(true) + case StepInProgress: + icon = "●" + iconStyle = iconStyle.Foreground(colorPrimary) + case StepSkipped: + icon = "○" + iconStyle = iconStyle.Foreground(colorSubtle) + default: // StepPending + icon = "○" + iconStyle = iconStyle.Foreground(colorMuted) + } + + b.WriteString(iconStyle.Render(icon)) + b.WriteString(" ") + + // Step name + nameStyle := lipgloss.NewStyle() + if step.Status == StepInProgress { + nameStyle = nameStyle.Bold(true) + } + b.WriteString(nameStyle.Render(step.Name)) + + // Additional message + if step.Message != "" { + messageStyle := lipgloss.NewStyle(). + Foreground(colorSubtle) + b.WriteString(" ") + b.WriteString(messageStyle.Render(step.Message)) + } + + b.WriteString("\n") + } + + return b.String() +} + +// renderTokenInfo renders the token information box +func (m *UnifiedFlowModel) renderTokenInfo() string { + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorSuccess). + Padding(1, 2). + Width(min(m.width-4, 60)) + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(colorSuccess) + content.WriteString(titleStyle.Render("Token Info")) + content.WriteString("\n\n") + + // Security notice + noticeStyle := lipgloss.NewStyle(). + Foreground(colorWarning). + Italic(true). + Width(55) + + tokenFile := getTokenFilePath() + content.WriteString(noticeStyle.Render( + "🔒 Full tokens stored in: " + tokenFile, + )) + content.WriteString("\n\n") + + // Access Token (masked) + labelStyle := lipgloss.NewStyle(). + Foreground(colorInfo). + Width(18) + valueStyle := lipgloss.NewStyle(). + Foreground(colorBright) + + content.WriteString(labelStyle.Render("Access Token:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(maskTokenPreview(m.tokenStorage.AccessToken))) + content.WriteString("\n") + + // Token Type + content.WriteString(labelStyle.Render("Token Type:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(m.tokenStorage.TokenType)) + content.WriteString("\n") + + // Expires In + if !m.tokenStorage.ExpiresAt.IsZero() { + remaining := time.Until(m.tokenStorage.ExpiresAt) + content.WriteString(labelStyle.Render("Expires In:")) + content.WriteString(" ") + content.WriteString(valueStyle.Render(formatDuration(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) +}