diff --git a/internal/client/ui/auth/auth.go b/internal/client/ui/auth/auth.go index 9444491..c2a3dd8 100644 --- a/internal/client/ui/auth/auth.go +++ b/internal/client/ui/auth/auth.go @@ -3,17 +3,26 @@ package auth import ( "errors" "fmt" + "os" "strings" - "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" authfield "github.com/kyren223/eko/internal/client/ui/auth/field" + "github.com/kyren223/eko/pkg/assert" +) + +const ( + usernameField = iota + privateKeyField + passphraseField + passphraseConfirmField ) var ( + grayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198")) cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("198")) noStyle = lipgloss.NewStyle() @@ -23,13 +32,28 @@ var ( Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#007E8A")) - focusedButton = focusedStyle.Render("[ sign-up ]") - blurredButton = fmt.Sprintf("[ %s ]", lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("sign-up")) + focusedSignupButton = focusedStyle.Render("[ sign-up ]") + focusedSigninButton = focusedStyle.Render("[ sign-in ]") + blurredSignupButton = fmt.Sprintf("[ %s ]", grayStyle.Render("sign-up")) + blurredSigninButton = fmt.Sprintf("[ %s ]", grayStyle.Render("sign-in")) headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#54D7A9")) titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5874FF")) + + signupTitle = titleStyle.Render(` +____ _ ____ _ _ _ _ ___ +[__ | | __ |\ | __ | | |__] +___] | |__] | \| |__| | + `) + + signinTitle = titleStyle.Render(` +____ _ ____ _ _ _ _ _ +[__ | | __ |\ | __ | |\ | +___] | |__] | \| | | \| + `) + + revealIcon = lipgloss.NewStyle().PaddingLeft(1).Render("󰈈 ") + concealIcon = lipgloss.NewStyle().PaddingLeft(1).Render("󰈉 ") ) type Model struct { @@ -38,16 +62,17 @@ type Model struct { focusIndex int fields []authfield.Model + + signup bool } func New() Model { m := Model{ - fields: make([]authfield.Model, 3), + fields: make([]authfield.Model, 4), } - var field authfield.Model for i := range m.fields { - field = authfield.New(32) + field := authfield.New(48) field.Input.Cursor.Style = cursorStyle field.Style = fieldStyle field.ErrorStyle = errorStyle @@ -55,60 +80,52 @@ func New() Model { field.BlurredStyle = noStyle switch i { - case 0: - field.Header = "Username" + case usernameField: + field.Header = headerStyle.Render("Username") field.Input.Placeholder = "Username" - field.Focus() field.Input.Validate = func(username string) error { if len(username) == 0 { - return errors.New("* required field") + return errors.New("Required") } return nil } - case 1: - field.Header = "Email (Optional)" - field.Input.Placeholder = "Email" - field.Input.CharLimit = 64 - field.Input.Validate = func(email string) error { - if len(email) == 0 { - return errors.New("* required field") - } - var errs []string - if !strings.ContainsRune(email, '@') { - errs = append(errs, "- missing @") - } - if !strings.ContainsRune(email, '.') { - errs = append(errs, "- missing .") - } - proton := strings.Contains(email, "proton.me") || strings.Contains(email, "protonmail.com") - gmail := strings.Contains(email, "gmail.com") - outlook := strings.Contains(email, "hotmail.com") - if !(proton || gmail || outlook) { - errs = append(errs, "- unknown email provider") - } - if errs != nil { - return errors.New(strings.Join(errs, "\n")) + case privateKeyField: + field.Header = headerStyle.Render("Private Key") + field.Input.Placeholder = "Path to Private Key" + // TODO: Consider if I need a custom validate function or not + // I most likely need it for prompts + // So if signup is on and private key file exists suggest to either: + // 1. Overwrite it 2. Switch to sign-in 3. Cancel + // And if it's off and the file doesn't exist, suggest: + // 1. Switch to sign-up 2. Cancel + field.Input.Validate = func(privKey string) error { + if len(privKey) == 0 { + return errors.New("Required") } return nil } - case 2: - field.Header = "Password" - field.Input.Placeholder = "Password" - field.Input.EchoMode = textinput.EchoPassword + + case passphraseField: + field.Header = headerStyle.Render("Passphrase (Optional)") + field.Input.Placeholder = "Passphrase" + field.SetRevealIcon(revealIcon) + field.SetConcealIcon(concealIcon) + field.SetRevealed(false) + field.Input.EchoCharacter = '*' + + case passphraseConfirmField: + field.Header = headerStyle.Render("Passphrase Confirm") + field.Input.Placeholder = "Repeated Passphrase" + field.SetRevealIcon(revealIcon) + field.SetConcealIcon(concealIcon) + field.SetRevealed(false) field.Input.EchoCharacter = '*' - // field.Input.EchoCharacter = '•' - field.Input.Validate = func(password string) error { - if len(password) == 0 { - return errors.New("* required field") - } - return nil - } } - field.Header = headerStyle.Render(field.Header) m.fields[i] = field } + m.SetSignup(false) return m } @@ -120,38 +137,51 @@ func (m Model) View() string { var builder strings.Builder builder.WriteRune('\n') - for i := range m.fields { - builder.WriteString(m.fields[i].View()) + for i, field := range m.fields { + if field.Visisble { + builder.WriteString(m.fields[i].View()) + } else { + builder.WriteString("\n\n") + } if i < len(m.fields)-1 { builder.WriteRune('\n') } } - button := &blurredButton - if m.focusIndex == len(m.fields) { - button = &focusedButton + var button *string + if m.signup { + if m.focusIndex == len(m.fields) { + button = &focusedSignupButton + } else { + button = &blurredSignupButton + } + } else { + if m.focusIndex == len(m.fields) { + button = &focusedSigninButton + } else { + button = &blurredSigninButton + } } - submit := lipgloss.NewStyle(). - Render(*button) - fmt.Fprintf(&builder, "\n\n%s", submit) + fmt.Fprintf(&builder, "\n\n%s", *button) - title := titleStyle.Render(` -____ _ ____ _ _ _ _ ___ -[__ | | __ |\ | __ | | |__] -___] | |__] | \| |__| | - `) + var title string + if m.signup { + title = signupTitle + } else { + title = signinTitle + } // Tiny offset so odd numbers will have the extra char on the right, ie: 12 13 content := lipgloss.JoinVertical(lipgloss.Center-0.01, title, builder.String()) width := lipgloss.Width(content) - vp := viewport.New(50, 25) + vp := viewport.New(64, 23) vp.SetContent(content) vp.Style = vp.Style. Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#007E8A")). // NOTE: Without -1 it wraps/truncates, not sure why - Padding(0, (vp.Width-width)/2-1) + Padding(0, (vp.Width-width)/2-1, 1) return lipgloss.Place( m.width, m.height, @@ -172,66 +202,151 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlC: return m, tea.Quit - case tea.KeyTab, tea.KeyShiftTab, tea.KeyEnter, tea.KeyUp, tea.KeyDown: - // User pressed submit - if key == tea.KeyEnter && m.focusIndex == len(m.fields) { - var cmds []tea.Cmd - for i, field := range m.fields { - if field.Input.Validate == nil { - continue - } - field.Input.Err = field.Input.Validate(field.Input.Value()) - if field.Input.Err != nil { - var cmd tea.Cmd - m.fields[i], cmd = field.Update(msg) - cmds = append(cmds, cmd) - } - } - if len(cmds) == 0 { - return m, tea.Quit - } - return m, tea.Batch(cmds...) + case tea.KeyCtrlS: + return m, m.SetSignup(!m.signup) + + case tea.KeyCtrlT: + if m.focusIndex == passphraseField || m.focusIndex == passphraseConfirmField { + m.fields[m.focusIndex].SetRevealed(!m.fields[m.focusIndex].Revealed()) + } + return m, nil + + case tea.KeyEnter: + pressedButton := key == tea.KeyEnter && m.focusIndex == len(m.fields) + if pressedButton { + return m, m.ButtonPressed(msg) } - // Cycle indexes - if key == tea.KeyUp || key == tea.KeyShiftTab { - m.focusIndex-- - } else { - m.focusIndex++ - } + case tea.KeyShiftTab, tea.KeyUp: + m.CycleBack() + return m, m.updateFocus() - if m.focusIndex > len(m.fields) { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = len(m.fields) - } - - cmds := make([]tea.Cmd, len(m.fields)) - for i := 0; i <= len(m.fields)-1; i++ { - if i == m.focusIndex { - cmds[i] = m.fields[i].Focus() - } else { - m.fields[i].Blur() - } - } - - return m, tea.Batch(cmds...) + case tea.KeyDown, tea.KeyTab: + m.CycleForward() + return m, m.updateFocus() } } - cmd := m.updateInputs(msg) + cmd := func() tea.Cmd { + cmds := make([]tea.Cmd, len((&m).fields)) + for i := range (&m).fields { + (&m).fields[i], cmds[i] = (&m).fields[i].Update(msg) + } + return tea.Batch(cmds...) + }() return m, cmd } -func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.fields)) +func (m *Model) CycleBack() { + for i := 0; ; i++ { + m.focusIndex-- - // Only fields with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.fields { - m.fields[i], cmds[i] = m.fields[i].Update(msg) + if m.focusIndex < 0 { + m.focusIndex = len(m.fields) + } + + if m.focusIndex == len(m.fields) || m.fields[m.focusIndex].Visisble { + break + } + + assert.Assert(i < 2*len(m.fields), "CycleBack infinite loop", "i", i) } +} +func (m *Model) CycleForward() { + for i := 0; ; i++ { + m.focusIndex++ + + if m.focusIndex > len(m.fields) { + m.focusIndex = 0 + } + + if m.focusIndex == len(m.fields) || m.fields[m.focusIndex].Visisble { + break + } + + assert.Assert(i < 2*len(m.fields), "CycleForward infinite loop", "i", i) + } +} + +func (m *Model) updateFocus() tea.Cmd { + cmds := make([]tea.Cmd, len(m.fields)) + for i := 0; i < len(m.fields); i++ { + if i == m.focusIndex { + cmds[i] = m.fields[i].Focus() + } else { + m.fields[i].Blur() + } + } return tea.Batch(cmds...) } + +func (m *Model) SetSignup(signup bool) tea.Cmd { + m.signup = signup + for i, field := range m.fields { + visible := m.signup || (i == privateKeyField || i == passphraseField) + field.Visisble = visible + field.Input.Err = nil + m.fields[i] = field + } + m.focusIndex = -1 + m.CycleForward() + return m.updateFocus() +} + +func (m *Model) ButtonPressed(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i, field := range m.fields { + if !field.Visisble || field.Input.Validate == nil { + continue + } + field.Input.Err = field.Input.Validate(field.Input.Value()) + if field.Input.Err != nil { + var cmd tea.Cmd + m.fields[i], cmd = field.Update(msg) + cmds = append(cmds, cmd) + } + } + + if len(cmds) != 0 { + return tea.Batch(cmds...) + } + + if m.signup { + return m.Signup() + } else { + return m.signin() + } +} + +func (m *Model) Signup() tea.Cmd { + return tea.Quit +} + +func (m *Model) signin() tea.Cmd { + privateKeyFilepath := m.fields[privateKeyField].Input.Value() + _, err := os.ReadFile(privateKeyFilepath) + if errors.Is(err, os.ErrNotExist) { + // TODO: add suggestion to move to signup, a popup like: + // File doesn't exist. + // Do you want to go to sign-up? + // Sign-up Cancel + return nil + } + if err != nil { + m.fields[privateKeyField].Input.Err = errors.Unwrap(err) + assert.NotNil(errors.Unwrap(err), "there should always be an error to unwrap", "err", err) + return nil + } + return tea.Quit +} + +func test() { + // pubKey, privKey, err := ed25519.GenerateKey(nil) + // sshPrivKey, err := ssh.NewSignerFromSigner(privKey) + // ssh.MarshalPrivateKey() + // ssh.MarshalPrivateKey() + // ssh.ParseRawPrivateKey() + // ssh.ParseRawPrivateKeyWithPassphrase() +} diff --git a/internal/client/ui/auth/field/field.go b/internal/client/ui/auth/field/field.go index 623cdd6..4c6ca12 100644 --- a/internal/client/ui/auth/field/field.go +++ b/internal/client/ui/auth/field/field.go @@ -9,25 +9,36 @@ import ( ) type Model struct { - Input textinput.Model + Input textinput.Model + Header string + Visisble bool + width int + + reveal bool + revealIcon string + concealIcon string + icon string + Style lipgloss.Style FocusedStyle lipgloss.Style BlurredStyle lipgloss.Style ErrorStyle lipgloss.Style - Header string + HeaderStyle lipgloss.Style } func New(width int) Model { - t := textinput.New() - t.Width = width - t.CharLimit = width - t.Prompt = "" + input := textinput.New() + input.Prompt = "" + input.Width = width m := Model{ - Input: t, + width: width, + Visisble: true, + Input: input, } m.Blur() + m.SetRevealed(true) return m } @@ -37,21 +48,29 @@ func (m Model) Init() tea.Cmd { } func (m Model) View() string { - style := m.Style - error := "" - if m.Input.Err != nil { - error = m.Input.Err.Error() - style = style.BorderForeground(m.ErrorStyle.GetForeground()) + if !m.Visisble { + return "" } - field := ui.AddBorderHeader(m.Header, 1, style, m.Input.View()) - error = m.ErrorStyle.MaxWidth(lipgloss.Width(field)).Render(error) + style := m.Style + header := m.HeaderStyle.Render(m.Header) + if m.Input.Err != nil { + error := m.Input.Err.Error() + style = style.BorderForeground(m.ErrorStyle.GetForeground()) + header = lipgloss.NewStyle(). + MaxWidth(m.width). + Render(header + m.ErrorStyle.Render(" - "+error)) + } - // return lipgloss.JoinVertical(lipgloss.Left, borderTop, field, error) - return lipgloss.JoinVertical(lipgloss.Left, field, error) + field := ui.AddBorderHeader(header, 1, style, m.Input.View()+m.icon) + return field } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.Visisble { + return m, nil + } + var cmd tea.Cmd m.Input, cmd = m.Input.Update(msg) return m, cmd @@ -68,3 +87,56 @@ func (m *Model) Blur() { m.Input.PromptStyle = m.BlurredStyle m.Input.TextStyle = m.BlurredStyle } + +func (m *Model) SetWidth(width int) { + m.width = width + m.recalculateWidth() +} + +func (m Model) Width() int { + return m.width +} + +func (m *Model) SetRevealed(revealed bool) { + m.reveal = revealed + if m.reveal { + m.Input.EchoMode = textinput.EchoNormal + m.icon = m.revealIcon + } else { + m.Input.EchoMode = textinput.EchoPassword + m.icon = m.concealIcon + } + m.recalculateWidth() +} + +func (m Model) Revealed() bool { + return m.reveal +} + +func (m *Model) SetRevealIcon(icon string) { + m.revealIcon = icon + if m.reveal { + m.icon = icon + } + m.recalculateWidth() +} + +func (m Model) RevealIcon() string { + return m.revealIcon +} + +func (m *Model) SetConcealIcon(icon string) { + m.concealIcon = icon + if !m.reveal { + m.icon = icon + } + m.recalculateWidth() +} + +func (m Model) ConcealIcon() string { + return m.concealIcon +} + +func (m *Model) recalculateWidth() { + m.Input.Width = m.width - lipgloss.Width(m.icon) +}