- 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.
342 lines
7.6 KiB
Go
342 lines
7.6 KiB
Go
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}
|
|
}
|
|
}
|