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.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
package ui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
// --- editor: create/update a KV secret -----------------------------------
func (m *Model) newSecret() (tea.Model, tea.Cmd) {
f := m.currentFrame()
m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, true, nil
m.editName = m.newTextInput(fieldSpec{label: "secret name"})
cmd := m.editName.Focus()
m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})}
m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})}
m.editFocus = -1 // -1 == the name field
m.editSnap = m.editSnapshot()
m.push(frame{scr: scrEditor, mount: f.mount, path: f.path})
return m, cmd
}
func (m *Model) editSelected() (tea.Model, tea.Cmd) {
if m.secret == nil {
return m, nil
}
f := m.currentFrame()
m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, false, m.secret
m.editKeys, m.editVals = nil, nil
for _, k := range sortedKeys(m.secret.Data) {
m.editKeys = append(m.editKeys, m.textInputWithValue("key", k))
m.editVals = append(m.editVals, m.textInputWithValue("value", fmt.Sprint(m.secret.Data[k])))
}
if len(m.editKeys) == 0 {
m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})}
m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})}
}
m.editFocus = 0
cmd := m.editKeys[0].Focus()
m.editSnap = m.editSnapshot()
m.push(frame{scr: scrEditor, mount: f.mount, path: f.path})
return m, cmd
}
// editSnapshot serializes the editor's current field values so esc can
// tell whether anything actually changed before asking to discard.
func (m *Model) editSnapshot() string {
var b strings.Builder
b.WriteString(m.editName.Value())
for i := range m.editKeys {
b.WriteByte(0)
b.WriteString(m.editKeys[i].Value())
b.WriteByte('=')
b.WriteString(m.editVals[i].Value())
}
return b.String()
}
func (m *Model) updateEditor(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case writeAckMsg:
m.loading = false
if msg.err != nil {
return m, func() tea.Msg { return errMsg{err: msg.err} }
}
toastCmd := m.notify(toastSuccess, "saved (version %d)", msg.ack.Version)
savedPath := msg.ack.Path
m.pop()
if m.top() == scrSecret {
return m, tea.Batch(toastCmd, m.loadSecret(m.editMount, savedPath, 0))
}
return m, tea.Batch(toastCmd, m.reloadTop())
case tea.KeyPressMsg:
switch msg.String() {
case "esc":
if m.editSnapshot() != m.editSnap {
m.confirm = &confirmSpec{
title: "Discard changes?",
body: "Unsaved edits to this secret will be lost.",
danger: true,
onConfirm: func() tea.Cmd {
m.pop()
return nil
},
}
return m, nil
}
m.pop()
return m, nil
case "ctrl+a":
m.editKeys = append(m.editKeys, m.newTextInput(fieldSpec{label: "key"}))
m.editVals = append(m.editVals, m.newTextInput(fieldSpec{label: "value"}))
return m, nil
case "ctrl+x":
return m, m.removeCurrentField()
case "ctrl+s":
return m.saveEditor()
case "tab":
return m, m.moveEditFocus(1)
case "shift+tab":
return m, m.moveEditFocus(-1)
}
return m.updateEditorInput(msg)
}
return m.updateEditorInput(msg)
}
// focus encoding: -1 = name field (new-secret only), 2*i = key[i], 2*i+1 = val[i]
func (m *Model) moveEditFocus(delta int) tea.Cmd {
maxIdx := len(m.editKeys)*2 - 1
m.blurEdit()
if m.editIsNew {
m.editFocus += delta
if m.editFocus < -1 {
m.editFocus = maxIdx
} else if m.editFocus > maxIdx {
m.editFocus = -1
}
} else {
if maxIdx < 0 {
return nil
}
m.editFocus = (m.editFocus + delta + maxIdx + 1) % (maxIdx + 1)
}
return m.focusEdit()
}
func (m *Model) removeCurrentField() tea.Cmd {
if m.editFocus < 0 || len(m.editKeys) == 0 {
return nil
}
i := m.editFocus / 2
if len(m.editKeys) <= 1 {
m.editKeys[0] = m.newTextInput(fieldSpec{label: "key"})
m.editVals[0] = m.newTextInput(fieldSpec{label: "value"})
m.editFocus = 0
return m.editKeys[0].Focus()
}
m.editKeys = append(m.editKeys[:i], m.editKeys[i+1:]...)
m.editVals = append(m.editVals[:i], m.editVals[i+1:]...)
if m.editFocus >= len(m.editKeys)*2 {
m.editFocus = len(m.editKeys)*2 - 1
}
return m.focusEdit()
}
func (m *Model) blurEdit() {
if m.editFocus == -1 {
m.editName.Blur()
return
}
i, isKey := m.editFocus/2, m.editFocus%2 == 0
if i >= len(m.editKeys) {
return
}
if isKey {
m.editKeys[i].Blur()
} else {
m.editVals[i].Blur()
}
}
func (m *Model) focusEdit() tea.Cmd {
if m.editFocus == -1 {
return m.editName.Focus()
}
i, isKey := m.editFocus/2, m.editFocus%2 == 0
if i >= len(m.editKeys) {
return nil
}
if isKey {
return m.editKeys[i].Focus()
}
return m.editVals[i].Focus()
}
// updateEditorInput forwards msg to whichever field is focused — every
// message type, not just key presses, so a textinput's own async commands
// (cursor blink, paste) actually reach it instead of being dropped by the
// screen dispatch above.
func (m *Model) updateEditorInput(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.editFocus == -1 {
m.editName, cmd = m.editName.Update(msg)
return m, cmd
}
if len(m.editKeys) == 0 {
return m, nil
}
i, isKey := m.editFocus/2, m.editFocus%2 == 0
if i >= len(m.editKeys) {
return m, nil
}
if isKey {
m.editKeys[i], cmd = m.editKeys[i].Update(msg)
} else {
m.editVals[i], cmd = m.editVals[i].Update(msg)
}
return m, cmd
}
func (m *Model) saveEditor() (tea.Model, tea.Cmd) {
data := map[string]interface{}{}
for i := range m.editKeys {
k := m.editKeys[i].Value()
if k == "" {
continue
}
data[k] = m.editVals[i].Value()
}
path := m.editPath
if m.editIsNew {
name := m.editName.Value()
if name == "" {
return m, m.notify(toastWarn, "secret name is required")
}
path = m.editPath + name
}
useCAS := m.a.Settings.RequireCAS && m.editMount.Kind == vault.EngineKVv2
cas := 0
if m.editSecret != nil {
cas = m.editSecret.Version
}
m.loading = true
mount := m.editMount
// Deliberately NOT mutating m.editPath here: on a failed write (CAS
// mismatch, permission denied, ...) the editor screen stays open so the
// user can retry, and a premature mutation would double-concatenate the
// name on a second "new secret" save attempt. writeAckMsg's handler uses
// ack.Path — the path Write actually succeeded at — instead.
return m, func() tea.Msg {
ack, err := m.svc.KV.Write(m.ctx, mount, path, data, useCAS, cas)
return writeAckMsg{ack: ack, err: err}
}
}
// editFieldWidths splits the panel's inner width into key/value columns —
// side by side from sizeCompact up, stacked on sizeTiny.
func (m *Model) editFieldWidths(iw int) (keyColW, valColW int) {
if m.lay.class == sizeTiny {
return iw, iw
}
keyColW = iw / 3
if keyColW < 16 {
keyColW = 16
}
valColW = iw - keyColW - 2
if valColW < 12 {
valColW = 12
}
return
}
func (m *Model) viewEditor() string {
ow, oh := m.lay.bodyW, m.lay.bodyH
iw, _ := panelInner(ow, oh)
tiny := m.lay.class == sizeTiny
keyColW, valColW := m.editFieldWidths(iw)
rows := []string{m.styles.PanelTitle.Render("Edit " + m.editMount.Path + m.editPath), ""}
if m.editIsNew {
label := " name"
if m.editFocus == -1 {
label = m.styles.CrumbActive.Render(symChevronR + " name")
}
m.editName.SetWidth(max(iw-2, 8))
rows = append(rows, label, " "+m.editName.View(), "")
}
const lbl = 8 // widest label ("▸ value") is 7 cells; +1 keeps a gap before the input's own prompt
for i := range m.editKeys {
kFocused, vFocused := m.editFocus == 2*i, m.editFocus == 2*i+1
kLabel, vLabel := padRight("key", lbl), padRight("value", lbl)
if kFocused {
kLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" key", lbl))
}
if vFocused {
vLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" value", lbl))
}
m.editKeys[i].SetWidth(max(keyColW-lbl-1, 6))
m.editVals[i].SetWidth(max(valColW-lbl-1, 6))
keyCell := kLabel + m.editKeys[i].View()
valCell := vLabel + m.editVals[i].View()
if tiny {
rows = append(rows, keyCell, valCell, "")
} else {
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, padRight(keyCell, keyColW+lbl+2), valCell))
}
}
if !tiny {
rows = append(rows, "")
}
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, rows...))
}