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}