Files
f.weber ae30ba1240 feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
2026-08-14 11:09:03 +02:00

395 lines
11 KiB
Go

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...))
}