login.go

  1package tui
  2
  3import (
  4	"strconv"
  5
  6	"charm.land/bubbles/v2/textinput"
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9)
 10
 11// Login holds the state for the login/add account form.
 12type Login struct {
 13	focusIndex int
 14	inputs     []textinput.Model
 15	showCustom bool // Show custom server fields
 16	useOAuth2  bool // Use OAuth2 instead of password (for gmail)
 17	isEditMode bool // Whether we're editing an existing account
 18	accountID  string
 19	hideTips   bool
 20	width      int
 21	height     int
 22}
 23
 24const (
 25	inputProvider = iota
 26	inputName
 27	inputEmail
 28	inputFetchEmail
 29	inputAuthMethod // "password" or "oauth2" (shown for gmail)
 30	inputPassword
 31	inputIMAPServer
 32	inputIMAPPort
 33	inputSMTPServer
 34	inputSMTPPort
 35	inputCount
 36)
 37
 38// NewLogin creates a new login model for adding accounts.
 39func NewLogin(hideTips bool) *Login {
 40	m := &Login{
 41		inputs:   make([]textinput.Model, inputCount),
 42		hideTips: hideTips,
 43	}
 44
 45	tiStyles := ThemedTextInputStyles()
 46	var t textinput.Model
 47	for i := range m.inputs {
 48		t = textinput.New()
 49		t.CharLimit = 128
 50		t.SetStyles(tiStyles)
 51
 52		switch i {
 53		case inputProvider:
 54			t.Placeholder = "Provider (gmail, icloud, or custom)"
 55			t.Focus()
 56			t.Prompt = "🏢 > "
 57		case inputName:
 58			t.Placeholder = "Display Name"
 59			t.Prompt = "👤 > "
 60		case inputEmail:
 61			t.Placeholder = "Username"
 62			t.Prompt = "🏠 > "
 63		case inputFetchEmail:
 64			t.Placeholder = "Email Address (comma-separated for multiple)"
 65			t.Prompt = "📧 > "
 66		case inputAuthMethod:
 67			t.Placeholder = "Auth Method (password or oauth2)"
 68			t.Prompt = "🔐 > "
 69		case inputPassword:
 70			t.Placeholder = "Password / App Password"
 71			t.EchoMode = textinput.EchoPassword
 72			t.Prompt = "🔑 > "
 73		case inputIMAPServer:
 74			t.Placeholder = "IMAP Server (e.g., imap.example.com)"
 75			t.Prompt = "📥 > "
 76		case inputIMAPPort:
 77			t.Placeholder = "IMAP Port (default: 993)"
 78			t.Prompt = "🔢 > "
 79		case inputSMTPServer:
 80			t.Placeholder = "SMTP Server (e.g., smtp.example.com)"
 81			t.Prompt = "📤 > "
 82		case inputSMTPPort:
 83			t.Placeholder = "SMTP Port (default: 587)"
 84			t.Prompt = "🔢 > "
 85		}
 86		m.inputs[i] = t
 87	}
 88
 89	return m
 90}
 91
 92// Init initializes the login model.
 93func (m *Login) Init() tea.Cmd {
 94	return textinput.Blink
 95}
 96
 97// Update handles messages for the login model.
 98func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 99	switch msg := msg.(type) {
100	case tea.WindowSizeMsg:
101		m.width = msg.Width
102		m.height = msg.Height
103		for i := range m.inputs {
104			m.inputs[i].SetWidth(msg.Width - 6)
105		}
106
107	case tea.KeyPressMsg:
108		switch msg.String() {
109		case "esc":
110			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
111
112		case "enter":
113			// Check if provider is "custom" to show/hide custom fields
114			provider := m.inputs[inputProvider].Value()
115			m.showCustom = provider == "custom"
116			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
117
118			lastFieldIndex := inputPassword
119			if m.useOAuth2 {
120				// OAuth2: last field before submit is the auth method field
121				lastFieldIndex = inputAuthMethod
122			}
123			if m.showCustom {
124				lastFieldIndex = inputSMTPPort
125			}
126
127			if m.focusIndex == lastFieldIndex {
128				// Submit the form
129				imapPort := 993
130				smtpPort := 587
131				if m.inputs[inputIMAPPort].Value() != "" {
132					if p, err := strconv.Atoi(m.inputs[inputIMAPPort].Value()); err == nil {
133						imapPort = p
134					}
135				}
136				if m.inputs[inputSMTPPort].Value() != "" {
137					if p, err := strconv.Atoi(m.inputs[inputSMTPPort].Value()); err == nil {
138						smtpPort = p
139					}
140				}
141
142				authMethod := "password"
143				if m.useOAuth2 {
144					authMethod = "oauth2"
145				}
146
147				return m, func() tea.Msg {
148					return Credentials{
149						Provider:   m.inputs[inputProvider].Value(),
150						Name:       m.inputs[inputName].Value(),
151						Host:       m.inputs[inputEmail].Value(),
152						FetchEmail: m.inputs[inputFetchEmail].Value(),
153						Password:   m.inputs[inputPassword].Value(),
154						IMAPServer: m.inputs[inputIMAPServer].Value(),
155						IMAPPort:   imapPort,
156						SMTPServer: m.inputs[inputSMTPServer].Value(),
157						SMTPPort:   smtpPort,
158						AuthMethod: authMethod,
159					}
160				}
161			}
162			fallthrough
163
164		case "tab", "shift+tab", "up", "down":
165			s := msg.String()
166
167			// Check provider to update showCustom and useOAuth2
168			provider := m.inputs[inputProvider].Value()
169			m.showCustom = provider == "custom"
170			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
171
172			maxIndex := inputPassword
173			if m.useOAuth2 {
174				maxIndex = inputAuthMethod
175			}
176			if m.showCustom {
177				maxIndex = inputSMTPPort
178			}
179
180			if s == "up" || s == "shift+tab" {
181				m.focusIndex--
182			} else {
183				m.focusIndex++
184			}
185
186			if m.focusIndex > maxIndex {
187				m.focusIndex = 0
188			} else if m.focusIndex < 0 {
189				m.focusIndex = maxIndex
190			}
191
192			// Skip password field when using OAuth2
193			if m.useOAuth2 && m.focusIndex == inputPassword {
194				if s == "up" || s == "shift+tab" {
195					m.focusIndex = inputAuthMethod
196				} else {
197					m.focusIndex = 0
198				}
199			}
200
201			// Skip auth method field when not Gmail (only Gmail supports OAuth2)
202			if provider != "gmail" && m.focusIndex == inputAuthMethod {
203				if s == "up" || s == "shift+tab" {
204					m.focusIndex = inputFetchEmail
205				} else {
206					m.focusIndex = inputPassword
207				}
208			}
209
210			// Skip custom fields if not showing them
211			if !m.showCustom && m.focusIndex > inputPassword {
212				if s == "up" || s == "shift+tab" {
213					if m.useOAuth2 {
214						m.focusIndex = inputAuthMethod
215					} else {
216						m.focusIndex = inputPassword
217					}
218				} else {
219					m.focusIndex = 0
220				}
221			}
222
223			cmds := make([]tea.Cmd, len(m.inputs))
224			for i := 0; i < len(m.inputs); i++ {
225				if i == m.focusIndex {
226					cmds[i] = m.inputs[i].Focus()
227				} else {
228					m.inputs[i].Blur()
229				}
230			}
231			return m, tea.Batch(cmds...)
232		}
233	}
234
235	// Update the focused input field
236	var cmds = make([]tea.Cmd, len(m.inputs))
237	for i := range m.inputs {
238		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
239	}
240
241	// Check if provider changed
242	provider := m.inputs[inputProvider].Value()
243	m.showCustom = provider == "custom"
244	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
245
246	return m, tea.Batch(cmds...)
247}
248
249// View renders the login form.
250func (m *Login) View() tea.View {
251	title := "Add Account"
252	if m.isEditMode {
253		title = "Edit Account"
254	}
255
256	customHint := ""
257	if m.inputs[inputProvider].Value() == "custom" || m.showCustom {
258		customHint = "\n" + accountEmailStyle.Render("Custom provider selected - configure server settings below")
259	}
260
261	tip := ""
262	switch m.focusIndex {
263	case inputProvider:
264		tip = "Enter your email provider (e.g., gmail, icloud) or 'custom'."
265	case inputName:
266		tip = "The name that will appear on emails you send."
267	case inputEmail:
268		tip = "Your full email address used to log in."
269	case inputFetchEmail:
270		tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)."
271	case inputAuthMethod:
272		tip = "Type 'oauth2' for Gmail OAuth2 or 'password' for app password."
273	case inputPassword:
274		tip = "Your password or an app-specific password if using 2FA."
275	case inputIMAPServer:
276		tip = "The server address for receiving emails."
277	case inputIMAPPort:
278		tip = "The port for the IMAP server (usually 993 for SSL)."
279	case inputSMTPServer:
280		tip = "The server address for sending emails."
281	case inputSMTPPort:
282		tip = "The port for the SMTP server (usually 587 for TLS)."
283	}
284
285	isGmail := m.inputs[inputProvider].Value() == "gmail"
286
287	views := []string{
288		titleStyle.Render(title),
289		"Enter your email account credentials.",
290		customHint,
291		m.inputs[inputProvider].View(),
292		m.inputs[inputName].View(),
293		m.inputs[inputEmail].View(),
294		m.inputs[inputFetchEmail].View(),
295	}
296
297	// Show auth method selector for Gmail
298	if isGmail {
299		views = append(views, m.inputs[inputAuthMethod].View())
300	}
301
302	// Hide password field when using OAuth2
303	if !m.useOAuth2 {
304		views = append(views, m.inputs[inputPassword].View())
305	} else {
306		views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
307	}
308
309	if m.showCustom {
310		views = append(views,
311			"",
312			listHeader.Render("Custom Server Settings:"),
313			m.inputs[inputIMAPServer].View(),
314			m.inputs[inputIMAPPort].View(),
315			m.inputs[inputSMTPServer].View(),
316			m.inputs[inputSMTPPort].View(),
317		)
318	}
319
320	views = append(views, "")
321	if !m.hideTips && tip != "" {
322		views = append(views, TipStyle.Render("Tip: "+tip))
323	}
324	views = append(views, helpStyle.Render("\nenter: save • tab: next field • esc: back to menu"))
325
326	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
327}
328
329// SetEditMode sets the login form to edit an existing account.
330func (m *Login) SetEditMode(accountID, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int) {
331	m.isEditMode = true
332	m.accountID = accountID
333	m.inputs[inputProvider].SetValue(provider)
334	m.inputs[inputName].SetValue(name)
335	m.inputs[inputEmail].SetValue(email)
336	m.inputs[inputFetchEmail].SetValue(fetchEmail)
337	m.showCustom = provider == "custom"
338
339	if m.showCustom {
340		m.inputs[inputIMAPServer].SetValue(imapServer)
341		if imapPort != 0 {
342			m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
343		}
344		m.inputs[inputSMTPServer].SetValue(smtpServer)
345		if smtpPort != 0 {
346			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
347		}
348	}
349}
350
351// GetAccountID returns the account ID being edited (if in edit mode).
352func (m *Login) GetAccountID() string {
353	return m.accountID
354}
355
356// IsEditMode returns whether the form is in edit mode.
357func (m *Login) IsEditMode() bool {
358	return m.isEditMode
359}