diff --git a/cmd/layout.go b/cmd/layout.go index d77d30e..031150a 100644 --- a/cmd/layout.go +++ b/cmd/layout.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/login" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/zalando/go-keyring" @@ -41,7 +42,7 @@ func newLayout(cfg *config.Config) *Layout { func (l *Layout) show(token string) error { if token == "" { - loginForm := newLoginForm(func(token string, err error) { + loginForm := login.NewForm(l.app, func(token string, err error) { if err != nil { slog.Error("failed to login", "err", err) return @@ -49,8 +50,9 @@ func (l *Layout) show(token string) error { if err := l.show(token); err != nil { slog.Error("failed to show app", "err", err) + return } - }, l.cfg) + }) l.app.SetRoot(loginForm, true) } else { if err := openState(token, l.app, l.cfg); err != nil { diff --git a/cmd/login_form.go b/cmd/login_form.go deleted file mode 100644 index bb0d168..0000000 --- a/cmd/login_form.go +++ /dev/null @@ -1,94 +0,0 @@ -package cmd - -import ( - "errors" - - "github.com/ayn2op/discordo/internal/config" - "github.com/diamondburned/arikawa/v3/api" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "github.com/zalando/go-keyring" -) - -type doneFn func(token string, err error) - -type loginForm struct { - *tview.Form - done doneFn -} - -func newLoginForm(done doneFn, cfg *config.Config) *loginForm { - if done == nil { - done = func(_ string, _ error) {} - } - - lf := &loginForm{ - Form: tview.NewForm(), - done: done, - } - - lf.AddInputField("Email", "", 0, nil, nil) - lf.AddPasswordField("Password", "", 0, 0, nil) - lf.AddPasswordField("Code (optional)", "", 0, 0, nil) - lf.AddCheckbox("Remember Me", true, nil) - lf.AddButton("Login", lf.login) - - lf.SetTitle("Login") - lf.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor)) - lf.SetTitleAlign(tview.AlignLeft) - - p := cfg.Theme.BorderPadding - lf.SetBorder(cfg.Theme.Border) - lf.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor)) - lf.SetBorderPadding(p[0], p[1], p[2], p[3]) - - return lf -} - -func (lf *loginForm) login() { - email := lf.GetFormItem(0).(*tview.InputField).GetText() - password := lf.GetFormItem(1).(*tview.InputField).GetText() - if email == "" || password == "" { - return - } - - // Create a new API client without an authentication token. - apiClient := api.NewClient("") - // Log in using the provided email and password. - lr, err := apiClient.Login(email, password) - if err != nil { - lf.done("", err) - return - } - - // If the account has MFA-enabled, attempt to log in using the provided code. - if lr.MFA && lr.Token == "" { - code := lf.GetFormItem(2).(*tview.InputField).GetText() - if code == "" { - lf.done("", errors.New("code required")) - return - } - - lr, err = apiClient.TOTP(code, lr.Ticket) - if err != nil { - lf.done("", err) - return - } - } - - if lr.Token == "" { - lf.done("", errors.New("missing token")) - return - } - - rememberMe := lf.GetFormItem(3).(*tview.Checkbox).IsChecked() - if rememberMe { - go func() { - if err := keyring.Set(config.Name, "token", lr.Token); err != nil { - lf.done("", err) - } - }() - } - - lf.done(lr.Token, nil) -} diff --git a/internal/login/form.go b/internal/login/form.go new file mode 100644 index 0000000..eaabaff --- /dev/null +++ b/internal/login/form.go @@ -0,0 +1,152 @@ +package login + +import ( + "errors" + + "github.com/ayn2op/discordo/internal/config" + "github.com/diamondburned/arikawa/v3/api" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/zalando/go-keyring" +) + +type DoneFn = func(token string, err error) + +type Form struct { + *tview.Flex + app *tview.Application + errorTextView *tview.TextView + + inputs []*tview.InputField + active int + done DoneFn +} + +func NewForm(app *tview.Application, done DoneFn) *Form { + self := &Form{ + Flex: tview.NewFlex().SetDirection(tview.FlexRow), + app: app, + errorTextView: tview.NewTextView(), + + done: done, + } + + emailInput := tview.NewInputField() + emailInput. + SetBorder(true). + SetTitle("Email"). + SetTitleAlign(tview.AlignLeft) + + passwordInput := tview.NewInputField() + passwordInput. + SetMaskCharacter('*'). + SetBorder(true). + SetTitle("Password"). + SetTitleAlign(tview.AlignLeft) + + codeInput := tview.NewInputField() + codeInput. + SetMaskCharacter('*'). + SetBorder(true). + SetTitle("Code (optional)"). + SetTitleAlign(tview.AlignLeft) + + self.inputs = []*tview.InputField{emailInput, passwordInput, codeInput} + for i, input := range self.inputs { + var focus bool + if i == 0 { + focus = true + } + + self.AddItem(input, 3, 1, focus) + } + + self. + AddItem(self.errorTextView, 0, 1, false). + SetBorderPadding(0, 0, 1, 1). + SetInputCapture(self.onInputCapture) + return self +} + +func (self *Form) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyBacktab: + if self.active == 0 { + self.active = len(self.inputs) - 1 + } else { + self.active-- + } + + self.app.SetFocus(self.inputs[self.active]) + return nil + case tcell.KeyTab: + if self.active == len(self.inputs)-1 { + self.active = 0 + } else { + self.active++ + } + + self.app.SetFocus(self.inputs[self.active]) + return nil + + case tcell.KeyEnter: + // If the currently active input is not the email input, proceed to login with the provided details. + if self.active != 0 { + self.login() + } + } + + return event +} + +func (self *Form) login() { + email := self.inputs[0].GetText() + password := self.inputs[1].GetText() + if email == "" || password == "" { + return + } + + // Create an API client without an authentication token. + client := api.NewClient("") + + // Attempt to login using the email and password. + resp, err := client.Login(email, password) + if err != nil { + self.onError(err) + return + } + + if resp.Token == "" && resp.MFA { + code := self.inputs[2].GetText() + if code == "" { + self.onError(errors.New("code required")) + return + } + + // Attempt to login using the code. + resp, err = client.TOTP(code, resp.Ticket) + if err != nil { + self.onError(err) + return + } + } + + if resp.Token == "" { + self.onError(errors.New("missing token")) + return + } + + go keyring.Set(config.Name, "token", resp.Token) + + if self.done != nil { + self.done(resp.Token, nil) + } +} + +func (self *Form) onError(err error) { + self.errorTextView.SetText(err.Error()) + + if self.done != nil { + self.done("", err) + } +}