On an Azure account's group row, the Details pane now describes the account: | field | from | |---|---| | name | token `name` | | user (and "service principal") | az's account list | | tenant: name · ID | az's account list (the name is borrowed from another entry in the same tenant when az left it out) | | object ID | token `oid`; `y` on the row copies it | | sign-in: MFA or not, and how | token `amr` | | directory roles | token `wids`: built-in roles by name, others as `role 1a2b3c4d`, the default member role left out | | subs: count · default | az's account list | | cloud | az's account list, when not AzureCloud | Nothing is fetched for it: the claims are the ones from the token lazysecrets already gets for the account's own tenant, read without verification. The token itself is never stored or shown. Before the first token, only the az-list fields show. A failing account shows its sign-in problem there instead (from #4). New optional `provider.Accounts` interface; `azure.Provider` implements it. Tests: the fields from claims and az's list, sign-in methods, the UI showing it on the group row only, and `y` copying the object ID. Checked against a real signed-in account (name, MFA · password, default subscription) and an expired one (az-list fields only, since there's no token). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com> Reviewed-on: #5
397 lines
12 KiB
Go
397 lines
12 KiB
Go
package panes
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
|
)
|
|
|
|
type ValueState int
|
|
|
|
const (
|
|
ValueHidden ValueState = iota
|
|
ValueLoading
|
|
ValueShown
|
|
ValueFailed
|
|
)
|
|
|
|
// Detail shows metadata for the selected secret (or container) and, on
|
|
// request, its value.
|
|
type Detail struct {
|
|
Now func() time.Time
|
|
Icons icons.Set
|
|
// RevealKey and VersionsKey are the keys hints mention ("space to
|
|
// reveal"); a hint is left out when its key is "" (unbound).
|
|
RevealKey, VersionsKey string
|
|
|
|
secret *provider.Object
|
|
container *provider.Container
|
|
|
|
state ValueState
|
|
value provider.SecretValue
|
|
err error
|
|
hidesIn string // when a shown value hides by itself, if it does
|
|
|
|
describing bool
|
|
describeErr error
|
|
|
|
versions []provider.Object // newest first; nil while unknown
|
|
note string // the user's note on what's shown
|
|
message string // shown instead of the hint when nothing is selected: an error
|
|
account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as
|
|
|
|
offset int
|
|
w, h int
|
|
}
|
|
|
|
func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} }
|
|
|
|
// ShowSecret selects a secret and forgets any previously fetched value.
|
|
func (d *Detail) ShowSecret(s provider.Object) {
|
|
d.Clear()
|
|
d.secret = &s
|
|
d.container = &s.Container
|
|
}
|
|
|
|
// ShowContainer shows a container summary when no secret is selected.
|
|
func (d *Detail) ShowContainer(c provider.Container) {
|
|
d.Clear()
|
|
d.container = &c
|
|
}
|
|
|
|
// Clear drops the selection and any fetched value.
|
|
func (d *Detail) Clear() {
|
|
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
|
|
}
|
|
|
|
// SetMessage sets what's shown instead of the hint when nothing is
|
|
// selected, such as why an account has nothing to show; "" for the hint.
|
|
func (d *Detail) SetMessage(s string) { d.message = s }
|
|
|
|
// SetAccount sets the account shown on a provider's row when nothing is
|
|
// selected; nil for the hint.
|
|
func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields }
|
|
|
|
// SetDescribing marks extra metadata as being fetched.
|
|
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
|
|
|
// SetDescribed replaces the shown object's metadata, keeping the value
|
|
// state. It ignores objects other than the one shown.
|
|
func (d *Detail) SetDescribed(o provider.Object) {
|
|
if d.secret == nil || d.secret.ID != o.ID {
|
|
return
|
|
}
|
|
// Keep the container (and so the account) the object was shown with;
|
|
// value requests are routed by it.
|
|
o.Container = d.secret.Container
|
|
d.secret = &o
|
|
d.describing = false
|
|
}
|
|
|
|
// SetNote shows the user's note on the shown object or container.
|
|
func (d *Detail) SetNote(note string) { d.note = note }
|
|
|
|
// SetVersions records the shown object's versions, newest first. It
|
|
// ignores objects other than the one shown.
|
|
func (d *Detail) SetVersions(id string, versions []provider.Object) {
|
|
if d.secret != nil && d.secret.ID == id {
|
|
d.versions = versions
|
|
}
|
|
}
|
|
|
|
func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err }
|
|
|
|
func (d Detail) Secret() (provider.Object, bool) {
|
|
if d.secret == nil {
|
|
return provider.Object{}, false
|
|
}
|
|
return *d.secret, true
|
|
}
|
|
|
|
// Container is the container shown, if any.
|
|
func (d Detail) Container() (provider.Container, bool) {
|
|
if d.container == nil {
|
|
return provider.Container{}, false
|
|
}
|
|
return *d.container, true
|
|
}
|
|
|
|
// Value returns the value if it is shown.
|
|
func (d Detail) Value() (provider.SecretValue, bool) {
|
|
return d.value, d.state == ValueShown
|
|
}
|
|
|
|
func (d Detail) State() ValueState { return d.state }
|
|
|
|
func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil }
|
|
|
|
func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil }
|
|
|
|
func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err }
|
|
|
|
// Hide masks the value again and drops it from memory.
|
|
func (d *Detail) Hide() {
|
|
d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, ""
|
|
}
|
|
|
|
// SetHidesIn shows when a revealed value hides by itself, e.g. "42s".
|
|
func (d *Detail) SetHidesIn(s string) { d.hidesIn = s }
|
|
|
|
func (d *Detail) SetSize(w, h int) {
|
|
d.w, d.h = w, h
|
|
d.Scroll(0)
|
|
}
|
|
|
|
func (d *Detail) Scroll(delta int) {
|
|
total := len(d.lines(styles.Styles{}))
|
|
d.offset = max(0, min(d.offset+delta, total-(d.h-2)))
|
|
}
|
|
|
|
func (d *Detail) ScrollTop() { d.offset = 0 }
|
|
func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) }
|
|
|
|
func (d Detail) View(st styles.Styles, focused bool, spin string) string {
|
|
lines := d.lines(st)
|
|
if spin != "" && d.state == ValueLoading {
|
|
for i, l := range lines {
|
|
lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1)
|
|
}
|
|
}
|
|
rows := max(0, d.h-2)
|
|
footer := ""
|
|
if len(lines) > rows {
|
|
footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines))
|
|
}
|
|
title := "Details"
|
|
if d.secret != nil {
|
|
title = string(d.secret.Type)
|
|
}
|
|
return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused)
|
|
}
|
|
|
|
const spinnerPlaceholder = "\x00spin\x00"
|
|
|
|
// lines renders the body, wrapped to the pane width.
|
|
func (d Detail) lines(st styles.Styles) []string {
|
|
inner := max(1, d.w-3)
|
|
var out []string
|
|
field := func(label, value string) {
|
|
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
|
|
indent := strings.Repeat(" ", 12)
|
|
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
|
|
out = append(out, prefix+wrapped[0])
|
|
for _, w := range wrapped[1:] {
|
|
out = append(out, indent+w)
|
|
}
|
|
}
|
|
muted := st.Muted.Render
|
|
|
|
if d.container == nil {
|
|
if d.message == "" && len(d.account) > 0 {
|
|
for _, a := range d.account {
|
|
field(a.Label, a.Value)
|
|
}
|
|
return out
|
|
}
|
|
if d.message != "" {
|
|
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
|
|
out = append(out, " "+st.Error.Render(l))
|
|
}
|
|
return out
|
|
}
|
|
return []string{muted(" Select a vault or scope to browse its secrets.")}
|
|
}
|
|
c := *d.container
|
|
containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind]
|
|
if containerLabel == "" {
|
|
containerLabel = "vault"
|
|
}
|
|
|
|
if d.secret == nil {
|
|
field(containerLabel, c.Name)
|
|
if d.note != "" {
|
|
field("note", st.Favourite.Render(d.note))
|
|
}
|
|
if c.Detail != "" {
|
|
field("", muted(c.Detail))
|
|
}
|
|
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
|
out = append(out, "", muted(" Select a secret to see its details."))
|
|
return out
|
|
}
|
|
|
|
s := *d.secret
|
|
field("name", s.Name)
|
|
if d.note != "" {
|
|
field("note", st.Favourite.Render(d.note))
|
|
}
|
|
field(containerLabel, c.Name)
|
|
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
|
|
field("", muted(c.Detail))
|
|
}
|
|
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
|
if s.Version != "" {
|
|
field("version", s.Version)
|
|
}
|
|
|
|
if s.Enabled != nil {
|
|
if *s.Enabled {
|
|
field("enabled", "yes")
|
|
} else {
|
|
field("enabled", st.Error.Render("no"))
|
|
}
|
|
}
|
|
if !s.Updated.IsZero() {
|
|
field("updated", formatTime(s.Updated))
|
|
}
|
|
if !s.Expires.IsZero() {
|
|
exp := formatTime(s.Expires)
|
|
if s.Expires.Before(d.Now()) {
|
|
exp = st.Error.Render(exp + " (expired)")
|
|
}
|
|
field("expires", exp)
|
|
}
|
|
if s.ContentType != "" {
|
|
field("content", s.ContentType)
|
|
}
|
|
for _, a := range s.Attrs {
|
|
field(a.Label, a.Value)
|
|
}
|
|
if len(s.Tags) > 0 {
|
|
var tags []string
|
|
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
|
|
tags = append(tags, k+"="+s.Tags[k])
|
|
}
|
|
field("tags", strings.Join(tags, ", "))
|
|
}
|
|
if s.Reference != "" {
|
|
field("ref", muted(s.Reference))
|
|
}
|
|
switch {
|
|
case d.describing:
|
|
out = append(out, muted(" loading details…"))
|
|
case d.describeErr != nil:
|
|
field("details", st.Error.Render(d.describeErr.Error()))
|
|
}
|
|
|
|
// Secrets are masked; keys and certificates only expose public material.
|
|
keyHint := func(k, what string) string {
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
return "(" + k + " to " + what + ")"
|
|
}
|
|
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
|
|
switch s.Type {
|
|
case provider.TypeKey:
|
|
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
|
|
case provider.TypeCertificate:
|
|
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
|
|
}
|
|
|
|
out = append(out, "")
|
|
switch d.state {
|
|
case ValueHidden:
|
|
field(label, hidden)
|
|
case ValueLoading:
|
|
field(label, spinnerPlaceholder+" fetching…")
|
|
case ValueFailed:
|
|
// Errors can run over several lines (sops lists every key it
|
|
// tried): all of them, each wrapped at spaces and styled alone,
|
|
// so lines aren't padded to the longest.
|
|
for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") {
|
|
// Keep a line's own indent, on each part it wraps into.
|
|
text := strings.TrimLeft(line, " ")
|
|
lead := strings.Repeat(" ", len(line)-len(text))
|
|
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
|
|
w = lead + w
|
|
if i == 0 && j == 0 {
|
|
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
|
|
} else {
|
|
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
|
|
}
|
|
}
|
|
}
|
|
case ValueShown:
|
|
var hints []string
|
|
if d.RevealKey != "" {
|
|
hints = append(hints, d.RevealKey+" to hide")
|
|
}
|
|
if d.hidesIn != "" {
|
|
hints = append(hints, "hides in "+d.hidesIn)
|
|
}
|
|
line := " " + st.Label.Render(label)
|
|
if len(hints) > 0 {
|
|
line += muted(" (" + strings.Join(hints, " · ") + ")")
|
|
}
|
|
out = append(out, line)
|
|
for _, line := range strings.Split(d.value.Reveal(), "\n") {
|
|
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
|
|
out = append(out, " "+st.Value.Render(w))
|
|
}
|
|
}
|
|
}
|
|
return append(out, d.versionLines(st)...)
|
|
}
|
|
|
|
// maxVersionLines caps the version history shown in the detail pane; the
|
|
// versions view (v) shows them all.
|
|
const maxVersionLines = 10
|
|
|
|
func (d Detail) versionLines(st styles.Styles) []string {
|
|
if len(d.versions) == 0 {
|
|
return nil
|
|
}
|
|
muted := st.Muted.Render
|
|
head := fmt.Sprintf("%d", len(d.versions))
|
|
if len(d.versions) > 1 {
|
|
head = st.Notice.Render(head)
|
|
if d.VersionsKey != "" {
|
|
head += muted(" · " + d.VersionsKey + " to browse")
|
|
}
|
|
}
|
|
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
|
|
current := d.versions[0].Version
|
|
for i, v := range d.versions {
|
|
if i == maxVersionLines {
|
|
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
|
break
|
|
}
|
|
marker := " "
|
|
if d.secret.Version != "" && v.Version == d.secret.Version {
|
|
marker = "▸ " // the version shown above
|
|
}
|
|
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// VersionLine draws a version as "id date state".
|
|
func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string {
|
|
id := v.Version
|
|
if len(id) > 8 {
|
|
id = id[:8]
|
|
}
|
|
line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02"))
|
|
switch {
|
|
case v.Enabled != nil && !*v.Enabled:
|
|
return st.Muted.Render(line + " disabled")
|
|
case !v.Expires.IsZero() && v.Expires.Before(now):
|
|
return st.Muted.Render(line + " expired")
|
|
case v.Version == current:
|
|
return line + st.Notice.Render(" current")
|
|
}
|
|
return line
|
|
}
|
|
|
|
func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
|