1// Package hyper provides the dialog for Hyper device flow authentication.
2package hyper
3
4import (
5 "context"
6 "fmt"
7 "time"
8
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/oauth"
13 "github.com/charmbracelet/crush/internal/oauth/hyper"
14 "github.com/charmbracelet/crush/internal/tui/styles"
15 "github.com/charmbracelet/crush/internal/tui/util"
16 "github.com/pkg/browser"
17)
18
19// DeviceFlowState represents the current state of the device flow.
20type DeviceFlowState int
21
22const (
23 DeviceFlowStateDisplay DeviceFlowState = iota
24 DeviceFlowStateSuccess
25 DeviceFlowStateError
26)
27
28// DeviceAuthInitiatedMsg is sent when the device auth is initiated
29// successfully.
30type DeviceAuthInitiatedMsg struct {
31 deviceCode string
32 expiresIn int
33}
34
35// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36type DeviceFlowCompletedMsg struct {
37 Token *oauth.Token
38}
39
40// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41type DeviceFlowErrorMsg struct {
42 Error error
43}
44
45// DeviceFlow handles the Hyper device flow authentication.
46type DeviceFlow struct {
47 State DeviceFlowState
48 width int
49 deviceCode string
50 userCode string
51 verificationURL string
52 expiresIn int
53 token *oauth.Token
54 cancelFunc context.CancelFunc
55 spinner spinner.Model
56}
57
58// NewDeviceFlow creates a new device flow component.
59func NewDeviceFlow() *DeviceFlow {
60 s := spinner.New()
61 s.Spinner = spinner.Dot
62 s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
63 return &DeviceFlow{
64 State: DeviceFlowStateDisplay,
65 spinner: s,
66 }
67}
68
69// Init initializes the device flow by calling the device auth API and starting polling.
70func (d *DeviceFlow) Init() tea.Cmd {
71 return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
72}
73
74// Update handles messages and state transitions.
75func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
76 var cmd tea.Cmd
77 d.spinner, cmd = d.spinner.Update(msg)
78
79 switch msg := msg.(type) {
80 case DeviceAuthInitiatedMsg:
81 // Start polling now that we have the device code.
82 d.expiresIn = msg.expiresIn
83 return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
84 case DeviceFlowCompletedMsg:
85 d.State = DeviceFlowStateSuccess
86 d.token = msg.Token
87 return d, nil
88 case DeviceFlowErrorMsg:
89 d.State = DeviceFlowStateError
90 return d, util.ReportError(msg.Error)
91 }
92
93 return d, cmd
94}
95
96// View renders the device flow dialog.
97func (d *DeviceFlow) View() string {
98 t := styles.CurrentTheme()
99
100 whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101 primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102 greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103 linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104 errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105 mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106
107 switch d.State {
108 case DeviceFlowStateDisplay:
109 if d.userCode == "" {
110 return lipgloss.NewStyle().
111 Margin(0, 1).
112 Render(
113 greenStyle.Render(d.spinner.View()) +
114 mutedStyle.Render("Initializing..."),
115 )
116 }
117
118 instructions := lipgloss.NewStyle().
119 Margin(1, 1, 0, 1).
120 Width(d.width - 2).
121 Render(
122 whiteStyle.Render("Press ") +
123 primaryStyle.Render("enter") +
124 whiteStyle.Render(" to copy the code below and open the browser."),
125 )
126
127 codeBox := lipgloss.NewStyle().
128 Width(d.width-2).
129 Height(7).
130 Align(lipgloss.Center, lipgloss.Center).
131 Background(t.BgBaseLighter).
132 Margin(1).
133 Render(
134 lipgloss.NewStyle().
135 Bold(true).
136 Foreground(t.White).
137 Render(d.userCode),
138 )
139
140 link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
141 url := mutedStyle.
142 Margin(0, 1).
143 Width(d.width - 2).
144 Render("Browser not opening? Refer to\n" + link)
145
146 waiting := greenStyle.
147 Width(d.width-2).
148 Margin(1, 1, 0, 1).
149 Render(d.spinner.View() + "Verifying...")
150
151 return lipgloss.JoinVertical(
152 lipgloss.Left,
153 instructions,
154 codeBox,
155 url,
156 waiting,
157 )
158
159 case DeviceFlowStateSuccess:
160 return greenStyle.Margin(0, 1).Render("Authentication successful!")
161
162 case DeviceFlowStateError:
163 return lipgloss.NewStyle().
164 Margin(0, 1).
165 Width(d.width - 2).
166 Render(errorStyle.Render("Authentication failed."))
167
168 default:
169 return ""
170 }
171}
172
173// SetWidth sets the width of the dialog.
174func (d *DeviceFlow) SetWidth(w int) {
175 d.width = w
176}
177
178// Cursor hides the cursor.
179func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
180
181// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
182func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
183 if d.State != DeviceFlowStateDisplay {
184 return nil
185 }
186 return tea.Sequence(
187 tea.SetClipboard(d.userCode),
188 func() tea.Msg {
189 if err := browser.OpenURL(d.verificationURL); err != nil {
190 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
191 }
192 return nil
193 },
194 util.ReportInfo("Code copied and URL opened"),
195 )
196}
197
198// CopyCode copies just the user code to the clipboard.
199func (d *DeviceFlow) CopyCode() tea.Cmd {
200 if d.State != DeviceFlowStateDisplay {
201 return nil
202 }
203 return tea.Sequence(
204 tea.SetClipboard(d.userCode),
205 util.ReportInfo("Code copied to clipboard"),
206 )
207}
208
209// Cancel cancels the device flow polling.
210func (d *DeviceFlow) Cancel() {
211 if d.cancelFunc != nil {
212 d.cancelFunc()
213 }
214}
215
216func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
217 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
218 defer cancel()
219 authResp, err := hyper.InitiateDeviceAuth(ctx)
220 if err != nil {
221 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
222 }
223
224 d.deviceCode = authResp.DeviceCode
225 d.userCode = authResp.UserCode
226 d.verificationURL = authResp.VerificationURL
227
228 return DeviceAuthInitiatedMsg{
229 deviceCode: authResp.DeviceCode,
230 expiresIn: authResp.ExpiresIn,
231 }
232}
233
234// startPolling starts polling for the device token.
235func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
236 return func() tea.Msg {
237 ctx, cancel := context.WithCancel(context.Background())
238 d.cancelFunc = cancel
239
240 // Poll for refresh token.
241 refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
242 if err != nil {
243 if ctx.Err() != nil {
244 // Cancelled, don't report error.
245 return nil
246 }
247 return DeviceFlowErrorMsg{Error: err}
248 }
249
250 // Exchange refresh token for access token.
251 token, err := hyper.ExchangeToken(ctx, refreshToken)
252 if err != nil {
253 return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
254 }
255
256 // Verify the access token works.
257 introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
258 if err != nil {
259 return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
260 }
261 if !introspect.Active {
262 return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
263 }
264
265 return DeviceFlowCompletedMsg{Token: token}
266 }
267}