- 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.
295 lines
7.7 KiB
Go
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...))
|
|
}
|