summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/client/ui/auth/auth.go329
-rw-r--r--internal/client/ui/auth/field/field.go100
2 files changed, 308 insertions, 121 deletions
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 <thing> 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)
- // Cycle indexes
- if key == tea.KeyUp || key == tea.KeyShiftTab {
- m.focusIndex--
- } else {
- m.focusIndex++
+ case tea.KeyCtrlT:
+ if m.focusIndex == passphraseField || m.focusIndex == passphraseConfirmField {
+ m.fields[m.focusIndex].SetRevealed(!m.fields[m.focusIndex].Revealed())
}
+ return m, nil
- if m.focusIndex > len(m.fields) {
- m.focusIndex = 0
- } else if m.focusIndex < 0 {
- m.focusIndex = len(m.fields)
+ case tea.KeyEnter:
+ pressedButton := key == tea.KeyEnter && m.focusIndex == len(m.fields)
+ if pressedButton {
+ return m, m.ButtonPressed(msg)
}
- 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()
- }
- }
+ case tea.KeyShiftTab, tea.KeyUp:
+ m.CycleBack()
+ return m, m.updateFocus()
- 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 <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 {
+ if !m.Visisble {
+ return ""
+ }
+
style := m.Style
- error := ""
+ header := m.HeaderStyle.Render(m.Header)
if m.Input.Err != nil {
- error = m.Input.Err.Error()
+ error := m.Input.Err.Error()
style = style.BorderForeground(m.ErrorStyle.GetForeground())
+ header = lipgloss.NewStyle().
+ MaxWidth(m.width).
+ Render(header + m.ErrorStyle.Render(" - "+error))
}
- field := ui.AddBorderHeader(m.Header, 1, style, m.Input.View())
- error = m.ErrorStyle.MaxWidth(lipgloss.Width(field)).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)
+}