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,394 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
)
|
||||
|
||||
// startAuth (re)enters the auth screen: build the method list (prioritising
|
||||
// the profile's configured default, if any) and reset to the picker phase.
|
||||
// Called from Init and again whenever a token turns out to be missing or
|
||||
// invalid (see tokenInfoMsg handling and ensureLoggedIn's own error path).
|
||||
func (m *Model) startAuth() tea.Cmd {
|
||||
methods := auth.Default().All()
|
||||
cursor := 0
|
||||
for i, meth := range methods {
|
||||
if meth.Name() == m.a.Settings.Auth.Method {
|
||||
cursor = i
|
||||
}
|
||||
}
|
||||
m.auth = authState{phase: 0, methods: methods, cursor: cursor}
|
||||
m.stack = nil
|
||||
|
||||
return m.tryExistingToken()
|
||||
}
|
||||
|
||||
// tryExistingToken attempts the flag/env/store token resolution the
|
||||
// headless CLI also uses (internal/cli.App.EnsureLoggedIn) before falling
|
||||
// back to an interactive login — this is what makes VAULT_TOKEN and a
|
||||
// pre-existing `vault login` session "just work" without visiting the auth
|
||||
// screen at all.
|
||||
func (m *Model) tryExistingToken() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, err := m.a.EnsureLoggedIn(m.ctx, "")
|
||||
if err != nil {
|
||||
return statusMsg{text: "no saved token — pick an auth method"}
|
||||
}
|
||||
return loginDoneMsg{result: &auth.Result{Token: m.a.Client.Token()}}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case loginDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.auth.err = msg.err
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
}
|
||||
if msg.result != nil && msg.result.Token != "" && m.a.Client.Token() == "" {
|
||||
m.a.Client.SetToken(msg.result.Token)
|
||||
}
|
||||
m.push(frame{scr: scrMounts})
|
||||
cmd := m.loadMounts()
|
||||
if msg.storeWarn != "" {
|
||||
cmd = tea.Batch(cmd, m.notify(toastWarn, "logged in, but could not save token: %s", msg.storeWarn))
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case loginEventMsg:
|
||||
switch msg.Kind {
|
||||
case auth.EventOpenURL:
|
||||
m.auth.authURL = msg.URL
|
||||
default:
|
||||
m.auth.status = append(m.auth.status, msg.Message)
|
||||
}
|
||||
return m, waitForAuthEvent(m.auth.events)
|
||||
}
|
||||
|
||||
switch m.auth.phase {
|
||||
case 0:
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
return m.updateAuthPicker(km)
|
||||
}
|
||||
case 1:
|
||||
return m.updateAuthForm(msg)
|
||||
case 2:
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
return m.updateAuthWaiting(km)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) updateAuthPicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.auth.cursor < len(m.auth.methods)-1 {
|
||||
m.auth.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.auth.cursor > 0 {
|
||||
m.auth.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
return m.chooseAuthMethod(m.auth.methods[m.auth.cursor])
|
||||
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.ForceQuit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) chooseAuthMethod(meth auth.Method) (tea.Model, tea.Cmd) {
|
||||
m.auth.method = meth
|
||||
m.auth.mount = m.a.Settings.Auth.Mount
|
||||
if m.auth.mount == "" || m.a.Settings.Auth.Method != meth.Name() {
|
||||
m.auth.mount = meth.DefaultMount()
|
||||
}
|
||||
|
||||
creds := auth.Prefill(meth, auth.PrefillSource{ConfigArgs: m.a.Settings.Auth.Params})
|
||||
if len(auth.Missing(meth, creds)) == 0 {
|
||||
return m.beginLogin(meth, creds)
|
||||
}
|
||||
|
||||
m.auth.inputs = nil
|
||||
for _, f := range meth.Fields() {
|
||||
ti := m.newTextInput(fieldSpec{label: f.Label, secret: f.Kind == auth.FieldSecret, value: creds.Get(f.Name)})
|
||||
m.auth.inputs = append(m.auth.inputs, textInputField{field: f, input: ti})
|
||||
}
|
||||
m.auth.focus = 0
|
||||
var cmd tea.Cmd
|
||||
if len(m.auth.inputs) > 0 {
|
||||
cmd = m.auth.inputs[0].input.Focus()
|
||||
}
|
||||
m.auth.phase = 1
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateAuthForm handles the credential-form phase. It only special-cases
|
||||
// key presses that are form navigation (esc/tab/enter); everything else —
|
||||
// including non-key messages like a textinput's own cursor-blink tick — is
|
||||
// forwarded straight to the focused field's Update.
|
||||
func (m *Model) updateAuthForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch km.String() {
|
||||
case "esc":
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
return m, m.moveAuthFocus(1)
|
||||
case "shift+tab", "up":
|
||||
return m, m.moveAuthFocus(-1)
|
||||
case "enter":
|
||||
if m.auth.focus < len(m.auth.inputs)-1 {
|
||||
return m, m.moveAuthFocus(1)
|
||||
}
|
||||
creds := auth.Credentials{}
|
||||
for _, f := range m.auth.inputs {
|
||||
creds[f.field.Name] = f.input.Value()
|
||||
}
|
||||
if err := auth.Validate(m.auth.method, creds); err != nil {
|
||||
m.auth.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m.beginLogin(m.auth.method, creds)
|
||||
}
|
||||
}
|
||||
if len(m.auth.inputs) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.auth.inputs[m.auth.focus].input, cmd = m.auth.inputs[m.auth.focus].input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) moveAuthFocus(delta int) tea.Cmd {
|
||||
if len(m.auth.inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m.auth.inputs[m.auth.focus].input.Blur()
|
||||
m.auth.focus = (m.auth.focus + delta + len(m.auth.inputs)) % len(m.auth.inputs)
|
||||
return m.auth.inputs[m.auth.focus].input.Focus()
|
||||
}
|
||||
|
||||
func (m *Model) beginLogin(meth auth.Method, creds auth.Credentials) (tea.Model, tea.Cmd) {
|
||||
m.auth.phase = 2
|
||||
m.auth.err = nil
|
||||
m.auth.status = nil
|
||||
m.auth.authURL = ""
|
||||
|
||||
ctx, cancel := context.WithCancel(m.ctx)
|
||||
m.auth.cancel = cancel
|
||||
events := make(chan auth.Event, 16)
|
||||
m.auth.events = events
|
||||
|
||||
req := auth.Request{Mount: m.auth.mount, Namespace: m.a.Settings.Namespace, Creds: creds, Events: events}
|
||||
run := func() tea.Msg {
|
||||
defer close(events)
|
||||
sec, err := meth.Login(ctx, m.a.Client, req)
|
||||
if err != nil {
|
||||
return loginDoneMsg{err: err}
|
||||
}
|
||||
res, err := auth.NewResult(sec, m.a.Settings.Namespace)
|
||||
if err != nil {
|
||||
return loginDoneMsg{err: err}
|
||||
}
|
||||
if serr := m.a.Store.Store(ctx, res.Token); serr != nil {
|
||||
return loginDoneMsg{result: res, storeWarn: serr.Error()}
|
||||
}
|
||||
return loginDoneMsg{result: res}
|
||||
}
|
||||
return m, tea.Batch(run, waitForAuthEvent(events))
|
||||
}
|
||||
|
||||
func waitForAuthEvent(events <-chan auth.Event) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
e, ok := <-events
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return loginEventMsg(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateAuthWaiting(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.auth.cancel != nil {
|
||||
m.auth.cancel()
|
||||
}
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
case "y":
|
||||
return m, copyToClipboard(m.auth.authURL)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func copyToClipboard(s string) tea.Cmd {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return tea.SetClipboard(s)
|
||||
}
|
||||
|
||||
// authCardWidth is the inner content width every auth-phase card wraps
|
||||
// text to, capped so the card never dominates a very wide terminal.
|
||||
func (m *Model) authCardWidth() int {
|
||||
w := m.lay.bodyW - 12
|
||||
if w > 56 {
|
||||
w = 56
|
||||
}
|
||||
if w < 24 {
|
||||
w = 24
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// authCard centers content (already wrapped to authCardWidth) in a bordered
|
||||
// card over the auth screen's body area — the "modern app" look asked for,
|
||||
// replacing the old flush-left, unbounded-width panel.
|
||||
func (m *Model) authCard(content string) string {
|
||||
box := m.styles.PanelActive.Render(content)
|
||||
return lipgloss.Place(m.lay.bodyW, m.lay.bodyH, lipgloss.Center, lipgloss.Center, box,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg)))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuth() string {
|
||||
switch m.auth.phase {
|
||||
case 1:
|
||||
return m.viewAuthForm()
|
||||
case 2:
|
||||
return m.viewAuthWaiting()
|
||||
default:
|
||||
return m.viewAuthPicker()
|
||||
}
|
||||
}
|
||||
|
||||
// methodDescription returns a one-line hint for a method: its
|
||||
// auth.Describable text if implemented, else a small built-in fallback so
|
||||
// the picker never shows a bare name with nothing else to go on.
|
||||
func methodDescription(meth auth.Method) string {
|
||||
if d, ok := meth.(auth.Describable); ok {
|
||||
return d.Description()
|
||||
}
|
||||
switch meth.Name() {
|
||||
case "token":
|
||||
return "use an existing Vault token"
|
||||
case "oidc":
|
||||
return "sign in via your browser"
|
||||
case "userpass":
|
||||
return "username and password"
|
||||
case "ldap":
|
||||
return "LDAP directory credentials"
|
||||
case "okta":
|
||||
return "Okta username/password, with MFA"
|
||||
case "radius":
|
||||
return "RADIUS username/password"
|
||||
case "approle":
|
||||
return "role ID and secret ID"
|
||||
case "github":
|
||||
return "a GitHub personal access token"
|
||||
case "jwt":
|
||||
return "a role and a signed JWT"
|
||||
case "kubernetes":
|
||||
return "the pod's projected service-account token"
|
||||
case "cert":
|
||||
return "a TLS client certificate"
|
||||
case "aws", "azure", "gcp":
|
||||
return "cloud-native machine identity"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthPicker() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render("Sign in to Vault"), ""}
|
||||
for i, meth := range m.auth.methods {
|
||||
name := meth.DisplayName()
|
||||
desc := methodDescription(meth)
|
||||
if i == m.auth.cursor {
|
||||
lines = append(lines, m.styles.RowSelected.Render(padRight(symChevronR+" "+name, w)))
|
||||
} else {
|
||||
line := " " + name
|
||||
if desc != "" {
|
||||
line = padRight(line, 18) + m.styles.PanelSubtle.Render(desc)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if unavail := auth.Default().UnavailableAll(); len(unavail) > 0 {
|
||||
names := make([]string, 0, len(unavail))
|
||||
for n := range unavail {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
lines = append(lines, "")
|
||||
for _, n := range names {
|
||||
lines = append(lines, m.styles.RowDim.Render(fmt.Sprintf(" %s — %s", n, unavail[n])))
|
||||
}
|
||||
}
|
||||
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")))
|
||||
}
|
||||
lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter select • q quit"))
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthForm() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render(m.auth.method.DisplayName()), ""}
|
||||
for i, f := range m.auth.inputs {
|
||||
label := f.field.Label
|
||||
if f.field.Required {
|
||||
label += " *"
|
||||
}
|
||||
if i == m.auth.focus {
|
||||
label = m.styles.CrumbActive.Render(symChevronR + " " + label)
|
||||
} else {
|
||||
label = " " + label
|
||||
}
|
||||
f.input.SetWidth(w - 2)
|
||||
lines = append(lines, label, " "+f.input.View())
|
||||
if f.field.Help != "" {
|
||||
lines = append(lines, m.styles.EmptyState.Render(" "+f.field.Help))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")), "")
|
||||
}
|
||||
lines = append(lines, m.styles.Help.Render("tab/shift+tab move • enter next/submit • esc back"))
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthWaiting() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render("Signing in via " + m.auth.method.DisplayName()), ""}
|
||||
if m.auth.authURL != "" {
|
||||
lines = append(lines,
|
||||
"Open this URL if your browser didn't launch automatically:", "",
|
||||
m.styles.InputFocused.Render(lipgloss.Wrap(m.auth.authURL, w, "")), "",
|
||||
m.styles.Help.Render("y copy URL • esc cancel"))
|
||||
} else {
|
||||
lines = append(lines, m.spin.View()+" waiting…")
|
||||
}
|
||||
for _, s := range m.auth.status {
|
||||
lines = append(lines, m.styles.PanelSubtle.Render(lipgloss.Wrap(s, w, "")))
|
||||
}
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")))
|
||||
}
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
Reference in New Issue
Block a user