Files
2026-07-13 12:35:43 +08:00

642 lines
17 KiB
Go

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
}