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
This commit was merged in pull request #4.
This commit is contained in:
1 parent
87d98d038d
commit
a82f6bff27
3 files changed
+123
-15
No files matched your search
+65
-15
@@ -721,7 +721,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.waiting = 0
|
m.waiting = 0
|
||||||
m.askHandlerSetup()
|
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 {
|
if m.anyLoading() && !m.spinning {
|
||||||
m.spinning = true
|
m.spinning = true
|
||||||
cmds = append(cmds, m.spin.Tick)
|
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):
|
case key.Matches(msg, k.Edit):
|
||||||
return m.edit(), false
|
return m.edit(), false
|
||||||
case key.Matches(msg, k.Login):
|
case key.Matches(msg, k.Login):
|
||||||
if m.auth != nil && len(m.auth.Login) > 0 {
|
if auth := m.loginFor(); auth != nil {
|
||||||
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
|
c := exec.Command(auth.Login[0], auth.Login[1:]...)
|
||||||
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
||||||
}
|
}
|
||||||
case key.Matches(msg, k.Refresh):
|
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))
|
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.rebuildSources()
|
||||||
m.sources.Loading = m.sourcesPending > 0
|
m.sources.Loading = m.sourcesPending > 0
|
||||||
return m.autoLoad(msg)
|
return m.autoLoad(msg)
|
||||||
@@ -1532,14 +1540,27 @@ func (m *Model) openSelected() tea.Cmd {
|
|||||||
return nil
|
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) {
|
func (m *Model) noteErr(err error) {
|
||||||
var auth *provider.AuthError
|
var auth *provider.AuthError
|
||||||
if errors.As(err, &auth) {
|
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
|
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 {
|
func (m Model) anyLoading() bool {
|
||||||
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
||||||
m.detail.State() == panes.ValueLoading
|
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)
|
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2)
|
||||||
}
|
}
|
||||||
var lines []string
|
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() {
|
if !m.clipDeadline.IsZero() {
|
||||||
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
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))))
|
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"
|
v.WindowTitle = "lazysecrets"
|
||||||
return v
|
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
|
||||||
|
}
|
||||||
@@ -1022,3 +1022,50 @@ func TestMultiLineErrorsShownWhole(t *testing.T) {
|
|||||||
t.Errorf("footer:\n%s", d.screen())
|
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")
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ type Detail struct {
|
|||||||
|
|
||||||
versions []provider.Object // newest first; nil while unknown
|
versions []provider.Object // newest first; nil while unknown
|
||||||
note string // the user's note on what's shown
|
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
|
offset int
|
||||||
w, h 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
|
*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.
|
// SetDescribing marks extra metadata as being fetched.
|
||||||
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
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
|
muted := st.Muted.Render
|
||||||
|
|
||||||
if d.container == nil {
|
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.")}
|
return []string{muted(" Select a vault or scope to browse its secrets.")}
|
||||||
}
|
}
|
||||||
c := *d.container
|
c := *d.container
|
||||||
|
|||||||
Reference in new issue
Block a user