- 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.
306 lines
8.2 KiB
Go
306 lines
8.2 KiB
Go
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...))
|
|
}
|