diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index d72a7a8..940a006 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -12,6 +12,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" @@ -288,7 +289,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { switch { case msg.err != nil: m.noteErr(msg.err) - m.flash = flash{text: "open: " + msg.err.Error(), isErr: true} + m.openFailed(msg.sealed, msg.err, false) case msg.dir == "": return m.askShareDir(msg) default: @@ -302,6 +303,38 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { return nil } +// openFailed says why a share link couldn't be decrypted, in a popup +// where its offer was: its first line, or with whole all of it +// (Key Vault's refusals say what to check on later lines). From there, +// the key's secret can be gone to: its version, which may be disabled. +func (m *Model) openFailed(s share.Sealed, err error, whole bool) { + width := max(30, min(m.w-12, 76)) + text := capitalize(err.Error()) + first, rest, _ := strings.Cut(text, "\n") + cut := !whole && (rest != "" || ansi.StringWidth(first) > width-2) + if !whole { + text = ansi.Truncate(first, width-2, "…") + } + var lines []string + for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") { + if i == 0 { + l = "✗ " + l + } else if l != "" { + l = " " + l + } + lines = append(lines, m.st.Error.Render(l)) + } + var opts []option + if cut { + opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd { + m.openFailed(s, err, true) + return nil + }}) + } + opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }}) + m.showMenu("Can't decrypt the shared files", lines, opts) +} + // askShareDir asks where to save files from a repo that isn't here. It // stays until they're saved, saying why when they can't be. func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 6e5e214..220d4a9 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -1,6 +1,8 @@ package app import ( + "context" + "errors" "net/url" "os" "os/exec" @@ -312,3 +314,42 @@ func TestPlainEnvNotPickableFromFavourites(t *testing.T) { } t.Fatalf("the starred key isn't among the favourites: %d", len(favs)) } + +// refusing is a provider whose values can't be read, like a disabled +// version in Key Vault. +type refusing struct{ provider.Provider } + +func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) { + return provider.SecretValue{}, &provider.Denied{ + Err: errors.New("Operation get is not allowed on a disabled secret."), + Checks: []string{"the secret's version is enabled"}, + } +} + +func TestOpenFailsInAPopup(t *testing.T) { + key, _ := share.ParseKey("https://" + kv + "/secrets/db-password") + link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"}, + []byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules) + if err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)}) + d.wait = scryptWait + d.send(tea.PasteMsg{Content: link.String()}) + d.keys("o") + p := d.m.prompt + if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") { + t.Fatalf("not in a popup:\n%s", d.screen()) + } + if strings.Contains(d.screen(), "version is enabled") { + t.Error("the whole message is shown before asking") + } + d.keys("r") + if !strings.Contains(d.screen(), "version is enabled") { + t.Errorf("r doesn't show the whole message:\n%s", d.screen()) + } + d.keys("g") + if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) { + t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen()) + } +}