Files
vault-tui/internal/ui/screen_secret.go
T
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

295 lines
7.7 KiB
Go

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