- 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.
223 lines
6.0 KiB
Go
223 lines
6.0 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"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) loadVersions(mount vault.Mount, path string) tea.Cmd {
|
|
m.loading = true
|
|
return func() tea.Msg {
|
|
vs, err := m.svc.KV.Versions(m.ctx, mount, path)
|
|
return versionsMsg{versions: vs, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) openVersions() (tea.Model, tea.Cmd) {
|
|
f := m.currentFrame()
|
|
if f.mount.Kind != vault.EngineKVv2 {
|
|
return m, m.notify(toastWarn, "version history is only available for KV v2")
|
|
}
|
|
m.versionsCursor = 0
|
|
m.push(frame{scr: scrVersions, mount: f.mount, path: f.path})
|
|
return m, m.loadVersions(f.mount, f.path)
|
|
}
|
|
|
|
func (m *Model) updateVersions(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case versionsMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
|
}
|
|
m.versions = msg.versions
|
|
if m.versionsCursor >= len(m.versions) {
|
|
m.versionsCursor = 0
|
|
}
|
|
return m, nil
|
|
|
|
case rollbackAckMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
|
}
|
|
f := m.currentFrame()
|
|
return m, tea.Batch(
|
|
m.notify(toastSuccess, "rolled back — created version %d", msg.ack.Version),
|
|
m.loadVersions(f.mount, f.path),
|
|
)
|
|
|
|
case undeleteAckMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
|
}
|
|
f := m.currentFrame()
|
|
return m, tea.Batch(m.notify(toastSuccess, "undeleted"), m.loadVersions(f.mount, f.path))
|
|
|
|
case tea.KeyPressMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Back):
|
|
m.pop()
|
|
return m, m.reloadSecretAfterVersions()
|
|
case key.Matches(msg, m.keys.Quit):
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case key.Matches(msg, m.keys.Up):
|
|
if m.versionsCursor > 0 {
|
|
m.versionsCursor--
|
|
}
|
|
case key.Matches(msg, m.keys.Down):
|
|
if m.versionsCursor < len(m.versions)-1 {
|
|
m.versionsCursor++
|
|
}
|
|
case key.Matches(msg, m.keys.Enter):
|
|
return m.viewSelectedVersion()
|
|
case key.Matches(msg, m.keys.Rollback):
|
|
if !m.a.Settings.ReadOnly {
|
|
return m.confirmRollback()
|
|
}
|
|
case key.Matches(msg, m.keys.Undelete):
|
|
if !m.a.Settings.ReadOnly {
|
|
return m.confirmUndelete()
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) reloadSecretAfterVersions() tea.Cmd {
|
|
f := m.currentFrame()
|
|
if f.scr == scrSecret {
|
|
return m.loadSecret(f.mount, f.path, 0)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) viewSelectedVersion() (tea.Model, tea.Cmd) {
|
|
if len(m.versions) == 0 {
|
|
return m, nil
|
|
}
|
|
v := m.versions[m.versionsCursor]
|
|
f := m.currentFrame()
|
|
m.push(frame{scr: scrSecret, mount: f.mount, path: f.path})
|
|
return m, m.loadSecret(f.mount, f.path, v.Version)
|
|
}
|
|
|
|
func (m *Model) confirmRollback() (tea.Model, tea.Cmd) {
|
|
if len(m.versions) == 0 {
|
|
return m, nil
|
|
}
|
|
v := m.versions[m.versionsCursor]
|
|
f := m.currentFrame()
|
|
mount, path := f.mount, f.path
|
|
action := func() tea.Cmd {
|
|
m.loading = true
|
|
return func() tea.Msg {
|
|
ack, err := m.svc.KV.Rollback(m.ctx, mount, path, v.Version)
|
|
return rollbackAckMsg{ack: ack, err: err}
|
|
}
|
|
}
|
|
if !m.a.Settings.ConfirmDestructive {
|
|
return m, action()
|
|
}
|
|
m.confirm = &confirmSpec{
|
|
title: fmt.Sprintf("Roll back to version %d?", v.Version),
|
|
body: "Creates a new version with version " + strconv.Itoa(v.Version) + "'s data. No existing version is deleted.",
|
|
onConfirm: action,
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) confirmUndelete() (tea.Model, tea.Cmd) {
|
|
if len(m.versions) == 0 {
|
|
return m, nil
|
|
}
|
|
v := m.versions[m.versionsCursor]
|
|
if v.DeletionTime == "" && !v.Destroyed {
|
|
return m, m.notify(toastInfo, "version %d is not deleted", v.Version)
|
|
}
|
|
if v.Destroyed {
|
|
return m, m.notify(toastWarn, "version %d was destroyed — it cannot be undeleted", v.Version)
|
|
}
|
|
f := m.currentFrame()
|
|
mount, path := f.mount, f.path
|
|
action := func() tea.Cmd {
|
|
m.loading = true
|
|
return func() tea.Msg {
|
|
ack, err := m.svc.KV.Delete(m.ctx, mount, path, vault.OpUndelete, []int{v.Version})
|
|
return undeleteAckMsg{ack: ack, err: err}
|
|
}
|
|
}
|
|
if !m.a.Settings.ConfirmDestructive {
|
|
return m, action()
|
|
}
|
|
m.confirm = &confirmSpec{
|
|
title: fmt.Sprintf("Undelete version %d?", v.Version),
|
|
body: "Restores this version so it can be read again.",
|
|
onConfirm: action,
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) viewVersions() string {
|
|
f := m.currentFrame()
|
|
ow, oh := m.lay.bodyW, m.lay.bodyH
|
|
iw, ih := panelInner(ow, oh)
|
|
title := m.styles.PanelTitle.Render("Versions of " + f.mount.Path + f.path)
|
|
|
|
if m.loading {
|
|
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…"))
|
|
}
|
|
if len(m.versions) == 0 {
|
|
return renderPanel(m.styles.PanelActive, ow, oh,
|
|
lipgloss.JoinVertical(lipgloss.Left, title, "", m.styles.EmptyState.Render("no version history")))
|
|
}
|
|
|
|
header := m.styles.TableHeader.Render(padRight("", 3) + padRight("version", 10) + padRight("created", 22) + "status")
|
|
rowsH := ih - 2 // title + header
|
|
if rowsH < 1 {
|
|
rowsH = 1
|
|
}
|
|
top := m.versionsCursor - rowsH/2
|
|
if top < 0 {
|
|
top = 0
|
|
}
|
|
if top > len(m.versions)-rowsH {
|
|
top = len(m.versions) - rowsH
|
|
}
|
|
if top < 0 {
|
|
top = 0
|
|
}
|
|
bottom := min(top+rowsH, len(m.versions))
|
|
|
|
rows := make([]string, 0, bottom-top)
|
|
for i := top; i < bottom; i++ {
|
|
v := m.versions[i]
|
|
state := m.styles.SuccessText.Render(symOK + " active")
|
|
if v.Destroyed {
|
|
state = m.styles.ErrorText.Render(symDestroyed + " destroyed")
|
|
} else if v.DeletionTime != "" {
|
|
state = m.styles.WarnText.Render(symDeleted + " deleted")
|
|
}
|
|
marker := " "
|
|
if i == 0 {
|
|
marker = m.styles.SuccessText.Render(symCurrent) + " "
|
|
}
|
|
line := marker + padRight(fmt.Sprintf("v%d", v.Version), 9) + padRight(humanTime(v.CreatedTime), 22) + state
|
|
if i == m.versionsCursor {
|
|
line = m.styles.RowSelected.Render(padRight(line, iw))
|
|
}
|
|
rows = append(rows, line)
|
|
}
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, title, header, lipgloss.JoinVertical(lipgloss.Left, rows...))
|
|
return renderPanel(m.styles.PanelActive, ow, oh, body)
|
|
}
|