package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // oauthProvider represents an OAuth provider option. type oauthProvider struct { name string apiPath string // management API path emoji string deviceFlow bool // true for RFC 8628 device-code providers } var oauthProviders = []oauthProvider{ {"Claude (Anthropic)", "anthropic-auth-url", "🟧", false}, {"Codex (OpenAI)", "codex-auth-url", "🟩", false}, {"Antigravity", "antigravity-auth-url", "🟪", false}, {"Kimi", "kimi-auth-url", "🟫", true}, {"xAI", "xai-auth-url", "⬛", true}, } // oauthTabModel handles OAuth login flows. type oauthTabModel struct { client *Client viewport viewport.Model cursor int state oauthState message string err error width int height int ready bool // Remote browser / device-code mode authURL string // auth URL to display authState string // OAuth state parameter providerName string // current provider name userCode string // device-code user_code (optional) deviceFlow bool // true when waiting on device authorization expiresIn int // device-code / poll timeout in seconds callbackInput textinput.Model inputActive bool // true when user is typing callback URL // pollGeneration invalidates in-flight start/poll commands after cancel or restart. pollGeneration int } type oauthState int const ( oauthIdle oauthState = iota oauthPending oauthRemote // remote browser mode: waiting for manual callback or device auth oauthSuccess oauthError ) const ( defaultOAuthPollTimeout = 5 * time.Minute deviceOAuthPollTimeout = 30 * time.Minute maxOAuthStatusPollErrors = 5 oauthStatusPollInterval = 2 * time.Second ) // Messages type oauthStartMsg struct { url string state string providerName string userCode string deviceFlow bool expiresIn int generation int err error } type oauthPollMsg struct { state string generation int done bool message string err error } type oauthCallbackSubmitMsg struct { err error } func newOAuthTabModel(client *Client) oauthTabModel { ti := textinput.New() ti.Placeholder = "http://localhost:.../auth/callback?code=...&state=..." ti.CharLimit = 2048 ti.Prompt = " 回调 URL: " return oauthTabModel{ client: client, callbackInput: ti, } } func (m oauthTabModel) Init() tea.Cmd { return nil } func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { switch msg := msg.(type) { case localeChangedMsg: m.viewport.SetContent(m.renderContent()) return m, nil case oauthStartMsg: if !shouldAcceptOAuthStart(msg, m.pollGeneration) { // Stale start after Esc/restart: cancel server session so credentials are not saved. if msg.err == nil && strings.TrimSpace(msg.state) != "" { return m, m.cancelOAuthSession(msg.state) } return m, nil } if msg.err != nil { m.state = oauthError m.err = msg.err m.message = errorStyle.Render("✗ " + msg.err.Error()) m.viewport.SetContent(m.renderContent()) return m, nil } m.authURL = msg.url m.authState = msg.state m.providerName = msg.providerName m.userCode = msg.userCode m.deviceFlow = msg.deviceFlow m.expiresIn = msg.expiresIn m.state = oauthRemote m.callbackInput.SetValue("") m.message = "" if m.deviceFlow { m.inputActive = false m.callbackInput.Blur() m.viewport.SetContent(m.renderContent()) return m, m.pollOAuthStatus(msg.state, msg.expiresIn, true, msg.generation) } m.callbackInput.Focus() m.inputActive = true m.viewport.SetContent(m.renderContent()) return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state, msg.expiresIn, false, msg.generation)) case oauthPollMsg: if !shouldAcceptOAuthPoll(msg, m.authState, m.pollGeneration, m.state) { return m, nil } if msg.err != nil { m.state = oauthError m.err = msg.err m.message = errorStyle.Render("✗ " + msg.err.Error()) m.inputActive = false m.callbackInput.Blur() } else if msg.done { m.state = oauthSuccess m.message = successStyle.Render("✓ " + msg.message) m.inputActive = false m.callbackInput.Blur() } else { m.message = warningStyle.Render("⏳ " + msg.message) } m.viewport.SetContent(m.renderContent()) return m, nil case oauthCallbackSubmitMsg: if msg.err != nil { m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error()) } else { m.message = successStyle.Render(T("oauth_submit_ok")) } m.viewport.SetContent(m.renderContent()) return m, nil case tea.KeyMsg: // ---- Input active: typing callback URL (web flow only) ---- if m.inputActive && !m.deviceFlow { switch msg.String() { case "enter": callbackURL := m.callbackInput.Value() if callbackURL == "" { return m, nil } m.inputActive = false m.callbackInput.Blur() m.message = warningStyle.Render(T("oauth_submitting")) m.viewport.SetContent(m.renderContent()) return m, m.submitCallback(callbackURL) case "esc": // Cancel the remote OAuth session even while the callback input is focused. return m, m.cancelRemoteOAuth() default: var cmd tea.Cmd m.callbackInput, cmd = m.callbackInput.Update(msg) m.viewport.SetContent(m.renderContent()) return m, cmd } } // ---- Remote mode but not typing ---- if m.state == oauthRemote { switch msg.String() { case "c", "C": if m.deviceFlow { return m, nil } // Re-activate input m.inputActive = true m.callbackInput.Focus() m.viewport.SetContent(m.renderContent()) return m, textinput.Blink case "esc": return m, m.cancelRemoteOAuth() } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } // ---- Pending (auto polling) ---- if m.state == oauthPending { if msg.String() == "esc" { m.pollGeneration++ m.state = oauthIdle m.message = "" m.viewport.SetContent(m.renderContent()) } return m, nil } // ---- Idle ---- switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- m.viewport.SetContent(m.renderContent()) } return m, nil case "down", "j": if m.cursor < len(oauthProviders)-1 { m.cursor++ m.viewport.SetContent(m.renderContent()) } return m, nil case "enter": if m.cursor >= 0 && m.cursor < len(oauthProviders) { provider := oauthProviders[m.cursor] m.pollGeneration++ m.state = oauthPending m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name)) m.viewport.SetContent(m.renderContent()) return m, m.startOAuth(provider, m.pollGeneration) } return m, nil case "esc": m.state = oauthIdle m.message = "" m.err = nil m.viewport.SetContent(m.renderContent()) return m, nil } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } func (m oauthTabModel) startOAuth(provider oauthProvider, generation int) tea.Cmd { return func() tea.Msg { // Call the auth URL endpoint with is_webui=true data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true") if err != nil { return oauthStartMsg{generation: generation, err: fmt.Errorf("failed to start %s login: %w", provider.name, err)} } authURL := getString(data, "url") state := getString(data, "state") if authURL == "" { return oauthStartMsg{generation: generation, err: fmt.Errorf("no auth URL returned for %s", provider.name)} } userCode := getString(data, "user_code") flow := strings.ToLower(strings.TrimSpace(getString(data, "flow"))) expiresIn := int(getFloat(data, "expires_in")) deviceFlow := provider.deviceFlow || flow == "device" || userCode != "" // Try to open browser (best effort) _ = openBrowser(authURL) return oauthStartMsg{ url: authURL, state: state, providerName: provider.name, userCode: userCode, deviceFlow: deviceFlow, expiresIn: expiresIn, generation: generation, } } } // cancelRemoteOAuth clears local remote/device UI state and cancels the server session. func (m *oauthTabModel) cancelRemoteOAuth() tea.Cmd { state := m.authState m.pollGeneration++ m.state = oauthIdle m.message = "" m.authURL = "" m.authState = "" m.userCode = "" m.deviceFlow = false m.expiresIn = 0 m.inputActive = false m.callbackInput.Blur() m.callbackInput.SetValue("") m.viewport.SetContent(m.renderContent()) return m.cancelOAuthSession(state) } func (m oauthTabModel) cancelOAuthSession(state string) tea.Cmd { state = strings.TrimSpace(state) if state == "" || m.client == nil { return nil } return func() tea.Msg { _ = m.client.CancelAuthSession(state) return nil } } func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd { return func() tea.Msg { // Determine provider from current context providerKey := "" for _, p := range oauthProviders { if p.name == m.providerName { // Map provider name to the canonical key the API expects switch p.apiPath { case "anthropic-auth-url": providerKey = "anthropic" case "codex-auth-url": providerKey = "codex" case "antigravity-auth-url": providerKey = "antigravity" case "kimi-auth-url": providerKey = "kimi" case "xai-auth-url": providerKey = "xai" } break } } body := map[string]string{ "provider": providerKey, "redirect_url": callbackURL, "state": m.authState, } err := m.client.postJSON("/v0/management/oauth-callback", body) if err != nil { return oauthCallbackSubmitMsg{err: err} } return oauthCallbackSubmitMsg{} } } func (m oauthTabModel) pollOAuthStatus(state string, expiresIn int, deviceFlow bool, generation int) tea.Cmd { return func() tea.Msg { timeout := defaultOAuthPollTimeout if expiresIn > 0 { timeout = time.Duration(expiresIn) * time.Second } else if deviceFlow { timeout = deviceOAuthPollTimeout } deadline := time.Now().Add(timeout) consecutiveErrors := 0 for { if time.Now().After(deadline) { return oauthPollMsg{ state: state, generation: generation, done: false, err: fmt.Errorf("%s", T("oauth_timeout")), } } time.Sleep(oauthStatusPollInterval) status, errMsg, err := m.client.GetAuthStatus(state) if err != nil { consecutiveErrors++ if shouldFailOAuthStatusPoll(consecutiveErrors, maxOAuthStatusPollErrors) { return oauthPollMsg{ state: state, generation: generation, done: false, err: fmt.Errorf("%s: %w", T("oauth_status_error"), err), } } continue } consecutiveErrors = 0 switch status { case "ok": return oauthPollMsg{ state: state, generation: generation, done: true, message: T("oauth_success"), } case "error": return oauthPollMsg{ state: state, generation: generation, done: false, err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg), } case "wait": continue default: return oauthPollMsg{ state: state, generation: generation, done: true, message: T("oauth_completed"), } } } } } // shouldAcceptOAuthStart reports whether a start result belongs to the current flow. func shouldAcceptOAuthStart(msg oauthStartMsg, generation int) bool { return msg.generation == generation } // shouldAcceptOAuthPoll reports whether a poll result belongs to the active remote flow. func shouldAcceptOAuthPoll(msg oauthPollMsg, authState string, generation int, state oauthState) bool { if msg.generation != generation { return false } if msg.state == "" || msg.state != authState { return false } return state == oauthRemote } // shouldFailOAuthStatusPoll reports whether consecutive status request errors should fail the flow. func shouldFailOAuthStatusPoll(consecutiveErrors, maxErrors int) bool { if maxErrors <= 0 { return consecutiveErrors > 0 } return consecutiveErrors >= maxErrors } func (m *oauthTabModel) SetSize(w, h int) { m.width = w m.height = h m.callbackInput.Width = w - 16 if !m.ready { m.viewport = viewport.New(w, h) m.viewport.SetContent(m.renderContent()) m.ready = true } else { m.viewport.Width = w m.viewport.Height = h } } func (m oauthTabModel) View() string { if !m.ready { return T("loading") } return m.viewport.View() } func (m oauthTabModel) renderContent() string { var sb strings.Builder sb.WriteString(titleStyle.Render(T("oauth_title"))) sb.WriteString("\n\n") if m.message != "" { sb.WriteString(" " + m.message) sb.WriteString("\n\n") } // ---- Remote browser / device-code mode ---- if m.state == oauthRemote { if m.deviceFlow { sb.WriteString(m.renderDeviceMode()) } else { sb.WriteString(m.renderRemoteMode()) } return sb.String() } if m.state == oauthPending { sb.WriteString(helpStyle.Render(T("oauth_press_esc"))) return sb.String() } sb.WriteString(helpStyle.Render(T("oauth_select"))) sb.WriteString("\n\n") for i, p := range oauthProviders { isSelected := i == m.cursor prefix := " " if isSelected { prefix = "▸ " } label := fmt.Sprintf("%s %s", p.emoji, p.name) if isSelected { label = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1).Render(label) } else { label = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label) } sb.WriteString(prefix + label + "\n") } sb.WriteString("\n") sb.WriteString(helpStyle.Render(T("oauth_help"))) return sb.String() } func (m oauthTabModel) renderRemoteMode() string { var sb strings.Builder providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight) sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName))) sb.WriteString("\n\n") // Auth URL section sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url"))) sb.WriteString("\n") // Wrap URL to fit terminal width urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) maxURLWidth := m.width - 6 if maxURLWidth < 40 { maxURLWidth = 40 } wrappedURL := wrapText(m.authURL, maxURLWidth) for _, line := range wrappedURL { sb.WriteString(" " + urlStyle.Render(line) + "\n") } sb.WriteString("\n") sb.WriteString(helpStyle.Render(T("oauth_remote_hint"))) sb.WriteString("\n\n") // Callback URL input sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url"))) sb.WriteString("\n") if m.inputActive { sb.WriteString(m.callbackInput.View()) sb.WriteString("\n") sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel"))) } else { sb.WriteString(helpStyle.Render(T("oauth_press_c"))) } sb.WriteString("\n\n") sb.WriteString(warningStyle.Render(T("oauth_waiting"))) return sb.String() } func (m oauthTabModel) renderDeviceMode() string { var sb strings.Builder providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight) sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName))) sb.WriteString("\n\n") sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url"))) sb.WriteString("\n") urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) maxURLWidth := m.width - 6 if maxURLWidth < 40 { maxURLWidth = 40 } for _, line := range wrapText(m.authURL, maxURLWidth) { sb.WriteString(" " + urlStyle.Render(line) + "\n") } sb.WriteString("\n") if strings.TrimSpace(m.userCode) != "" { sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_user_code"))) sb.WriteString("\n") codeStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1) sb.WriteString(" " + codeStyle.Render(m.userCode) + "\n\n") } sb.WriteString(helpStyle.Render(T("oauth_device_hint"))) sb.WriteString("\n") if m.expiresIn > 0 { sb.WriteString(helpStyle.Render(fmt.Sprintf(T("oauth_device_expires"), m.expiresIn))) sb.WriteString("\n") } sb.WriteString("\n") sb.WriteString(warningStyle.Render(T("oauth_waiting"))) sb.WriteString("\n") sb.WriteString(helpStyle.Render(T("oauth_press_esc"))) return sb.String() } // wrapText splits a long string into lines of at most maxWidth characters. func wrapText(s string, maxWidth int) []string { if maxWidth <= 0 { return []string{s} } var lines []string for len(s) > maxWidth { lines = append(lines, s[:maxWidth]) s = s[maxWidth:] } if len(s) > 0 { lines = append(lines, s) } return lines }