feat: pick the share key in Go to, or by reference
Sharing without share.key opens Go to as a picker of secrets instead of asking for a link: it lists only secrets, enter picks one, and a pasted reference picks it too. The key, there and in share.key or --key, can be a secret's lazysecrets:// link, Key Vault URL or @Microsoft.KeyVault(...) reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
13 files changed
+152
-67
No files matched your search
@@ -273,9 +273,7 @@ type Model struct {
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
|
||||
// shareKey is the config's share.key; lastShareKey the key last
|
||||
// typed when sharing, offered next time.
|
||||
shareKey, lastShareKey string
|
||||
shareKey string // the config's share.key
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
|
||||
@@ -818,6 +818,9 @@ func (m *Model) openText(s string) tea.Cmd {
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if m.search != nil && m.search.pick != nil {
|
||||
return m.pickPasted(msg.Content) // a reference to the secret
|
||||
}
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
@@ -52,6 +53,12 @@ type searchView struct {
|
||||
res int // cursor in shown
|
||||
here string // the scope of the source Go to was opened from, if any
|
||||
|
||||
// pick, if set, makes Go to a picker of secrets, for title: it lists
|
||||
// secrets only, and enter gives the selected one's link to pick
|
||||
// instead of going there.
|
||||
pick func(m *Model, r ref.Ref) tea.Cmd
|
||||
pickTitle, pickNote string
|
||||
|
||||
// loaded are the scopes a search has loaded since the popup opened:
|
||||
// typing loads the scope once, so lists that fail aren't asked for
|
||||
// again on every key.
|
||||
@@ -267,6 +274,9 @@ func (v *searchView) match() []option {
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.pick != nil {
|
||||
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
|
||||
}
|
||||
q := v.input.Value()
|
||||
if q == "" {
|
||||
return slices.Clone(cand)
|
||||
@@ -423,9 +433,15 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return nil
|
||||
case key.Matches(msg, k.sel):
|
||||
switch {
|
||||
case v.results && v.res < len(v.shown) && v.pick != nil:
|
||||
r, _ := pickable(v.shown[v.res])
|
||||
m.search = nil
|
||||
return v.pick(m, r)
|
||||
case v.results && v.res < len(v.shown):
|
||||
m.search = nil
|
||||
return v.shown[v.res].run(m)
|
||||
case !v.results && v.pick != nil:
|
||||
v.results = true // a picker only narrows to the scope
|
||||
case !v.results && v.onSource():
|
||||
m.search = nil
|
||||
return v.scope().run(m)
|
||||
@@ -745,6 +761,9 @@ func (m Model) searchViewBox() string {
|
||||
} else {
|
||||
body = append(body, "") // the same height on every tab
|
||||
}
|
||||
if v.pick != nil && v.pickNote != "" {
|
||||
body = append(body, " "+m.st.Muted.Render(v.pickNote))
|
||||
}
|
||||
km := m.keys
|
||||
var foot string
|
||||
switch {
|
||||
@@ -753,17 +772,20 @@ func (m Model) searchViewBox() string {
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
|
||||
default:
|
||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
|
||||
}
|
||||
// Tabs in the title, the current one underlined, like the panes.
|
||||
ts := panes.TitleStyle(m.st, focused)
|
||||
head := ts.Render(goToTitle + " ")
|
||||
if v.pick != nil {
|
||||
head = ts.Render(v.pickTitle + " ")
|
||||
}
|
||||
for i, name := range tabNames {
|
||||
if i > 0 {
|
||||
head += ts.Render(" - ")
|
||||
@@ -776,3 +798,21 @@ func (m Model) searchViewBox() string {
|
||||
}
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
}
|
||||
|
||||
// selectLabel names what enter does with the selected entry.
|
||||
func (v *searchView) selectLabel() string {
|
||||
if v.pick != nil {
|
||||
return "pick"
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
// pickable is the link of a Go to entry that a picker can pick: a
|
||||
// secret (not a key or certificate).
|
||||
func pickable(o option) (ref.Ref, bool) {
|
||||
r, err := ref.Parse(o.star)
|
||||
if err != nil || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
|
||||
return ref.Ref{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||
case msg.key == (ref.Ref{}):
|
||||
return m.askShareKey(msg.sharing)
|
||||
return m.pickShareKey(msg.sharing)
|
||||
default:
|
||||
m.confirmShare(msg.sharing)
|
||||
}
|
||||
@@ -118,40 +118,34 @@ func (m *Model) confirmShare(s sharing) {
|
||||
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
||||
m.showMenu("Share as an encrypted link", lines, []option{
|
||||
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
|
||||
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }},
|
||||
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
|
||||
})
|
||||
}
|
||||
|
||||
// askShareKey asks for the link to the secret to encrypt with.
|
||||
func (m *Model) askShareKey(s sharing) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "key: "
|
||||
in.Placeholder = "lazysecrets:// link to a secret"
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
in.SetValue(m.lastShareKey)
|
||||
if s.key != (ref.Ref{}) {
|
||||
in.SetValue(s.key.String())
|
||||
// pickShareKey asks which secret to encrypt with, in Go to.
|
||||
func (m *Model) pickShareKey(s sharing) tea.Cmd {
|
||||
cmd := m.openGoTo(tabSearch, true)
|
||||
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
|
||||
s.key = key
|
||||
m.confirmShare(s)
|
||||
return nil
|
||||
}
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: "Encrypt with which secret?", input: &in,
|
||||
lines: []string{
|
||||
m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."),
|
||||
m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."),
|
||||
},
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
key, err := share.ParseKey(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.lastShareKey = key.String()
|
||||
s.key = key
|
||||
m.confirmShare(s)
|
||||
return nil
|
||||
},
|
||||
m.search.pickTitle = "Encrypt with which secret?"
|
||||
m.search.pickNote = "Whoever can read it can open the link. share.key sets a default."
|
||||
m.fillSearch(false)
|
||||
return tea.Batch(cmd, m.searchLoads())
|
||||
}
|
||||
|
||||
// pickPasted picks a pasted reference to a secret, while picking one.
|
||||
func (m *Model) pickPasted(text string) tea.Cmd {
|
||||
key, err := share.ParseKey(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
pick := m.search.pick
|
||||
m.search = nil
|
||||
return pick(m, key)
|
||||
}
|
||||
|
||||
// seal reads the key and encrypts the files with it.
|
||||
|
||||
@@ -18,9 +18,6 @@ import (
|
||||
// which take about a second on purpose.
|
||||
const scryptWait = 20 * time.Second
|
||||
|
||||
// shareKeyLink is a demo secret, long enough to be a key.
|
||||
const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password"
|
||||
|
||||
// cloneWith makes a clone of git.example.com/team/app holding files.
|
||||
func cloneWith(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
@@ -35,7 +32,7 @@ func cloneWith(t *testing.T, files map[string]string) string {
|
||||
|
||||
// shareFrom shares a repo's env files from lazysecrets started in it,
|
||||
// returning the link copied.
|
||||
func shareFrom(t *testing.T, root string) string {
|
||||
func shareFrom(t *testing.T, root string, paste bool) string {
|
||||
t.Helper()
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
@@ -44,12 +41,32 @@ func shareFrom(t *testing.T, root string) string {
|
||||
t.Fatalf("selected %v, want the repo", src.ID)
|
||||
}
|
||||
d.keys("S", "f")
|
||||
// No share.key set: it asks which secret.
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil {
|
||||
t.Fatalf("no key prompt: %s", d.m.flash.text)
|
||||
// No share.key set: it asks which secret, in Go to, which lists
|
||||
// only secrets.
|
||||
v := d.m.search
|
||||
if v == nil || v.pick == nil {
|
||||
t.Fatalf("no secret picker: %s", d.m.flash.text)
|
||||
}
|
||||
if paste {
|
||||
// A reference pasted is picked as it is.
|
||||
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
|
||||
} else {
|
||||
for _, r := range "app-prod db-password" {
|
||||
d.keys(string(r))
|
||||
}
|
||||
if len(v.shown) == 0 {
|
||||
t.Fatalf("no secrets to pick:\n%s", d.screen())
|
||||
}
|
||||
for _, o := range v.shown {
|
||||
if _, ok := pickable(o); !ok {
|
||||
t.Errorf("not a secret: %s", o.label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Encrypt with which secret?") {
|
||||
t.Errorf("not titled as a picker:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
d.send(tea.PasteMsg{Content: shareKeyLink})
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
|
||||
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
|
||||
}
|
||||
@@ -67,7 +84,7 @@ func TestShareFilesRoundTrip(t *testing.T) {
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{
|
||||
".env": "DB_PASSWORD=hunter2\n",
|
||||
"prod.env": "TOKEN=abc\n",
|
||||
}))
|
||||
}), false)
|
||||
|
||||
// A colleague's clone: .env is there already, prod.env isn't.
|
||||
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
||||
@@ -104,7 +121,7 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}))
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
||||
d.wait = scryptWait
|
||||
|
||||
Reference in new issue
Block a user