loginform.go (6475B)
1 package tui 2 3 import ( 4 "os" 5 "strings" 6 7 "github.com/charmbracelet/bubbles/textinput" 8 tea "github.com/charmbracelet/bubbletea" 9 "github.com/charmbracelet/lipgloss" 10 ) 11 12 type loginField int 13 14 const ( 15 loginFieldSaved loginField = iota // "Login with saved credentials" button 16 loginFieldUser 17 loginFieldPassword 18 loginFieldDatabase 19 loginFieldSave 20 loginFieldCount 21 ) 22 23 const loginTextInputs = 3 // user, password, database 24 25 // LoginForm is the login dialog overlay shown when credentials are missing. 26 type LoginForm struct { 27 inputs [loginTextInputs]textinput.Model 28 save bool 29 focused loginField 30 savedUser string // non-empty when saved credentials are available 31 savedPw string 32 savedDB string 33 } 34 35 // LoginSubmitMsg is sent when the user confirms the login form. 36 type LoginSubmitMsg struct { 37 User string 38 Password string 39 Database string 40 Save bool 41 } 42 43 // LoginCancelMsg is sent when the user cancels the login form. 44 type LoginCancelMsg struct{} 45 46 func newLoginForm() LoginForm { 47 f := LoginForm{} 48 49 f.inputs[loginFieldUser-1] = textinput.New() 50 f.inputs[loginFieldUser-1].Placeholder = "WRDS username" 51 f.inputs[loginFieldUser-1].CharLimit = 128 52 53 f.inputs[loginFieldPassword-1] = textinput.New() 54 f.inputs[loginFieldPassword-1].Placeholder = "WRDS password" 55 f.inputs[loginFieldPassword-1].CharLimit = 128 56 f.inputs[loginFieldPassword-1].EchoMode = textinput.EchoPassword 57 f.inputs[loginFieldPassword-1].EchoCharacter = '*' 58 59 f.inputs[loginFieldDatabase-1] = textinput.New() 60 f.inputs[loginFieldDatabase-1].Placeholder = "wrds" 61 f.inputs[loginFieldDatabase-1].CharLimit = 128 62 f.inputs[loginFieldDatabase-1].SetValue("wrds") 63 64 // Check for saved credentials in env (set by config.ApplyCredentials). 65 f.savedUser = os.Getenv("PGUSER") 66 f.savedPw = os.Getenv("PGPASSWORD") 67 f.savedDB = os.Getenv("PGDATABASE") 68 if f.savedDB == "" { 69 f.savedDB = "wrds" 70 } 71 72 f.save = true 73 74 if f.hasSaved() { 75 f.focused = loginFieldSaved 76 } else { 77 f.focused = loginFieldUser 78 f.inputs[loginFieldUser-1].Focus() 79 } 80 return f 81 } 82 83 func (f *LoginForm) hasSaved() bool { 84 return f.savedUser != "" && f.savedPw != "" 85 } 86 87 // inputIndex maps a loginField to the inputs array index. 88 // Returns -1 for non-input fields (saved, save). 89 func inputIndex(field loginField) int { 90 switch field { 91 case loginFieldUser, loginFieldPassword, loginFieldDatabase: 92 return int(field) - 1 93 } 94 return -1 95 } 96 97 func (f *LoginForm) blurCurrent() { 98 if idx := inputIndex(f.focused); idx >= 0 { 99 f.inputs[idx].Blur() 100 } 101 } 102 103 func (f *LoginForm) focusCurrent() tea.Cmd { 104 if idx := inputIndex(f.focused); idx >= 0 { 105 f.inputs[idx].Focus() 106 return textinput.Blink 107 } 108 return nil 109 } 110 111 func (f *LoginForm) advance(delta int) tea.Cmd { 112 f.blurCurrent() 113 start := loginFieldSaved 114 if !f.hasSaved() { 115 start = loginFieldUser 116 } 117 count := int(loginFieldCount) - int(start) 118 pos := (int(f.focused) - int(start) + delta%count + count) % count 119 f.focused = loginField(pos + int(start)) 120 return f.focusCurrent() 121 } 122 123 func (f LoginForm) submit() tea.Cmd { 124 user := strings.TrimSpace(f.inputs[loginFieldUser-1].Value()) 125 pw := f.inputs[loginFieldPassword-1].Value() 126 if user == "" || pw == "" { 127 return nil 128 } 129 database := strings.TrimSpace(f.inputs[loginFieldDatabase-1].Value()) 130 if database == "" { 131 database = "wrds" 132 } 133 return func() tea.Msg { 134 return LoginSubmitMsg{User: user, Password: pw, Database: database, Save: f.save} 135 } 136 } 137 138 func (f LoginForm) Update(msg tea.Msg) (LoginForm, tea.Cmd) { 139 switch msg := msg.(type) { 140 case tea.KeyMsg: 141 switch msg.String() { 142 case "esc": 143 return f, func() tea.Msg { return LoginCancelMsg{} } 144 145 case "enter": 146 if f.focused == loginFieldSaved { 147 // Connect using saved credentials. 148 return f, func() tea.Msg { 149 return LoginSubmitMsg{ 150 User: f.savedUser, 151 Password: f.savedPw, 152 Database: f.savedDB, 153 Save: false, 154 } 155 } 156 } 157 if f.focused == loginFieldSave { 158 return f, f.submit() 159 } 160 // Advance to next field. 161 cmd := f.advance(1) 162 return f, cmd 163 164 case "tab", "down": 165 cmd := f.advance(1) 166 return f, cmd 167 168 case "shift+tab", "up": 169 cmd := f.advance(-1) 170 return f, cmd 171 172 case " ": 173 if f.focused == loginFieldSave { 174 f.save = !f.save 175 return f, nil 176 } 177 } 178 } 179 180 // Forward to focused text input. 181 if idx := inputIndex(f.focused); idx >= 0 { 182 var cmd tea.Cmd 183 f.inputs[idx], cmd = f.inputs[idx].Update(msg) 184 return f, cmd 185 } 186 return f, nil 187 } 188 189 func (f LoginForm) View(width int, errMsg string) string { 190 var sb strings.Builder 191 192 title := stylePanelHeader.Render("WRDS Login") 193 sb.WriteString(title + "\n\n") 194 195 // "Login with saved credentials" button. 196 if f.hasSaved() { 197 btnLabel := "Login as " + f.savedUser 198 btnStyle := lipgloss.NewStyle().Foreground(colorMuted) 199 if f.focused == loginFieldSaved { 200 btnStyle = lipgloss.NewStyle(). 201 Foreground(lipgloss.Color("#FFFFFF")). 202 Background(colorFocus). 203 Padding(0, 1) 204 btnLabel += " [enter]" 205 } 206 sb.WriteString(btnStyle.Render(btnLabel) + "\n\n") 207 sb.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Render("── or enter credentials manually ──") + "\n\n") 208 } 209 210 labels := []string{"Username", "Password", "Database"} 211 fields := []loginField{loginFieldUser, loginFieldPassword, loginFieldDatabase} 212 for i, label := range labels { 213 style := lipgloss.NewStyle().Foreground(colorMuted) 214 if fields[i] == f.focused { 215 style = lipgloss.NewStyle().Foreground(colorFocus) 216 } 217 sb.WriteString(style.Render(label+" ") + "\n") 218 sb.WriteString(f.inputs[i].View() + "\n\n") 219 } 220 221 // Save toggle. 222 check := "[ ]" 223 if f.save { 224 check = "[x]" 225 } 226 saveStyle := lipgloss.NewStyle().Foreground(colorMuted) 227 if f.focused == loginFieldSave { 228 saveStyle = lipgloss.NewStyle().Foreground(colorFocus) 229 } 230 sb.WriteString(saveStyle.Render(check+" Save to ~/.config/wrds-dl/credentials") + "\n\n") 231 232 if errMsg != "" { 233 maxLen := 52 234 if len(errMsg) > maxLen { 235 errMsg = errMsg[:maxLen-1] + "…" 236 } 237 sb.WriteString(styleError.Render("Error: "+errMsg) + "\n\n") 238 } 239 240 hint := styleStatusBar.Render("[tab] next field [enter] submit [esc] quit") 241 sb.WriteString(hint) 242 243 content := sb.String() 244 boxWidth := 60 245 if boxWidth > width-4 { 246 boxWidth = width - 4 247 } 248 if boxWidth < 40 { 249 boxWidth = 40 250 } 251 252 box := lipgloss.NewStyle(). 253 Border(lipgloss.RoundedBorder()). 254 BorderForeground(colorFocus). 255 Padding(1, 2). 256 Width(boxWidth). 257 Render(content) 258 259 return lipgloss.Place(width, 24, lipgloss.Center, lipgloss.Center, box) 260 }