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,294 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
func (m *Model) loadSecret(mount vault.Mount, path string, version int) tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
sec, err := m.svc.KV.Read(m.ctx, mount, path, version)
|
||||
return secretMsg{secret: sec, mount: mount, path: path, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(data map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(data))
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m *Model) resizeSecretViewport() {
|
||||
if m.secret == nil {
|
||||
return
|
||||
}
|
||||
iw, ih := panelInner(m.lay.bodyW, m.lay.bodyH)
|
||||
ih -= 2 // title + metadata line
|
||||
if ih < 1 {
|
||||
ih = 1
|
||||
}
|
||||
m.secretVP.SetWidth(iw)
|
||||
m.secretVP.SetHeight(ih)
|
||||
}
|
||||
|
||||
func (m *Model) updateSecret(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case secretMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
if msg.secret == nil {
|
||||
m.pop()
|
||||
return m, m.notify(toastWarn, "%s%s: secret not found (already deleted?)", msg.mount.Path, msg.path)
|
||||
}
|
||||
m.secret = msg.secret
|
||||
m.secretKeys = sortedKeys(msg.secret.Data)
|
||||
m.secretMask = make(map[string]bool, len(m.secretKeys))
|
||||
for _, k := range m.secretKeys {
|
||||
m.secretMask[k] = !m.a.Settings.MaskValues
|
||||
}
|
||||
if m.secretCursor >= len(m.secretKeys) {
|
||||
m.secretCursor = 0
|
||||
}
|
||||
m.resizeSecretViewport()
|
||||
return m, nil
|
||||
|
||||
case deleteAckMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
m.secret = nil
|
||||
_, popCmd := m.popBrowser()
|
||||
return m, tea.Batch(m.notify(toastSuccess, "%v: %s", msg.ack.Op, msg.ack.Path), popCmd)
|
||||
|
||||
case clipboardClearMsg:
|
||||
if msg.seq == m.clipSeq {
|
||||
return m, tea.SetClipboard("")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
m.secret = nil
|
||||
return m.popBrowser()
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
// Safe to bind bare "q" here (unlike the editor/auth-form
|
||||
// screens): the secret detail screen owns no text input.
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.secretCursor > 0 {
|
||||
m.secretCursor--
|
||||
}
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.secretCursor < len(m.secretKeys)-1 {
|
||||
m.secretCursor++
|
||||
}
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.ToggleMask):
|
||||
m.toggleRowMask()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.ToggleMaskAll):
|
||||
m.toggleAllMask()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Versions):
|
||||
return m.openVersions()
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.editSelected()
|
||||
}
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmDelete(false)
|
||||
}
|
||||
case key.Matches(msg, m.keys.Destroy):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmDelete(true)
|
||||
}
|
||||
case key.Matches(msg, m.keys.CopyValue):
|
||||
return m, m.copySelectedValue()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) toggleRowMask() {
|
||||
if len(m.secretKeys) == 0 {
|
||||
return
|
||||
}
|
||||
k := m.secretKeys[m.secretCursor]
|
||||
m.secretMask[k] = !m.secretMask[k]
|
||||
}
|
||||
|
||||
func (m *Model) toggleAllMask() {
|
||||
any := false
|
||||
for _, k := range m.secretKeys {
|
||||
if m.secretMask[k] {
|
||||
any = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, k := range m.secretKeys {
|
||||
m.secretMask[k] = !any
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) copySelectedValue() tea.Cmd {
|
||||
if m.secret == nil || len(m.secretKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
k := m.secretKeys[m.secretCursor]
|
||||
v := fmt.Sprint(m.secret.Data[k])
|
||||
cmds := []tea.Cmd{tea.SetClipboard(v), m.notify(toastSuccess, "copied %s", k)}
|
||||
if d := m.a.Settings.ClipboardClear; d > 0 {
|
||||
m.clipSeq++
|
||||
seq := m.clipSeq
|
||||
cmds = append(cmds, tea.Tick(d, func(time.Time) tea.Msg { return clipboardClearMsg{seq: seq} }))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// confirmTypedName decides whether a destructive confirmation requires the
|
||||
// user to type the path back (irreversible actions) or just y/enter.
|
||||
func confirmTypedName(danger bool, path string) string {
|
||||
if danger {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Model) confirmDelete(destroy bool) (tea.Model, tea.Cmd) {
|
||||
f := m.currentFrame()
|
||||
mount, path := f.mount, f.path
|
||||
op := vault.OpSoftDelete
|
||||
danger := destroy
|
||||
title := "Delete " + path + "?"
|
||||
body := "This soft-deletes the current version. It can be undeleted."
|
||||
var versions []int // nil => current version, for the ops that support that shorthand
|
||||
if mount.Kind != vault.EngineKVv2 {
|
||||
op = vault.OpDeleteV1
|
||||
danger = true
|
||||
body = "This permanently deletes the secret. This cannot be undone."
|
||||
} else if destroy {
|
||||
op = vault.OpDestroy
|
||||
title = "Destroy " + path + "?"
|
||||
body = "This permanently destroys the current version's data. This cannot be undone."
|
||||
// Unlike soft-delete, Vault's destroy endpoint has no "current
|
||||
// version" shorthand — it always requires explicit version
|
||||
// numbers — so the version being viewed must be passed along.
|
||||
if m.secret != nil {
|
||||
versions = []int{m.secret.Version}
|
||||
}
|
||||
}
|
||||
|
||||
action := func() tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
ack, err := m.svc.KV.Delete(m.ctx, mount, path, op, versions)
|
||||
return deleteAckMsg{ack: ack, err: err}
|
||||
}
|
||||
}
|
||||
if !m.a.Settings.ConfirmDestructive {
|
||||
return m, action()
|
||||
}
|
||||
m.confirm = &confirmSpec{
|
||||
title: title, body: body, danger: danger,
|
||||
typeToConf: confirmTypedName(danger, path),
|
||||
onConfirm: action,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// secretMetaLine renders the version/status line shown between the title
|
||||
// and the key/value table.
|
||||
func (m *Model) secretMetaLine() string {
|
||||
if m.secret == nil || m.secret.Meta == nil {
|
||||
return ""
|
||||
}
|
||||
meta := m.secret.Meta
|
||||
parts := []string{fmt.Sprintf("version %d", m.secret.Version), "created " + humanTime(meta.CreatedTime)}
|
||||
if meta.Destroyed {
|
||||
parts = append(parts, m.styles.ErrorText.Render(symDestroyed+" destroyed"))
|
||||
} else if meta.DeletionTime != "" {
|
||||
parts = append(parts, m.styles.WarnText.Render(symDeleted+" deleted"))
|
||||
}
|
||||
return m.styles.PanelSubtle.Render(strings.Join(parts, " • "))
|
||||
}
|
||||
|
||||
// secretRows renders the key/value table fed to secretVP, one row per
|
||||
// sorted key with the selected row highlighted full-width.
|
||||
func (m *Model) secretRows(width int) string {
|
||||
if len(m.secretKeys) == 0 {
|
||||
return m.styles.EmptyState.Render("(no data)")
|
||||
}
|
||||
keyW := 0
|
||||
for _, k := range m.secretKeys {
|
||||
if w := lipgloss.Width(k); w > keyW {
|
||||
keyW = w
|
||||
}
|
||||
}
|
||||
if keyW > width/3 {
|
||||
keyW = width / 3
|
||||
}
|
||||
if keyW < 4 {
|
||||
keyW = 4
|
||||
}
|
||||
valW := width - keyW - 2
|
||||
if valW < 4 {
|
||||
valW = 4
|
||||
}
|
||||
|
||||
lines := make([]string, len(m.secretKeys))
|
||||
for i, k := range m.secretKeys {
|
||||
v := fmt.Sprint(m.secret.Data[k])
|
||||
if !m.secretMask[k] {
|
||||
v = maskValue(v, m.styles.MaskChar)
|
||||
}
|
||||
row := padRight(truncate(k, keyW), keyW) + " " + truncate(v, valW)
|
||||
if i == m.secretCursor {
|
||||
lines[i] = m.styles.RowSelected.Render(padRight(row, width))
|
||||
} else {
|
||||
lines[i] = m.styles.KeyCell.Render(padRight(truncate(k, keyW), keyW)) + " " + m.styles.ValueCell.Render(truncate(v, valW))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m *Model) viewSecret() string {
|
||||
f := m.currentFrame()
|
||||
ow, oh := m.lay.bodyW, m.lay.bodyH
|
||||
title := m.styles.PanelTitle.Render(f.mount.Path + f.path)
|
||||
|
||||
if m.loading || m.secret == nil {
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…")
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, body)
|
||||
}
|
||||
|
||||
iw, _ := panelInner(ow, oh)
|
||||
m.resizeSecretViewport()
|
||||
m.secretVP.SetContent(m.secretRows(iw))
|
||||
m.secretVP.EnsureVisible(m.secretCursor, 0, 0)
|
||||
|
||||
lines := []string{title}
|
||||
if meta := m.secretMetaLine(); meta != "" {
|
||||
lines = append(lines, meta)
|
||||
}
|
||||
lines = append(lines, m.secretVP.View())
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
Reference in New Issue
Block a user