diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 435ff91..01afc31 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -721,7 +721,8 @@ 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()) if m.anyLoading() && !m.spinning { m.spinning = true cmds = append(cmds, m.spin.Tick) @@ -881,8 +882,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 +1262,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) @@ -1532,14 +1540,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 +1581,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 +1635,42 @@ 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 +} diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index b4d501a..a122c1a 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1022,3 +1022,50 @@ 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") +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 88ca4a4..f1f2e15 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -46,6 +46,7 @@ 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 offset int w, h int @@ -71,6 +72,10 @@ 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 } + // SetDescribing marks extra metadata as being fetched. func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } @@ -186,6 +191,12 @@ func (d Detail) lines(st styles.Styles) []string { muted := st.Muted.Render if d.container == nil { + 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