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,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}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user