tarakan-client/internal/app/login.go
Maxfield Luke 58b29a7f84
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
2026-07-29 04:56:36 -04:00

148 lines
4.6 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/atomine-elektrine/tarakan-client/internal/api"
"github.com/atomine-elektrine/tarakan-client/internal/browser"
"github.com/atomine-elektrine/tarakan-client/internal/session"
)
type pendingLogin struct {
config api.Config
authorization api.DeviceAuthorization
expiresAt time.Time
}
type loginStartedMsg struct {
authorization api.DeviceAuthorization
err error
}
type loginPollMsg struct {
credential api.DeviceCredential
err error
}
type loginPollTickMsg struct{}
func (m Model) beginLogin() (tea.Model, tea.Cmd) {
m.busy = true
m.busyStatus = "Starting browser login…"
m.transcript.Append(session.RoleSystem, "Starting browser login at "+m.apiConfig.BaseURL+"…")
m.refreshTranscript()
m.resize(m.width, m.height)
config := m.apiConfig
return m, func() tea.Msg {
client, err := api.NewPublic(config.BaseURL, nil)
if err != nil {
return loginStartedMsg{err: err}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
authorization, err := client.StartDeviceAuthorization(ctx, tuiClientName())
return loginStartedMsg{authorization: authorization, err: err}
}
}
func (m Model) handleLoginStarted(message loginStartedMsg) (tea.Model, tea.Cmd) {
if message.err != nil {
return m.finishLoginError(fmt.Errorf("start web login: %w", message.err))
}
authorization := message.authorization
m.pendingLogin = &pendingLogin{
config: m.apiConfig,
authorization: authorization,
expiresAt: time.Now().Add(time.Duration(authorization.ExpiresIn) * time.Second),
}
m.busyStatus = "Waiting for browser approval…"
m.transcript.Append(
session.RoleSystem,
"Confirm code "+authorization.UserCode+" in your browser:\n"+authorization.VerificationURIComplete,
)
if err := browser.Open(authorization.VerificationURIComplete); err != nil {
m.transcript.Append(session.RoleSystem, "Could not open a browser automatically: "+err.Error()+"\nOpen the URL above to continue.")
}
m.refreshTranscript()
m.resize(m.width, m.height)
return m, pollLogin(m.pendingLogin)
}
func (m Model) handleLoginPoll(message loginPollMsg) (tea.Model, tea.Cmd) {
if m.pendingLogin == nil {
return m, nil
}
switch {
case message.err == nil && strings.TrimSpace(message.credential.Token) != "":
config := m.pendingLogin.config.WithOverrides("", message.credential.Token)
path, err := api.SaveConfig(config)
if err != nil {
return m.finishLoginError(fmt.Errorf("save login: %w", err))
}
m.apiConfig = config
m.pendingLogin = nil
m.busy = false
m.busyStatus = ""
m.transcript.Append(session.RoleSystem, "Logged in to "+config.BaseURL+". Credentials saved to "+path+".\n\nNext: /pickup to claim a review job.")
m.updateInputHint()
m.refreshTranscript()
m.resize(m.width, m.height)
return m, nil
case errors.Is(message.err, api.ErrAuthorizationPending):
if time.Now().After(m.pendingLogin.expiresAt) {
return m.finishLoginError(errors.New("web login expired; run /login to try again"))
}
interval := time.Duration(m.pendingLogin.authorization.Interval) * time.Second
if interval < time.Second {
interval = 2 * time.Second
}
return m, tea.Tick(interval, func(time.Time) tea.Msg { return loginPollTickMsg{} })
case errors.Is(message.err, api.ErrAccessDenied):
return m.finishLoginError(errors.New("web login was denied"))
case errors.Is(message.err, api.ErrDeviceCodeExpired):
return m.finishLoginError(errors.New("web login expired; run /login to try again"))
case message.err == nil:
return m.finishLoginError(errors.New("server returned an empty credential"))
default:
return m.finishLoginError(fmt.Errorf("finish web login: %w", message.err))
}
}
func (m Model) finishLoginError(err error) (tea.Model, tea.Cmd) {
m.pendingLogin = nil
m.busy = false
m.busyStatus = ""
m.transcript.Append(session.RoleSystem, "Login error: "+err.Error())
m.updateInputHint()
m.refreshTranscript()
m.resize(m.width, m.height)
return m, nil
}
func pollLogin(login *pendingLogin) tea.Cmd {
return func() tea.Msg {
client, err := api.NewPublic(login.config.BaseURL, nil)
if err != nil {
return loginPollMsg{err: err}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
credential, err := client.ExchangeDeviceAuthorization(ctx, login.authorization.DeviceCode)
return loginPollMsg{credential: credential, err: err}
}
}
func tuiClientName() string {
hostname, err := os.Hostname()
if err != nil || strings.TrimSpace(hostname) == "" {
return "Tarakan TUI"
}
return "Tarakan TUI on " + hostname
}