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:
@@ -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...))
|
||||
}
|
||||
Reference in New Issue
Block a user