4 Commits
Author SHA1 Message Date
kebandClaude Opus 5.5 2e941102a2 feat: show who an Azure account signs in as, in Details on its row
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:08:04 +02:00
keb 5355c65284 Merge remote-tracking branch 'origin/main' into feat/account-details 2026-10-11 10:06:41 +02:00
keb 482e0805b7 wip: account details from az and the token 2026-10-11 10:06:41 +02:00
keb a82f6bff27 fix: say a sign-in error once, then in Details for that account (#4)
A failing account's error ("sign-in required … press L to run az login --tenant …") stayed in the footer for the whole session.

- **Footer:** it's now an ordinary message, shown once when an account's error first appears (at start, or when a load fails), and gone on the next key press. It's said again only when the errors change, or for another account.
- **Details:** with that account's group header or one of its sources selected, the Details pane shows the error and the sign-in hint, in place of "Select a vault or scope…".
- **`L`** signs in to the selected account when it needs it, else to the last one that asked, as before. With two accounts, `L` used to run whichever sign-in had failed most recently.

Test: two accounts, one failing to list its subscriptions. The message is in the footer at start and gone after a key press, in Details on that account's group, and `L` targets it. It isn't shown on the other account's rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #4
2026-10-11 10:06:28 +02:00
12 changed files with 488 additions and 31 deletions

No files matched your search

+11 -2
View File
@@ -13,8 +13,17 @@ 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.
If your az login expires mid-session, the status line says so: press `L` to
run `az login` without leaving lazysecrets, and everything reloads.
On an account's group row, the Details 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.
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`
there to run `az login` for it without leaving lazysecrets, and everything
reloads.
## Permissions
+1 -1
View File
@@ -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 |
| `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 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) |
+160
View File
@@ -0,0 +1,160 @@
package azure
import (
"fmt"
"slices"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// accountDetails describes an az account from az's account list and the
// claims of the latest token lazysecrets got for it: nothing is fetched
// for it.
type accountDetails struct {
account
claims func() map[string]any
}
var _ provider.Accounts = (*Provider)(nil)
// Account describes the az account, once a token has been fetched for it
// (the user's name and object ID come from the token).
func (p *Provider) Account() (provider.Account, bool) {
if p.account == nil {
return provider.Account{}, false
}
a := p.account
claims := a.claims()
str := func(k string) string { s, _ := claims[k].(string); return s }
var out provider.Account
add := func(label, value string) {
if value != "" {
out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value})
}
}
add("name", str("name"))
user := a.user
if a.userType == "servicePrincipal" {
user += " (service principal)"
}
add("user", user)
tenant := a.tenant
if a.tenantName != "" {
tenant = a.tenantName + " · " + a.tenant
}
add("tenant", tenant)
if oid := str("oid"); oid != "" {
add("object ID", oid)
out.Copy, out.CopyLabel = oid, "object ID"
}
if claims != nil {
add("sign-in", signInMethods(claims["amr"]))
add("roles", directoryRoles(claims["wids"]))
}
subs := fmt.Sprint(a.subscriptions)
if a.defaultSub != "" {
subs += " · default " + a.defaultSub
}
add("subs", subs)
if a.cloud != "" && a.cloud != "AzureCloud" {
add("cloud", a.cloud)
}
return out, len(out.Fields) > 0
}
// signInMethods says how the session was signed in to, from the amr
// claim: with MFA or not, and by what.
func signInMethods(amr any) string {
list, _ := amr.([]any)
var methods []string
mfa := false
for _, m := range list {
s, _ := m.(string)
switch s {
case "mfa", "ngcmfa":
mfa = true
case "pwd":
methods = append(methods, "password")
case "rsa":
methods = append(methods, "Windows Hello or certificate")
case "fido":
methods = append(methods, "security key")
case "wia":
methods = append(methods, "Windows sign-in")
case "otp", "sms":
methods = append(methods, "one-time code")
}
}
if len(list) == 0 {
return ""
}
how := strings.Join(methods, ", ")
switch {
case mfa && how != "":
return "MFA · " + how
case mfa:
return "MFA"
case how != "":
return "no MFA · " + how
}
return "no MFA"
}
// builtinRoles names Entra ID's built-in directory roles by template ID,
// the IDs a token's wids claim lists.
var builtinRoles = map[string]string{
"62e90394-69f5-4237-9190-012177145e10": "Global Administrator",
"f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader",
"e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator",
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator",
"194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator",
"5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader",
"5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator",
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator",
"158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator",
"cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer",
"fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator",
"fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator",
"729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator",
"966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator",
"c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator",
"0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator",
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator",
"b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator",
"4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator",
"17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator",
"29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator",
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator",
"69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator",
"3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator",
"11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator",
"e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator",
"9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers",
"88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers",
"95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter",
}
// memberRole is in every member's token: not worth listing.
const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509"
// directoryRoles names the roles in a wids claim: built-in ones by name,
// others by their ID's first part.
func directoryRoles(wids any) string {
list, _ := wids.([]any)
var names []string
for _, w := range list {
id, _ := w.(string)
switch name, ok := builtinRoles[strings.ToLower(id)]; {
case id == "" || id == memberRole:
case ok:
names = append(names, name)
default:
short, _, _ := strings.Cut(id, "-")
names = append(names, "role "+short)
}
}
slices.Sort(names)
return strings.Join(names, ", ")
}
+67
View File
@@ -0,0 +1,67 @@
package azure
import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestAccount(t *testing.T) {
claims := map[string]any{
"name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555",
"amr": []any{"pwd", "mfa"},
"wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"},
}
p := &Provider{account: &accountDetails{
account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"},
claims: func() map[string]any { return claims },
}}
a, ok := p.Account()
if !ok {
t.Fatal("no account")
}
got := map[string]string{}
for _, f := range a.Fields {
got[f.Label] = f.Value
}
want := map[string]string{
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1",
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, want %q", k, got[k], v)
}
}
if len(got) != len(want) {
t.Errorf("fields %v", got)
}
if a.Copy != claims["oid"] || a.CopyLabel != "object ID" {
t.Errorf("copy %q %q", a.Copy, a.CopyLabel)
}
// Before any token: what az's list says.
claims = nil
a, _ = p.Account()
if len(a.Fields) != 3 || a.Copy != "" {
t.Errorf("without a token: %+v", a)
}
if _, ok := (&Provider{}).Account(); ok {
t.Error("a service principal from AZURE_* has no account details")
}
var _ provider.Accounts = p
}
func TestSignInMethods(t *testing.T) {
for want, amr := range map[string][]any{
"MFA · password": {"pwd", "mfa"},
"MFA · Windows Hello or certificate": {"rsa", "ngcmfa"},
"no MFA · password": {"pwd"},
"": nil,
} {
if got := signInMethods(amr); got != want {
t.Errorf("%v: %q, want %q", amr, got, want)
}
}
}
+18 -2
View File
@@ -19,7 +19,10 @@ import (
// account is one az CLI identity in one tenant.
type account struct {
user, tenant string
userType string // "user" or "servicePrincipal"
tenantName string
cloud string // az's environment: AzureCloud, AzureChinaCloud, …
defaultSub string // az's default subscription's name, if this account's
anchor string // a subscription az maps to this account, to pin it
anchorEnabled bool
isDefault bool
@@ -52,12 +55,15 @@ func readAccounts(path string) ([]account, error) {
var profile struct {
Subscriptions []struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
IsDefault bool `json:"isDefault"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantDisplayName"`
Cloud string `json:"environmentName"`
User struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"user"`
} `json:"subscriptions"`
}
@@ -72,7 +78,7 @@ func readAccounts(path string) ([]account, error) {
}
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
if i < 0 {
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
i = len(out) - 1
}
a := &out[i]
@@ -82,12 +88,21 @@ func readAccounts(path string) ([]account, error) {
enabled := s.State == "Enabled"
switch {
case s.IsDefault:
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
case a.isDefault:
case a.anchor == "" || enabled && !a.anchorEnabled:
a.anchor, a.anchorEnabled = s.ID, enabled
}
}
// az leaves the tenant's name out of some entries: take it from
// another in the same tenant.
for i := range out {
for _, b := range out {
if out[i].tenantName == "" && b.tenant == out[i].tenant {
out[i].tenantName = b.tenantName
}
}
}
slices.SortStableFunc(out, func(a, b account) int {
if a.isDefault != b.isDefault {
if a.isDefault {
@@ -179,6 +194,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
if err != nil {
return nil, err
}
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
if label != "" {
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
p.label = label
+2 -2
View File
@@ -181,11 +181,11 @@ func TestTokenUser(t *testing.T) {
`{"upn":"alice@contoso.com"}`: false,
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
} {
if _, got := tokenUser(jwt(claims), users); got != want {
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
t.Errorf("%s: %v", claims, got)
}
}
if _, got := tokenUser("not-a-jwt", users); got {
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
t.Error("not a JWT")
}
}
+1
View File
@@ -38,6 +38,7 @@ type Provider struct {
cred azcore.TokenCredential
opts options
account *accountDetails // nil for a service principal from AZURE_*
subs *armsubscriptions.Client
graph *armresourcegraph.Client
+28 -9
View File
@@ -42,6 +42,16 @@ type cliCredential struct {
mu sync.Mutex
cache map[string]azcore.AccessToken // by resource and tenant argument
// last is the claims of the latest token in the account's own
// tenant, to describe the account (Provider.Account).
last map[string]any
}
// claims are the latest token's claims, nil before the first.
func (c *cliCredential) claims() map[string]any {
c.mu.Lock()
defer c.mu.Unlock()
return c.last
}
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
@@ -110,29 +120,38 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
if err != nil {
return azcore.AccessToken{}, err
}
if u, ok := tokenUser(tok.Token, others); ok {
claims := tokenClaims(tok.Token)
if u, ok := tokenUser(claims, others); ok {
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
}
if !otherTenant && claims != nil {
c.last = claims
}
c.cache[key] = tok
return tok, nil
}
// tokenUser reports which of users a token was issued to, if any, from
// its claims. The token isn't verified: this only catches az picking the
// wrong signed-in account.
func tokenUser(token string, users []string) (string, bool) {
// tokenClaims reads a JWT's claims, without verifying it: they only
// describe the account and catch az picking the wrong one. nil if it
// isn't a JWT.
func tokenClaims(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) != 3 || len(users) == 0 {
return "", false
if len(parts) != 3 {
return nil
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return "", false
return nil
}
var claims map[string]any
if json.Unmarshal(b, &claims) != nil {
return "", false
return nil
}
return claims
}
// tokenUser reports which of users a token's claims are for, if any.
func tokenUser(claims map[string]any, users []string) (string, bool) {
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
v, _ := claims[name].(string)
if i := strings.LastIndexByte(v, '#'); i >= 0 {
+14
View File
@@ -184,3 +184,17 @@ type File struct {
Line int // 1-based; 0 for the top
Sops string // the sops command to edit it with; empty for a plain file
}
// Accounts is implemented by providers that can describe the identity
// they use, from what they have locally: for the Details pane on the
// provider's row. ok is false when there's nothing to say.
type Accounts interface {
Account() (Account, bool)
}
// Account describes a signed-in identity.
type Account struct {
Fields []Attr
// Copy is what y copies on the provider's row, named CopyLabel.
Copy, CopyLabel string
}
+87 -15
View File
@@ -721,7 +721,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.waiting = 0
m.askHandlerSetup()
m.keys.Login.SetEnabled(m.auth != nil && len(m.auth.Login) > 0)
m.keys.Login.SetEnabled(m.loginFor() != nil)
m.detail.SetMessage(m.selectedProblem())
acct, _ := m.selectedAccount()
m.detail.SetAccount(acct.Fields)
if m.anyLoading() && !m.spinning {
m.spinning = true
cmds = append(cmds, m.spin.Tick)
@@ -881,8 +884,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Edit):
return m.edit(), false
case key.Matches(msg, k.Login):
if m.auth != nil && len(m.auth.Login) > 0 {
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
if auth := m.loginFor(); auth != nil {
c := exec.Command(auth.Login[0], auth.Login[1:]...)
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
}
case key.Matches(msg, k.Refresh):
@@ -1261,7 +1264,14 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
}
}
m.status = strings.Join(errs, " · ")
// Said once, when they change; after that the Details pane says it
// for the selected account.
if status := strings.Join(errs, " · "); status != m.status {
m.status = status
if status != "" {
m.flash = flash{text: m.problemText(msg.provider, status), isErr: true}
}
}
m.rebuildSources()
m.sources.Loading = m.sourcesPending > 0
return m.autoLoad(msg)
@@ -1445,6 +1455,9 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd {
o, ok := m.detail.Secret()
if !ok {
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
return nil
}
if v, ok := m.detail.Value(); ok {
@@ -1532,14 +1545,27 @@ func (m *Model) openSelected() tea.Cmd {
return nil
}
// noteErr remembers authentication failures so the UI can offer sign-in.
// noteErr remembers authentication failures so the UI can offer sign-in,
// saying so once for each account.
func (m *Model) noteErr(err error) {
var auth *provider.AuthError
if errors.As(err, &auth) {
if m.auth == nil || m.auth.Who() != auth.Who() {
m.flash = flash{text: m.authText(auth), isErr: true}
}
m.auth = auth
}
}
// authText says which account needs signing in, and how.
func (m Model) authText(auth *provider.AuthError) string {
if len(auth.Login) == 0 {
return fmt.Sprintf("%s: sign-in required: %v", auth.Who(), auth.Err)
}
return fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s",
auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(auth.Login, " ")))
}
func (m Model) anyLoading() bool {
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
m.detail.State() == panes.ValueLoading
@@ -1560,16 +1586,6 @@ func (m Model) footerView() string {
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2)
}
var lines []string
failed := func(text string) { lines = append(lines, m.flashLine(flash{text: text, isErr: true})) }
switch {
case m.auth != nil && len(m.auth.Login) > 0:
failed(fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s",
m.auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(m.auth.Login, " "))))
case m.auth != nil:
failed(fmt.Sprintf("%s: sign-in required: %v", m.auth.Who(), m.auth.Err))
case m.status != "":
failed(m.status)
}
if !m.clipDeadline.IsZero() {
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
@@ -1624,3 +1640,59 @@ func (m Model) View() tea.View {
v.WindowTitle = "lazysecrets"
return v
}
// selectedProblem is why the selected Sources row's account has nothing
// to show, if it failed: for the Details pane.
func (m Model) selectedProblem() string {
r, ok := m.sources.Selected()
if !ok || (r.kind != rowGroup && r.kind != rowSource) {
return ""
}
res, done := m.sourceResults[r.provider]
if !done || res.err == nil {
return ""
}
return m.problemText(r.provider, res.err.Error())
}
// problemText says what went wrong for a provider: for a sign-in, which
// account and how to sign in again; else err as given.
func (m Model) problemText(providerID, err string) string {
var auth *provider.AuthError
if r, ok := m.sourceResults[providerID]; ok && errors.As(r.err, &auth) {
return m.authText(auth)
}
return err
}
// loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError {
if r, ok := m.sources.Selected(); ok {
var auth *provider.AuthError
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
return auth
}
}
if m.auth != nil && len(m.auth.Login) > 0 {
return m.auth
}
return nil
}
// selectedAccount describes the account of the selected provider row, if
// its provider can.
func (m Model) selectedAccount() (provider.Account, bool) {
r, ok := m.sources.Selected()
if !ok || r.kind != rowGroup {
return provider.Account{}, false
}
if res := m.sourceResults[r.provider]; res.err != nil {
return provider.Account{}, false // the problem shows instead
}
a, ok := m.byID[r.provider].(provider.Accounts)
if !ok {
return provider.Account{}, false
}
return a.Account()
}
+77
View File
@@ -1022,3 +1022,80 @@ func TestMultiLineErrorsShownWhole(t *testing.T) {
t.Errorf("footer:\n%s", d.screen())
}
}
// signedOut is a second Azure account whose sign-in has expired.
type signedOut struct{ provider.Provider }
func (signedOut) ID() string { return "Azure:admin@contoso.com/t1" }
func (signedOut) Label() string { return "Azure · admin" }
func (signedOut) Sources(context.Context) ([]provider.Source, error) {
return nil, &provider.AuthError{Kind: provider.KindAzure, Account: "Azure · admin",
Login: []string{"az", "login", "--tenant", "t1"}, Err: errors.New("AADSTS70043")}
}
func TestSignInMessageOnceThenInDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)})
hint := "Azure · admin: sign-in required"
if !strings.Contains(lastLines(d.screen(), 2), hint) {
t.Fatalf("not said at start:\n%s", d.screen())
}
d.keys("j")
if strings.Contains(lastLines(d.screen(), 2), hint) {
t.Errorf("still in the footer after moving:\n%s", d.screen())
}
// On the account's group, Details says it, and L signs in to it.
d.keys("G")
if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" {
t.Fatalf("selected %+v", r)
}
if !strings.Contains(d.screen(), hint) || !strings.Contains(d.screen(), "press L to run az login --tenant t1") {
t.Errorf("not in Details:\n%s", d.screen())
}
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
t.Errorf("L signs in to %+v", a)
}
// Elsewhere Details is as usual.
d.keys("g")
if strings.Contains(d.screen(), hint) {
t.Errorf("shown for another account:\n%s", d.screen())
}
}
// lastLines is the bottom n lines of a screen.
func lastLines(scr string, n int) string {
lines := strings.Split(strings.TrimRight(scr, "\n"), "\n")
return strings.Join(lines[max(0, len(lines)-n):], "\n")
}
// described is an account that can say who it is.
type described struct{ provider.Provider }
func (described) Account() (provider.Account, bool) {
return provider.Account{
Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}},
Copy: "oid-123", CopyLabel: "object ID",
}, true
}
func TestAccountInDetails(t *testing.T) {
t.Parallel()
d := newDriver(t, described{fake.Azure(0)})
d.keys("g") // the group's row
if r, _ := d.m.sources.Selected(); r.kind != rowGroup {
t.Fatalf("not on the group: %+v", r)
}
if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") {
t.Errorf("account not in Details:\n%s", scr)
}
d.keys("y")
if got := d.clip().content; got != "oid-123" {
t.Errorf("y copied %q", got)
}
d.keys("j")
if strings.Contains(d.screen(), "Alice Example") {
t.Errorf("still shown on a subscription:\n%s", d.screen())
}
}
+22
View File
@@ -46,6 +46,8 @@ 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
@@ -71,6 +73,14 @@ 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 }
@@ -186,6 +196,18 @@ 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))
}
return out
}
return []string{muted(" Select a vault or scope to browse its secrets.")}
}
c := *d.container