From b37caa5a255f36a6b74c3ca0ec2a9c0d801e50c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:17:22 +0200 Subject: [PATCH 1/5] feat: an account pane on Azure account rows: fields and groups to move through and copy Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 13 ++-- docs/keys.md | 2 +- internal/provider/azure/account.go | 70 +++++++++++++++++++ internal/provider/azure/account_test.go | 47 +++++++++++++ internal/provider/azure/azure.go | 12 ++-- internal/provider/provider.go | 7 ++ internal/ui/app/app.go | 32 +++++++-- internal/ui/app/app_test.go | 38 +++++++++++ internal/ui/app/help.go | 5 ++ internal/ui/app/layout.go | 8 ++- internal/ui/app/source_detail.go | 90 +++++++++++++++++++++++++ internal/ui/panes/detail.go | 11 --- internal/ui/panes/source_detail.go | 88 ++++++++++++++++++++++++ 13 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 internal/ui/app/source_detail.go create mode 100644 internal/ui/panes/source_detail.go diff --git a/docs/azure.md b/docs/azure.md index 75bc2b4..f82dfa7 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -13,12 +13,15 @@ is listed under each, since their permissions can differ. A service principal is used instead when `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and a secret or certificate are set in the environment. -On an account's group row, the Details pane shows who it signs in as: name, +On an account's group row, the fourth pane shows who it signs in as: name, user, tenant, object ID, whether the session used MFA, directory roles -(built-in ones by name) and its subscriptions. It's read from az's account -list and the token lazysecrets already has, so nothing is fetched for it. -`y` there copies the object ID, the one access policies and role -assignments ask for. +(built-in ones by name) and its subscriptions, read from az's account list +and the token lazysecrets already has. Below them are the groups the +account is in, nested ones too: those come from Microsoft Graph, once a +session, the first time the pane shows them. `y` on the row copies the +object ID, the one access policies and role assignments ask for; `tab` +goes into the pane, where `j`/`k` pick any field or group and `y` copies +it. If an account's az login has expired, the status line says so once, and the Details pane on that account's rows says it until you sign in: press `L` diff --git a/docs/keys.md b/docs/keys.md index 5bf842f..2058e77 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -48,7 +48,7 @@ match case exactly. | Key | Action | |---|---| | `space` | reveal / hide the selected value (public material for keys and certificates) | -| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index 7371203..a995453 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -1,10 +1,15 @@ package azure import ( + "context" + "encoding/json" "fmt" + "net/http" "slices" "strings" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -158,3 +163,68 @@ func directoryRoles(wids any) string { slices.Sort(names) return strings.Join(names, ", ") } + +// graphGroups lists the signed-in user's groups, nested ones too, from +// Microsoft Graph with az's token for it. +const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999" + +var _ provider.AccountGroups = (*Provider)(nil) + +// Groups lists the groups the account is a member of, by name. +func (p *Provider) Groups(ctx context.Context) ([]string, error) { + if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") { + return nil, provider.ErrUnsupported + } + tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}}) + if err != nil { + return nil, err + } + var names []string + for next := graphGroups; next != ""; { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok.Token) + resp, err := p.http().Do(req) + if err != nil { + return nil, err + } + var page struct { + Value []struct { + Type string `json:"@odata.type"` + Name string `json:"displayName"` + } `json:"value"` + Next string `json:"@odata.nextLink"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + err = json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + switch { + case err != nil: + return nil, fmt.Errorf("Microsoft Graph: %w", err) + case page.Error != nil: + return nil, fmt.Errorf("Microsoft Graph: %s", page.Error.Message) + case resp.StatusCode != http.StatusOK: + return nil, fmt.Errorf("Microsoft Graph: %s", resp.Status) + } + for _, v := range page.Value { + if v.Type == "#microsoft.graph.group" && v.Name != "" { + names = append(names, v.Name) + } + } + next = page.Next + } + slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) + return names, nil +} + +// http is the client for Microsoft Graph; tests replace it. +func (p *Provider) http() *http.Client { + if p.graphClient != nil { + return p.graphClient + } + return http.DefaultClient +} diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go index c82e32f..7027595 100644 --- a/internal/provider/azure/account_test.go +++ b/internal/provider/azure/account_test.go @@ -1,7 +1,17 @@ package azure import ( + "context" + "errors" + "io" + "net/http" + "slices" + "strings" "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) { } } } + +type staticToken struct{} + +func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type graphPages map[string]string // by URL + +func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) { + body, ok := g[r.URL.String()] + if !ok || r.Header.Get("Authorization") != "Bearer tok" { + return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil + } + return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func TestGroups(t *testing.T) { + next := "https://graph.microsoft.com/v1.0/next" + p := &Provider{ + cred: staticToken{}, + account: &accountDetails{account: account{user: "alice@contoso.com"}}, + graphClient: &http.Client{Transport: graphPages{ + graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"}, + {"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`, + next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`, + }}, + } + got, err := p.Groups(context.Background()) + if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) { + t.Errorf("groups %q, %v", got, err) + } + p.account.cloud = "AzureChinaCloud" + if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) { + t.Errorf("other cloud: %v", err) + } +} diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index 1d50999..b7b0629 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/http" "regexp" "slices" "strings" @@ -36,11 +37,12 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p type Provider struct { id, label string // "" for the single-account defaults - cred azcore.TokenCredential - opts options - account *accountDetails // nil for a service principal from AZURE_* - subs *armsubscriptions.Client - graph *armresourcegraph.Client + cred azcore.TokenCredential + opts options + account *accountDetails // nil for a service principal from AZURE_* + graphClient *http.Client // for Groups; nil for the default + subs *armsubscriptions.Client + graph *armresourcegraph.Client mu sync.Mutex vaults map[string]*vaultClients // by vault URL diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0857baf..2cce540 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -198,3 +198,10 @@ type Account struct { // Copy is what y copies on the provider's row, named CopyLabel. Copy, CopyLabel string } + +// AccountGroups is implemented by providers that can list the groups +// their account is a member of, directly or through other groups. It +// goes over the network. +type AccountGroups interface { + Groups(ctx context.Context) ([]string, error) +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 2e1eee2..f12cc31 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -163,6 +163,10 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error + // srcDetail is the fourth pane on a provider's row, with the groups + // of each provider's account as they load (source_detail.go). + srcDetail panes.SourceDetail + groups map[string]groupsState getenv func(string) string nvimOpen func(server, path string, line int) error // edit.go now func() time.Time @@ -356,6 +360,7 @@ func New(providers []provider.Provider, opts Options) Model { getenv: os.Getenv, nvimOpen: nvimOpen, cache: newCache(), + groups: map[string]groupsState{}, } if m.clip == nil { m.clip = clipboard.New() @@ -563,6 +568,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case persistedMsg: m.handlePersisted(msg) + case groupsMsg: + m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err} + case crawlMsg: cmds = append(cmds, m.handleCrawl(msg)) @@ -723,8 +731,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.askHandlerSetup() m.keys.Login.SetEnabled(m.loginFor() != nil) m.detail.SetMessage(m.selectedProblem()) - acct, _ := m.selectedAccount() - m.detail.SetAccount(acct.Fields) + cmds = append(cmds, m.syncSourceDetail()) if m.anyLoading() && !m.spinning { m.spinning = true cmds = append(cmds, m.spin.Tick) @@ -791,8 +798,14 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.jump(false) case key.Matches(msg, k.NextPane): m.focus = (m.focus + 1) % paneCount + if m.focus == paneContainers && m.srcDetail.Shown() { + m.focus = paneDetail // an account's row: nothing between + } case key.Matches(msg, k.PrevPane): m.focus = (m.focus + paneCount - 1) % paneCount + if m.focus == paneSecrets && m.srcDetail.Shown() { + m.focus = paneSources + } case key.Matches(msg, k.Pane1): m.focus = paneSources case key.Matches(msg, k.Pane2): @@ -928,7 +941,11 @@ func (m *Model) move(delta int) { case paneSecrets: m.secrets.Move(delta) case paneDetail: - m.detail.Scroll(delta) + if m.srcDetail.Shown() { + m.srcDetail.Move(delta) + } else { + m.detail.Scroll(delta) + } } } @@ -948,6 +965,10 @@ func (m *Model) jump(top bool) { m.secrets.Top() case m.focus == paneSecrets: m.secrets.Bottom() + case m.focus == paneDetail && m.srcDetail.Shown() && top: + m.srcDetail.Top() + case m.focus == paneDetail && m.srcDetail.Shown(): + m.srcDetail.Bottom() case m.focus == paneDetail && top: m.detail.ScrollTop() case m.focus == paneDetail: @@ -1458,6 +1479,9 @@ func (m *Model) copyValue() tea.Cmd { if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources { return m.copyText(a.CopyLabel, a.Copy, false) } + if f, ok := m.srcDetail.Selected(); ok && m.focus == paneDetail && m.srcDetail.Shown() { + return m.copyText(fieldName(f), f.Value, false) + } return nil } if v, ok := m.detail.Value(); ok { @@ -1583,7 +1607,7 @@ func (m Model) footerView() string { } else if l != nil && l.Filtering() { help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) } else { - help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2) + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.srcDetail.Shown()}.ShortHelp(), 2) } var lines []string if !m.clipDeadline.IsZero() { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index d952d6b..ef688fd 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1099,3 +1099,41 @@ func TestAccountInDetails(t *testing.T) { t.Errorf("still shown on a subscription:\n%s", d.screen()) } } + +// grouped is an account in two groups. +type grouped struct{ described } + +func (grouped) Groups(context.Context) ([]string, error) { + return []string{"Platform team", "Vault readers"}, nil +} + +func TestAccountPane(t *testing.T) { + t.Parallel() + d := newDriver(t, grouped{described{fake.Azure(0)}}) + d.keys("g", "tab") // the account's row, then straight to its pane + if d.m.focus != paneDetail { + t.Fatalf("focus %v, want the fourth pane", d.m.focus) + } + scr := d.screen() + for _, want := range []string{"Account", "Alice Example", "groups 2", "Platform team", "Vault readers"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + bar := lastLine(scr) + if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { + t.Errorf("help bar: %q", bar) + } + d.keys("j", "y") + if got := d.clip().content; got != "oid-123" { + t.Errorf("y on the object ID copied %q", got) + } + d.keys("G", "y") + if got := d.clip().content; got != "Vault readers" { + t.Errorf("y on a group copied %q", got) + } + d.keys("shift+tab") + if d.m.focus != paneSources { + t.Errorf("back to %v, want Sources", d.m.focus) + } +} diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 872443f..2fc9efb 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -36,6 +36,7 @@ type contextHelp struct { k keys.Map focus pane commands bool // custom commands apply here + account bool // the fourth pane describes an account (source_detail.go) } func (c contextHelp) ShortHelp() []key.Binding { @@ -44,6 +45,10 @@ func (c contextHelp) ShortHelp() []key.Binding { if k.Login.Enabled() { b = append(b, k.Login) } + if c.focus == paneDetail && c.account { + copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy")) + return append(b, k.Up, k.Down, copyField, k.Help, k.Quit) + } switch c.focus { case paneSources: b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter) diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index 94e628a..c1a3990 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -166,6 +166,7 @@ func (m *Model) layout() { m.containers.SetSize(leftW, h[1]) m.secrets.SetSize(leftW, h[2]) m.detail.SetSize(m.w-leftW, avail) + m.srcDetail.SetSize(m.w-leftW, avail) } // active reports whether p is drawn focused: it has focus and the terminal @@ -179,7 +180,12 @@ func (m Model) panesView() string { func() string { return m.sources.View(m.st, m.active(paneSources), spin) }, func() string { return m.containers.View(m.st, m.active(paneContainers), spin) }, func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, - func() string { return m.detail.View(m.st, m.active(paneDetail), spin) }, + func() string { + if m.srcDetail.Shown() { + return m.srcDetail.View(m.st, m.active(paneDetail)) + } + return m.detail.View(m.st, m.active(paneDetail), spin) + }, } if m.zoom == zoomFull { return views[m.focus]() diff --git a/internal/ui/app/source_detail.go b/internal/ui/app/source_detail.go new file mode 100644 index 0000000..93aa27d --- /dev/null +++ b/internal/ui/app/source_detail.go @@ -0,0 +1,90 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// On a provider's row, the fourth pane describes its account: the fields +// the provider knows locally, then the groups the account is in, loaded +// once a session when first shown. + +type groupsState struct { + names []string + err error + loading bool +} + +type groupsMsg struct { + provider string + names []string + err error +} + +// syncSourceDetail fills the pane for the selected row, loading the +// account's groups the first time. +func (m *Model) syncSourceDetail() tea.Cmd { + acct, ok := m.selectedAccount() + if !ok { + m.srcDetail.Set("", nil) + return nil + } + r, _ := m.sources.Selected() + var fields []panes.Field + for _, f := range acct.Fields { + fields = append(fields, panes.Field{Label: f.Label, Value: f.Value}) + } + var cmd tea.Cmd + if g, ok := m.byID[r.provider].(provider.AccountGroups); ok { + st, seen := m.groups[r.provider] + if !seen { + st = groupsState{loading: true} + m.groups[r.provider] = st + id, timeout := r.provider, m.timeout + cmd = func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + names, err := g.Groups(ctx) + return groupsMsg{provider: id, names: names, err: err} + } + } + fields = append(fields, groupFields(st)...) + } + m.srcDetail.Set("Account", fields) + return cmd +} + +// fieldName names a copied field: its label, or for a group, the group. +func fieldName(f panes.Field) string { + if f.Label == "" || strings.HasPrefix(f.Label, "groups") { + return "group " + f.Value + } + return f.Label +} + +// groupFields are the groups' rows: labelled on the first. +func groupFields(st groupsState) []panes.Field { + switch { + case st.loading: + return []panes.Field{{Label: "groups", Value: "loading…", Info: true}} + case errors.Is(st.err, provider.ErrUnsupported): + return nil + case st.err != nil: + return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}} + case len(st.names) == 0: + return []panes.Field{{Label: "groups", Value: "none", Info: true}} + } + out := make([]panes.Field, len(st.names)) + for i, n := range st.names { + out[i] = panes.Field{Value: n} + } + out[0].Label = fmt.Sprintf("groups %d", len(st.names)) + return out +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index dbdab8c..f1f2e15 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -47,7 +47,6 @@ type Detail struct { 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 @@ -77,10 +76,6 @@ func (d *Detail) Clear() { // 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 } @@ -196,12 +191,6 @@ func (d Detail) lines(st styles.Styles) []string { 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)) diff --git a/internal/ui/panes/source_detail.go b/internal/ui/panes/source_detail.go new file mode 100644 index 0000000..f18ed0d --- /dev/null +++ b/internal/ui/panes/source_detail.go @@ -0,0 +1,88 @@ +package panes + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// SourceDetail is the fourth pane on a provider's row: what's known about +// its account, one field per row, to move through and copy from. +type SourceDetail struct { + title string + fields []Field + sel int + w, h int +} + +// Field is a row: a value under a label. Info rows (loading, errors) +// can't be copied. +type Field struct { + Label, Value string + Info bool +} + +// Set shows fields under title, keeping the cursor where it was for the +// same title. +func (s *SourceDetail) Set(title string, fields []Field) { + if title != s.title { + s.sel = 0 + } + s.title, s.fields = title, fields + s.sel = max(0, min(s.sel, len(fields)-1)) +} + +// Shown reports whether there's anything to show. +func (s SourceDetail) Shown() bool { return len(s.fields) > 0 } + +func (s *SourceDetail) SetSize(w, h int) { s.w, s.h = w, h } + +func (s *SourceDetail) Move(delta int) { s.sel = max(0, min(s.sel+delta, len(s.fields)-1)) } +func (s *SourceDetail) Top() { s.sel = 0 } +func (s *SourceDetail) Bottom() { s.sel = max(0, len(s.fields)-1) } + +// Selected is the field under the cursor, unless it's an info row. +func (s SourceDetail) Selected() (Field, bool) { + if s.sel >= len(s.fields) || s.fields[s.sel].Info { + return Field{}, false + } + return s.fields[s.sel], true +} + +func (s SourceDetail) View(st styles.Styles, focused bool) string { + inner := max(1, s.w-2) + rows := max(0, s.h-2) + offset := 0 + if s.sel >= rows { // keep the cursor in view + offset = s.sel - rows + 1 + } + var body []string + for i, f := range s.fields[min(offset, len(s.fields)):] { + if i >= rows { + break + } + value := f.Value + if f.Info { + value = st.Muted.Render(value) + } + line := " " + st.Label.Render(fmt.Sprintf("%-10s", f.Label)) + " " + value + line = ansi.Truncate(line, inner, "…") + if i+offset == s.sel { + pad := strings.Repeat(" ", max(0, inner-ansi.StringWidth(line))) + if focused { + line = st.Selected.Render(ansi.Strip(line) + pad) + } else { + line = st.SelectedBlurred.Render(ansi.Strip(line) + pad) + } + } + body = append(body, line) + } + footer := "" + if len(s.fields) > rows { + footer = fmt.Sprintf("%d of %d", s.sel+1, len(s.fields)) + } + return Box(st, TitleStyle(st, focused).Render(s.title), footer, body, s.w, s.h, focused) +} -- 2.54.0 From 1543e992bbb269af1727e5d7262037b2376ed5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:17:54 +0200 Subject: [PATCH 2/5] fix: lower-case Graph errors (staticcheck) Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/provider/azure/account.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index a995453..68a684c 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -204,11 +204,11 @@ func (p *Provider) Groups(ctx context.Context) ([]string, error) { resp.Body.Close() switch { case err != nil: - return nil, fmt.Errorf("Microsoft Graph: %w", err) + return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err) case page.Error != nil: - return nil, fmt.Errorf("Microsoft Graph: %s", page.Error.Message) + return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message) case resp.StatusCode != http.StatusOK: - return nil, fmt.Errorf("Microsoft Graph: %s", resp.Status) + return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status) } for _, v := range page.Value { if v.Type == "#microsoft.graph.group" && v.Name != "" { -- 2.54.0 From 3913989cd174c2630f9c7f4df1eb13b91e3ba68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:27:19 +0200 Subject: [PATCH 3/5] feat: tenant name and tenant ID on lines of their own Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/provider/azure/account.go | 7 ++----- internal/provider/azure/account_test.go | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index 68a684c..690234e 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -45,11 +45,8 @@ func (p *Provider) Account() (provider.Account, bool) { user += " (service principal)" } add("user", user) - tenant := a.tenant - if a.tenantName != "" { - tenant = a.tenantName + " · " + a.tenant - } - add("tenant", tenant) + add("tenant", a.tenantName) + add("tenant ID", a.tenant) if oid := str("oid"); oid != "" { add("object ID", oid) out.Copy, out.CopyLabel = oid, "object ID" diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go index 7027595..a951ce8 100644 --- a/internal/provider/azure/account_test.go +++ b/internal/provider/azure/account_test.go @@ -35,7 +35,7 @@ func TestAccount(t *testing.T) { got[f.Label] = f.Value } want := map[string]string{ - "name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1", + "name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1", "object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password", "roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform", } @@ -54,7 +54,7 @@ func TestAccount(t *testing.T) { // Before any token: what az's list says. claims = nil a, _ = p.Account() - if len(a.Fields) != 3 || a.Copy != "" { + if len(a.Fields) != 4 || a.Copy != "" { t.Errorf("without a token: %+v", a) } if _, ok := (&Provider{}).Account(); ok { -- 2.54.0 From 0a80073aad77f46c83f4feccba40edd971eb943e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:28:59 +0200 Subject: [PATCH 4/5] feat: / filters the account pane Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 4 +- internal/ui/app/app.go | 5 ++ internal/ui/app/app_test.go | 19 ++++++- internal/ui/app/help.go | 2 +- internal/ui/app/layout.go | 2 +- internal/ui/app/source_detail.go | 2 +- internal/ui/panes/source_detail.go | 89 +++++++++++------------------- 7 files changed, 61 insertions(+), 62 deletions(-) diff --git a/docs/azure.md b/docs/azure.md index f82dfa7..85a9e91 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -20,8 +20,8 @@ and the token lazysecrets already has. Below them are the groups the account is in, nested ones too: those come from Microsoft Graph, once a session, the first time the pane shows them. `y` on the row copies the object ID, the one access policies and role assignments ask for; `tab` -goes into the pane, where `j`/`k` pick any field or group and `y` copies -it. +goes into the pane, where `j`/`k` pick any field or group, `/` filters +them (`/tenant`, `/platform`) and `y` copies the selected one. If an account's az login has expired, the status line says so once, and the Details pane on that account's rows says it until you sign in: press `L` diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index f12cc31..f92fd90 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -361,6 +361,7 @@ func New(providers []provider.Provider, opts Options) Model { nvimOpen: nvimOpen, cache: newCache(), groups: map[string]groupsState{}, + srcDetail: panes.NewSourceDetail(), } if m.clip == nil { m.clip = clipboard.New() @@ -927,6 +928,10 @@ func (m *Model) focusedFilter() filterable { return &m.containers case paneSecrets: return &m.secrets + case paneDetail: + if m.srcDetail.Shown() { + return &m.srcDetail + } } return nil } diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index ef688fd..7c1a2d6 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1115,7 +1115,7 @@ func TestAccountPane(t *testing.T) { t.Fatalf("focus %v, want the fourth pane", d.m.focus) } scr := d.screen() - for _, want := range []string{"Account", "Alice Example", "groups 2", "Platform team", "Vault readers"} { + for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} { if !strings.Contains(scr, want) { t.Errorf("missing %q:\n%s", want, scr) } @@ -1132,6 +1132,23 @@ func TestAccountPane(t *testing.T) { if got := d.clip().content; got != "Vault readers" { t.Errorf("y on a group copied %q", got) } + + // / filters it, labels too. + d.keys("/") + d.typeText("vault") + d.keys("enter") + if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") { + t.Errorf("filtered:\n%s", scr) + } + d.keys("y") + if got := d.clip().content; got != "Vault readers" { + t.Errorf("y in the filtered pane copied %q", got) + } + d.keys("esc") + if !strings.Contains(d.screen(), "Platform team") { + t.Errorf("filter not cleared:\n%s", d.screen()) + } + d.keys("shift+tab") if d.m.focus != paneSources { t.Errorf("back to %v, want Sources", d.m.focus) diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 2fc9efb..71760ce 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -47,7 +47,7 @@ func (c contextHelp) ShortHelp() []key.Binding { } if c.focus == paneDetail && c.account { copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy")) - return append(b, k.Up, k.Down, copyField, k.Help, k.Quit) + return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit) } switch c.focus { case paneSources: diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index c1a3990..b796653 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -182,7 +182,7 @@ func (m Model) panesView() string { func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, func() string { if m.srcDetail.Shown() { - return m.srcDetail.View(m.st, m.active(paneDetail)) + return m.srcDetail.View(m.st, m.active(paneDetail), spin) } return m.detail.View(m.st, m.active(paneDetail), spin) }, diff --git a/internal/ui/app/source_detail.go b/internal/ui/app/source_detail.go index 93aa27d..01e60f6 100644 --- a/internal/ui/app/source_detail.go +++ b/internal/ui/app/source_detail.go @@ -57,7 +57,7 @@ func (m *Model) syncSourceDetail() tea.Cmd { } fields = append(fields, groupFields(st)...) } - m.srcDetail.Set("Account", fields) + m.srcDetail.Set(m.byID[r.provider].Label(), fields) return cmd } diff --git a/internal/ui/panes/source_detail.go b/internal/ui/panes/source_detail.go index f18ed0d..00d39fd 100644 --- a/internal/ui/panes/source_detail.go +++ b/internal/ui/panes/source_detail.go @@ -2,7 +2,6 @@ package panes import ( "fmt" - "strings" "github.com/charmbracelet/x/ansi" @@ -10,12 +9,10 @@ import ( ) // SourceDetail is the fourth pane on a provider's row: what's known about -// its account, one field per row, to move through and copy from. +// its account, one field per row, to move through, filter and copy from. +// It's a List, so / filters it like the other panes. type SourceDetail struct { - title string - fields []Field - sel int - w, h int + List[Field] } // Field is a row: a value under a label. Info rows (loading, errors) @@ -25,64 +22,44 @@ type Field struct { Info bool } -// Set shows fields under title, keeping the cursor where it was for the -// same title. +func NewSourceDetail() SourceDetail { + l := NewList("", Item[Field]{ + ID: func(f Field) string { return f.Label + "\x00" + f.Value }, + Filter: func(f Field) string { return f.Value }, + // "/tenant" finds the tenant's rows. + FilterExtra: func(f Field) string { return f.Label }, + Render: func(st styles.Styles, f Field, width int) string { + value := f.Value + if f.Info { + value = st.Muted.Render(value) + } + return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…") + }, + }) + l.Empty = "nothing known about this account" + return SourceDetail{l} +} + +// Set shows fields under title. Another title (another account) starts +// over, without a filter. func (s *SourceDetail) Set(title string, fields []Field) { - if title != s.title { - s.sel = 0 + if title != s.Title { + s.Reset() + s.Title = title + } + if len(fields) > 0 || len(s.Items()) > 0 { + s.SetItems(fields) } - s.title, s.fields = title, fields - s.sel = max(0, min(s.sel, len(fields)-1)) } // Shown reports whether there's anything to show. -func (s SourceDetail) Shown() bool { return len(s.fields) > 0 } - -func (s *SourceDetail) SetSize(w, h int) { s.w, s.h = w, h } - -func (s *SourceDetail) Move(delta int) { s.sel = max(0, min(s.sel+delta, len(s.fields)-1)) } -func (s *SourceDetail) Top() { s.sel = 0 } -func (s *SourceDetail) Bottom() { s.sel = max(0, len(s.fields)-1) } +func (s SourceDetail) Shown() bool { return len(s.Items()) > 0 } // Selected is the field under the cursor, unless it's an info row. func (s SourceDetail) Selected() (Field, bool) { - if s.sel >= len(s.fields) || s.fields[s.sel].Info { + f, ok := s.List.Selected() + if !ok || f.Info { return Field{}, false } - return s.fields[s.sel], true -} - -func (s SourceDetail) View(st styles.Styles, focused bool) string { - inner := max(1, s.w-2) - rows := max(0, s.h-2) - offset := 0 - if s.sel >= rows { // keep the cursor in view - offset = s.sel - rows + 1 - } - var body []string - for i, f := range s.fields[min(offset, len(s.fields)):] { - if i >= rows { - break - } - value := f.Value - if f.Info { - value = st.Muted.Render(value) - } - line := " " + st.Label.Render(fmt.Sprintf("%-10s", f.Label)) + " " + value - line = ansi.Truncate(line, inner, "…") - if i+offset == s.sel { - pad := strings.Repeat(" ", max(0, inner-ansi.StringWidth(line))) - if focused { - line = st.Selected.Render(ansi.Strip(line) + pad) - } else { - line = st.SelectedBlurred.Render(ansi.Strip(line) + pad) - } - } - body = append(body, line) - } - footer := "" - if len(s.fields) > rows { - footer = fmt.Sprintf("%d of %d", s.sel+1, len(s.fields)) - } - return Box(st, TitleStyle(st, focused).Render(s.title), footer, body, s.w, s.h, focused) + return f, true } -- 2.54.0 From e64748e85d6ae6f4a6826d4a539714f6acaa1d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:32:46 +0200 Subject: [PATCH 5/5] refactor: the account pane becomes Fields, the pane for anything but a secret Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/app.go | 42 +++++++++---------- .../ui/app/{source_detail.go => fields.go} | 14 +++---- internal/ui/app/help.go | 2 +- internal/ui/app/layout.go | 6 +-- .../ui/panes/{source_detail.go => fields.go} | 26 ++++++------ 5 files changed, 46 insertions(+), 44 deletions(-) rename internal/ui/app/{source_detail.go => fields.go} (83%) rename internal/ui/panes/{source_detail.go => fields.go} (61%) diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index f92fd90..fc44701 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -163,13 +163,13 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error - // srcDetail is the fourth pane on a provider's row, with the groups - // of each provider's account as they load (source_detail.go). - srcDetail panes.SourceDetail - groups map[string]groupsState - getenv func(string) string - nvimOpen func(server, path string, line int) error // edit.go - now func() time.Time + // fields is the fourth pane for anything but a secret (now: an + // account), with each account's groups as they load (fields.go). + fields panes.Fields + groups map[string]groupsState + getenv func(string) string + nvimOpen func(server, path string, line int) error // edit.go + now func() time.Time // The cache file, whether the cache changed since it was saved, and // when each provider's Sources list was loaded (persist.go). cachePath string @@ -361,7 +361,7 @@ func New(providers []provider.Provider, opts Options) Model { nvimOpen: nvimOpen, cache: newCache(), groups: map[string]groupsState{}, - srcDetail: panes.NewSourceDetail(), + fields: panes.NewFields(), } if m.clip == nil { m.clip = clipboard.New() @@ -732,7 +732,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.askHandlerSetup() m.keys.Login.SetEnabled(m.loginFor() != nil) m.detail.SetMessage(m.selectedProblem()) - cmds = append(cmds, m.syncSourceDetail()) + cmds = append(cmds, m.syncFields()) if m.anyLoading() && !m.spinning { m.spinning = true cmds = append(cmds, m.spin.Tick) @@ -799,12 +799,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.jump(false) case key.Matches(msg, k.NextPane): m.focus = (m.focus + 1) % paneCount - if m.focus == paneContainers && m.srcDetail.Shown() { + if m.focus == paneContainers && m.fields.Shown() { m.focus = paneDetail // an account's row: nothing between } case key.Matches(msg, k.PrevPane): m.focus = (m.focus + paneCount - 1) % paneCount - if m.focus == paneSecrets && m.srcDetail.Shown() { + if m.focus == paneSecrets && m.fields.Shown() { m.focus = paneSources } case key.Matches(msg, k.Pane1): @@ -929,8 +929,8 @@ func (m *Model) focusedFilter() filterable { case paneSecrets: return &m.secrets case paneDetail: - if m.srcDetail.Shown() { - return &m.srcDetail + if m.fields.Shown() { + return &m.fields } } return nil @@ -946,8 +946,8 @@ func (m *Model) move(delta int) { case paneSecrets: m.secrets.Move(delta) case paneDetail: - if m.srcDetail.Shown() { - m.srcDetail.Move(delta) + if m.fields.Shown() { + m.fields.Move(delta) } else { m.detail.Scroll(delta) } @@ -970,10 +970,10 @@ func (m *Model) jump(top bool) { m.secrets.Top() case m.focus == paneSecrets: m.secrets.Bottom() - case m.focus == paneDetail && m.srcDetail.Shown() && top: - m.srcDetail.Top() - case m.focus == paneDetail && m.srcDetail.Shown(): - m.srcDetail.Bottom() + case m.focus == paneDetail && m.fields.Shown() && top: + m.fields.Top() + case m.focus == paneDetail && m.fields.Shown(): + m.fields.Bottom() case m.focus == paneDetail && top: m.detail.ScrollTop() case m.focus == paneDetail: @@ -1484,7 +1484,7 @@ func (m *Model) copyValue() tea.Cmd { if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources { return m.copyText(a.CopyLabel, a.Copy, false) } - if f, ok := m.srcDetail.Selected(); ok && m.focus == paneDetail && m.srcDetail.Shown() { + if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() { return m.copyText(fieldName(f), f.Value, false) } return nil @@ -1612,7 +1612,7 @@ func (m Model) footerView() string { } else if l != nil && l.Filtering() { help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) } else { - help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.srcDetail.Shown()}.ShortHelp(), 2) + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2) } var lines []string if !m.clipDeadline.IsZero() { diff --git a/internal/ui/app/source_detail.go b/internal/ui/app/fields.go similarity index 83% rename from internal/ui/app/source_detail.go rename to internal/ui/app/fields.go index 01e60f6..a85c58a 100644 --- a/internal/ui/app/source_detail.go +++ b/internal/ui/app/fields.go @@ -12,9 +12,9 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" ) -// On a provider's row, the fourth pane describes its account: the fields -// the provider knows locally, then the groups the account is in, loaded -// once a session when first shown. +// The fourth pane shows Fields for anything but a secret. On a provider's +// row: its account, from what the provider knows locally, then the +// groups the account is in, loaded once a session when first shown. type groupsState struct { names []string @@ -28,12 +28,12 @@ type groupsMsg struct { err error } -// syncSourceDetail fills the pane for the selected row, loading the +// syncFields fills the Fields pane for what's selected, loading an // account's groups the first time. -func (m *Model) syncSourceDetail() tea.Cmd { +func (m *Model) syncFields() tea.Cmd { acct, ok := m.selectedAccount() if !ok { - m.srcDetail.Set("", nil) + m.fields.Set("", nil) return nil } r, _ := m.sources.Selected() @@ -57,7 +57,7 @@ func (m *Model) syncSourceDetail() tea.Cmd { } fields = append(fields, groupFields(st)...) } - m.srcDetail.Set(m.byID[r.provider].Label(), fields) + m.fields.Set(m.byID[r.provider].Label(), fields) return cmd } diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 71760ce..641ae9f 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -36,7 +36,7 @@ type contextHelp struct { k keys.Map focus pane commands bool // custom commands apply here - account bool // the fourth pane describes an account (source_detail.go) + account bool // the fourth pane describes an account (fields.go) } func (c contextHelp) ShortHelp() []key.Binding { diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index b796653..9f1a8ff 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -166,7 +166,7 @@ func (m *Model) layout() { m.containers.SetSize(leftW, h[1]) m.secrets.SetSize(leftW, h[2]) m.detail.SetSize(m.w-leftW, avail) - m.srcDetail.SetSize(m.w-leftW, avail) + m.fields.SetSize(m.w-leftW, avail) } // active reports whether p is drawn focused: it has focus and the terminal @@ -181,8 +181,8 @@ func (m Model) panesView() string { func() string { return m.containers.View(m.st, m.active(paneContainers), spin) }, func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, func() string { - if m.srcDetail.Shown() { - return m.srcDetail.View(m.st, m.active(paneDetail), spin) + if m.fields.Shown() { + return m.fields.View(m.st, m.active(paneDetail), spin) } return m.detail.View(m.st, m.active(paneDetail), spin) }, diff --git a/internal/ui/panes/source_detail.go b/internal/ui/panes/fields.go similarity index 61% rename from internal/ui/panes/source_detail.go rename to internal/ui/panes/fields.go index 00d39fd..0859c46 100644 --- a/internal/ui/panes/source_detail.go +++ b/internal/ui/panes/fields.go @@ -8,10 +8,12 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" ) -// SourceDetail is the fourth pane on a provider's row: what's known about -// its account, one field per row, to move through, filter and copy from. -// It's a List, so / filters it like the other panes. -type SourceDetail struct { +// Fields is the fourth pane for anything but a secret (an account, and +// what describes vaults, scopes and files): labelled values, one per row, +// a label on the first of several values, to move through, filter and +// copy from. It's a List, so / filters it like the other panes. Secrets +// have Detail, for revealing and versions. +type Fields struct { List[Field] } @@ -22,7 +24,7 @@ type Field struct { Info bool } -func NewSourceDetail() SourceDetail { +func NewFields() Fields { l := NewList("", Item[Field]{ ID: func(f Field) string { return f.Label + "\x00" + f.Value }, Filter: func(f Field) string { return f.Value }, @@ -36,13 +38,13 @@ func NewSourceDetail() SourceDetail { return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…") }, }) - l.Empty = "nothing known about this account" - return SourceDetail{l} + l.Empty = "nothing to show" + return Fields{l} } -// Set shows fields under title. Another title (another account) starts -// over, without a filter. -func (s *SourceDetail) Set(title string, fields []Field) { +// Set shows fields under title. Another title (another account or vault) +// starts over, without a filter. +func (s *Fields) Set(title string, fields []Field) { if title != s.Title { s.Reset() s.Title = title @@ -53,10 +55,10 @@ func (s *SourceDetail) Set(title string, fields []Field) { } // Shown reports whether there's anything to show. -func (s SourceDetail) Shown() bool { return len(s.Items()) > 0 } +func (s Fields) Shown() bool { return len(s.Items()) > 0 } // Selected is the field under the cursor, unless it's an info row. -func (s SourceDetail) Selected() (Field, bool) { +func (s Fields) Selected() (Field, bool) { f, ok := s.List.Selected() if !ok || f.Info { return Field{}, false -- 2.54.0