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:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
package ui
import (
"context"
"time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.morlana.online/f.weber/vault-tui/internal/auth"
"git.morlana.online/f.weber/vault-tui/internal/cli"
"git.morlana.online/f.weber/vault-tui/internal/token"
"git.morlana.online/f.weber/vault-tui/internal/ui/keys"
"git.morlana.online/f.weber/vault-tui/internal/ui/theme"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
type screen int
const (
scrAuth screen = iota
scrMounts
scrBrowser
scrSecret
scrEditor
scrVersions
)
// frame is one entry in the navigation stack. Not every field is used by
// every screen kind; see the per-screen handlers in the other files in
// this package.
type frame struct {
scr screen
mount vault.Mount
path string // relative path: browser's current dir, or the secret path
}
// confirmSpec describes a pending destructive action, rendered as a
// centered overlay (see overlay.go) rather than appended below the
// current screen.
type confirmSpec struct {
title string
body string
danger bool
typeToConf string // if non-empty, the user must type this text to confirm
input string
onConfirm func() tea.Cmd
}
// authState holds everything the auth screen needs across its three
// sub-phases (method picker -> credential form -> waiting on a
// multi-step login such as OIDC or Okta).
type authState struct {
phase int // 0=picker 1=form 2=waiting
methods []auth.Method
cursor int
method auth.Method
mount string
inputs []textInputField
focus int
events chan auth.Event
cancel context.CancelFunc
authURL string
status []string
err error
}
type textInputField struct {
field auth.Field
input textInput
}
// profileState drives the profile-switch overlay (screen_profile.go).
type profileState struct {
open bool
names []string
cursor int
}
type Model struct {
ctx context.Context
a *cli.App
svc *vault.Service
styles *theme.Styles
keys *keys.KeyMap
help help.Model
spin spinner.Model
lay layout
loading bool
toast *toast
toastSeq int
stack []frame
auth authState
list list.Model
listReady bool
listSelect string // remembered selected item's label, restored after a reload
secret *vault.Secret
secretMask map[string]bool
secretKeys []string // sorted cache of secret.Data's keys, rebuilt on load
secretCursor int
secretVP viewport.Model
clipSeq int
editName textInput
editKeys []textInput
editVals []textInput
editFocus int
editIsNew bool
editMount vault.Mount
editPath string
editSecret *vault.Secret
editSnap string // serialized snapshot at open, for the unsaved-changes guard
editConfirm bool // pending "discard unsaved changes?" on esc
versions []vault.VersionMeta
versionsCursor int
confirm *confirmSpec
profile profileState
helpOpen bool
tokenInfo *token.Info
quitting bool
}
func newModel(ctx context.Context, a *cli.App) (*Model, error) {
isDark := theme.IsDark(a.File.Theme, a.Settings.Appearance)
mono := theme.NoColor(a.Settings.NoColor)
styles := theme.Build(a.File.Theme, isDark, mono)
km := keys.Default()
if err := km.Apply(a.File.Keys); err != nil {
return nil, err
}
if a.Settings.ReadOnly {
km.DisableWrites()
}
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
hp := help.New()
hp.Styles = styles.HelpStyles()
m := &Model{
ctx: ctx,
a: a,
svc: a.Service(),
styles: styles,
keys: km,
help: hp,
spin: sp,
secretMask: map[string]bool{},
}
return m, nil
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(m.startAuth(), m.spin.Tick, tickCmd())
}
func tickCmd() tea.Cmd {
return tea.Tick(30*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m *Model) top() screen {
if len(m.stack) == 0 {
return scrAuth
}
return m.stack[len(m.stack)-1].scr
}
func (m *Model) push(f frame) { m.stack = append(m.stack, f) }
func (m *Model) pop() (frame, bool) {
if len(m.stack) == 0 {
return frame{}, false
}
f := m.stack[len(m.stack)-1]
m.stack = m.stack[:len(m.stack)-1]
return f, true
}
func (m *Model) currentFrame() frame {
if len(m.stack) == 0 {
return frame{scr: scrMounts}
}
return m.stack[len(m.stack)-1]
}
// overlayActive reports whether anything should render on top of the
// current screen instead of routing input to it.
func (m *Model) overlayActive() bool {
return m.confirm != nil || m.profile.open || m.helpOpen
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.lay = computeLayout(msg.Width, msg.Height)
m.help.SetWidth(m.lay.w - 2)
m.resizeList()
m.resizeSecretViewport()
return m, nil
case tea.KeyPressMsg:
if key.Matches(msg, m.keys.ForceQuit) {
m.quitting = true
return m, tea.Quit
}
if m.confirm != nil {
return m.updateConfirm(msg)
}
if m.profile.open {
return m.updateProfilePicker(msg)
}
if m.helpOpen {
return m.updateHelp(msg)
}
if !m.textInputActive() {
if key.Matches(msg, m.keys.Help) {
m.helpOpen = true
return m, nil
}
if key.Matches(msg, m.keys.Profiles) && m.top() != scrAuth && len(m.a.File.Profiles) > 1 {
return m.openProfilePicker()
}
if key.Matches(msg, m.keys.Logout) && m.top() != scrAuth {
return m.confirmLogout()
}
}
case tickMsg:
cmds := []tea.Cmd{tickCmd()}
if m.a.Client.Token() != "" {
cmds = append(cmds, m.refreshTokenInfo())
}
return m, tea.Batch(cmds...)
case tokenInfoMsg:
if msg.err == nil {
m.tokenInfo = msg.info
}
return m, nil
case toastExpireMsg:
m.clearExpiredToast(msg)
return m, nil
case logoutDoneMsg:
if msg.err != nil {
return m, m.notify(toastErr, "logout: %v", msg.err)
}
m.a.Client.ClearToken()
m.tokenInfo = nil
return m, tea.Batch(m.notify(toastSuccess, "logged out"), m.startAuth())
case errMsg:
m.loading = false
return m, m.notify(toastErr, "%v", msg.err)
case statusMsg:
return m, m.notify(toastInfo, "%s", msg.text)
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
switch m.top() {
case scrAuth:
return m.updateAuth(msg)
case scrMounts, scrBrowser:
return m.updateBrowser(msg)
case scrSecret:
return m.updateSecret(msg)
case scrEditor:
return m.updateEditor(msg)
case scrVersions:
return m.updateVersions(msg)
}
return m, nil
}
func (m *Model) View() tea.View {
if m.lay.tooSmall {
v := tea.NewView(m.viewTooSmall())
v.AltScreen = true
v.WindowTitle = "vault-tui"
return v
}
var body string
var sh screenHelp
switch m.top() {
case scrAuth:
body, sh = m.viewAuth(), m.keys.AuthHelp()
case scrMounts, scrBrowser:
body, sh = m.viewBrowser(), m.keys.BrowserHelp()
case scrSecret:
body, sh = m.viewSecret(), m.keys.SecretHelp()
case scrEditor:
body, sh = m.viewEditor(), m.keys.EditorHelp()
case scrVersions:
body, sh = m.viewVersions(), m.keys.VersionsHelp()
}
content := lipgloss.JoinVertical(lipgloss.Left, m.viewHeader(), fit(body, m.lay.w, m.lay.bodyH), m.viewFooter(sh))
if m.confirm != nil {
content = m.overlay(content, m.viewConfirm())
}
if m.profile.open {
content = m.overlay(content, m.viewProfilePicker())
}
if m.helpOpen {
content = m.overlay(content, m.viewHelp())
}
v := tea.NewView(content)
v.AltScreen = true
v.WindowTitle = "vault-tui — " + m.a.Settings.Profile
return v
}
func (m *Model) refreshTokenInfo() tea.Cmd {
return func() tea.Msg {
info, err := token.Lookup(m.ctx, m.a.Client)
return tokenInfoMsg{info: info, err: err}
}
}