package ui import ( "context" "fmt" "sort" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/auth" ) // startAuth (re)enters the auth screen: build the method list (prioritising // the profile's configured default, if any) and reset to the picker phase. // Called from Init and again whenever a token turns out to be missing or // invalid (see tokenInfoMsg handling and ensureLoggedIn's own error path). func (m *Model) startAuth() tea.Cmd { methods := auth.Default().All() cursor := 0 for i, meth := range methods { if meth.Name() == m.a.Settings.Auth.Method { cursor = i } } m.auth = authState{phase: 0, methods: methods, cursor: cursor} m.stack = nil return m.tryExistingToken() } // tryExistingToken attempts the flag/env/store token resolution the // headless CLI also uses (internal/cli.App.EnsureLoggedIn) before falling // back to an interactive login — this is what makes VAULT_TOKEN and a // pre-existing `vault login` session "just work" without visiting the auth // screen at all. func (m *Model) tryExistingToken() tea.Cmd { return func() tea.Msg { _, err := m.a.EnsureLoggedIn(m.ctx, "") if err != nil { return statusMsg{text: "no saved token — pick an auth method"} } return loginDoneMsg{result: &auth.Result{Token: m.a.Client.Token()}} } } func (m *Model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case loginDoneMsg: if msg.err != nil { m.auth.err = msg.err m.auth.phase = 0 return m, nil } if msg.result != nil && msg.result.Token != "" && m.a.Client.Token() == "" { m.a.Client.SetToken(msg.result.Token) } m.push(frame{scr: scrMounts}) cmd := m.loadMounts() if msg.storeWarn != "" { cmd = tea.Batch(cmd, m.notify(toastWarn, "logged in, but could not save token: %s", msg.storeWarn)) } return m, cmd case loginEventMsg: switch msg.Kind { case auth.EventOpenURL: m.auth.authURL = msg.URL default: m.auth.status = append(m.auth.status, msg.Message) } return m, waitForAuthEvent(m.auth.events) } switch m.auth.phase { case 0: if km, ok := msg.(tea.KeyPressMsg); ok { return m.updateAuthPicker(km) } case 1: return m.updateAuthForm(msg) case 2: if km, ok := msg.(tea.KeyPressMsg); ok { return m.updateAuthWaiting(km) } } return m, nil } func (m *Model) updateAuthPicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Down): if m.auth.cursor < len(m.auth.methods)-1 { m.auth.cursor++ } case key.Matches(msg, m.keys.Up): if m.auth.cursor > 0 { m.auth.cursor-- } case key.Matches(msg, m.keys.Enter): return m.chooseAuthMethod(m.auth.methods[m.auth.cursor]) case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.ForceQuit): m.quitting = true return m, tea.Quit } return m, nil } func (m *Model) chooseAuthMethod(meth auth.Method) (tea.Model, tea.Cmd) { m.auth.method = meth m.auth.mount = m.a.Settings.Auth.Mount if m.auth.mount == "" || m.a.Settings.Auth.Method != meth.Name() { m.auth.mount = meth.DefaultMount() } creds := auth.Prefill(meth, auth.PrefillSource{ConfigArgs: m.a.Settings.Auth.Params}) if len(auth.Missing(meth, creds)) == 0 { return m.beginLogin(meth, creds) } m.auth.inputs = nil for _, f := range meth.Fields() { ti := m.newTextInput(fieldSpec{label: f.Label, secret: f.Kind == auth.FieldSecret, value: creds.Get(f.Name)}) m.auth.inputs = append(m.auth.inputs, textInputField{field: f, input: ti}) } m.auth.focus = 0 var cmd tea.Cmd if len(m.auth.inputs) > 0 { cmd = m.auth.inputs[0].input.Focus() } m.auth.phase = 1 return m, cmd } // updateAuthForm handles the credential-form phase. It only special-cases // key presses that are form navigation (esc/tab/enter); everything else — // including non-key messages like a textinput's own cursor-blink tick — is // forwarded straight to the focused field's Update. func (m *Model) updateAuthForm(msg tea.Msg) (tea.Model, tea.Cmd) { if km, ok := msg.(tea.KeyPressMsg); ok { switch km.String() { case "esc": m.auth.phase = 0 return m, nil case "tab", "down": return m, m.moveAuthFocus(1) case "shift+tab", "up": return m, m.moveAuthFocus(-1) case "enter": if m.auth.focus < len(m.auth.inputs)-1 { return m, m.moveAuthFocus(1) } creds := auth.Credentials{} for _, f := range m.auth.inputs { creds[f.field.Name] = f.input.Value() } if err := auth.Validate(m.auth.method, creds); err != nil { m.auth.err = err return m, nil } return m.beginLogin(m.auth.method, creds) } } if len(m.auth.inputs) == 0 { return m, nil } var cmd tea.Cmd m.auth.inputs[m.auth.focus].input, cmd = m.auth.inputs[m.auth.focus].input.Update(msg) return m, cmd } func (m *Model) moveAuthFocus(delta int) tea.Cmd { if len(m.auth.inputs) == 0 { return nil } m.auth.inputs[m.auth.focus].input.Blur() m.auth.focus = (m.auth.focus + delta + len(m.auth.inputs)) % len(m.auth.inputs) return m.auth.inputs[m.auth.focus].input.Focus() } func (m *Model) beginLogin(meth auth.Method, creds auth.Credentials) (tea.Model, tea.Cmd) { m.auth.phase = 2 m.auth.err = nil m.auth.status = nil m.auth.authURL = "" ctx, cancel := context.WithCancel(m.ctx) m.auth.cancel = cancel events := make(chan auth.Event, 16) m.auth.events = events req := auth.Request{Mount: m.auth.mount, Namespace: m.a.Settings.Namespace, Creds: creds, Events: events} run := func() tea.Msg { defer close(events) sec, err := meth.Login(ctx, m.a.Client, req) if err != nil { return loginDoneMsg{err: err} } res, err := auth.NewResult(sec, m.a.Settings.Namespace) if err != nil { return loginDoneMsg{err: err} } if serr := m.a.Store.Store(ctx, res.Token); serr != nil { return loginDoneMsg{result: res, storeWarn: serr.Error()} } return loginDoneMsg{result: res} } return m, tea.Batch(run, waitForAuthEvent(events)) } func waitForAuthEvent(events <-chan auth.Event) tea.Cmd { return func() tea.Msg { e, ok := <-events if !ok { return nil } return loginEventMsg(e) } } func (m *Model) updateAuthWaiting(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": if m.auth.cancel != nil { m.auth.cancel() } m.auth.phase = 0 return m, nil case "y": return m, copyToClipboard(m.auth.authURL) } return m, nil } func copyToClipboard(s string) tea.Cmd { if s == "" { return nil } return tea.SetClipboard(s) } // authCardWidth is the inner content width every auth-phase card wraps // text to, capped so the card never dominates a very wide terminal. func (m *Model) authCardWidth() int { w := m.lay.bodyW - 12 if w > 56 { w = 56 } if w < 24 { w = 24 } return w } // authCard centers content (already wrapped to authCardWidth) in a bordered // card over the auth screen's body area — the "modern app" look asked for, // replacing the old flush-left, unbounded-width panel. func (m *Model) authCard(content string) string { box := m.styles.PanelActive.Render(content) return lipgloss.Place(m.lay.bodyW, m.lay.bodyH, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg))) } func (m *Model) viewAuth() string { switch m.auth.phase { case 1: return m.viewAuthForm() case 2: return m.viewAuthWaiting() default: return m.viewAuthPicker() } } // methodDescription returns a one-line hint for a method: its // auth.Describable text if implemented, else a small built-in fallback so // the picker never shows a bare name with nothing else to go on. func methodDescription(meth auth.Method) string { if d, ok := meth.(auth.Describable); ok { return d.Description() } switch meth.Name() { case "token": return "use an existing Vault token" case "oidc": return "sign in via your browser" case "userpass": return "username and password" case "ldap": return "LDAP directory credentials" case "okta": return "Okta username/password, with MFA" case "radius": return "RADIUS username/password" case "approle": return "role ID and secret ID" case "github": return "a GitHub personal access token" case "jwt": return "a role and a signed JWT" case "kubernetes": return "the pod's projected service-account token" case "cert": return "a TLS client certificate" case "aws", "azure", "gcp": return "cloud-native machine identity" default: return "" } } func (m *Model) viewAuthPicker() string { w := m.authCardWidth() lines := []string{m.styles.ModalTitle.Render("Sign in to Vault"), ""} for i, meth := range m.auth.methods { name := meth.DisplayName() desc := methodDescription(meth) if i == m.auth.cursor { lines = append(lines, m.styles.RowSelected.Render(padRight(symChevronR+" "+name, w))) } else { line := " " + name if desc != "" { line = padRight(line, 18) + m.styles.PanelSubtle.Render(desc) } lines = append(lines, line) } } if unavail := auth.Default().UnavailableAll(); len(unavail) > 0 { names := make([]string, 0, len(unavail)) for n := range unavail { names = append(names, n) } sort.Strings(names) lines = append(lines, "") for _, n := range names { lines = append(lines, m.styles.RowDim.Render(fmt.Sprintf(" %s — %s", n, unavail[n]))) } } if m.auth.err != nil { lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, ""))) } lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter select • q quit")) return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) } func (m *Model) viewAuthForm() string { w := m.authCardWidth() lines := []string{m.styles.ModalTitle.Render(m.auth.method.DisplayName()), ""} for i, f := range m.auth.inputs { label := f.field.Label if f.field.Required { label += " *" } if i == m.auth.focus { label = m.styles.CrumbActive.Render(symChevronR + " " + label) } else { label = " " + label } f.input.SetWidth(w - 2) lines = append(lines, label, " "+f.input.View()) if f.field.Help != "" { lines = append(lines, m.styles.EmptyState.Render(" "+f.field.Help)) } lines = append(lines, "") } if m.auth.err != nil { lines = append(lines, m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")), "") } lines = append(lines, m.styles.Help.Render("tab/shift+tab move • enter next/submit • esc back")) return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) } func (m *Model) viewAuthWaiting() string { w := m.authCardWidth() lines := []string{m.styles.ModalTitle.Render("Signing in via " + m.auth.method.DisplayName()), ""} if m.auth.authURL != "" { lines = append(lines, "Open this URL if your browser didn't launch automatically:", "", m.styles.InputFocused.Render(lipgloss.Wrap(m.auth.authURL, w, "")), "", m.styles.Help.Render("y copy URL • esc cancel")) } else { lines = append(lines, m.spin.View()+" waiting…") } for _, s := range m.auth.status { lines = append(lines, m.styles.PanelSubtle.Render(lipgloss.Wrap(s, w, ""))) } if m.auth.err != nil { lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, ""))) } return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) }