diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go index dacb4ef..e315d51 100644 --- a/appstate/remote_binding.go +++ b/appstate/remote_binding.go @@ -103,6 +103,7 @@ func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (Remot Title: input.CredentialTitle, Username: input.Username, Password: input.Password, + URL: input.BaseURL, Path: append([]string(nil), input.CredentialPath...), }) diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go index 31a5703..de0e021 100644 --- a/appstate/remote_binding_test.go +++ b/appstate/remote_binding_test.go @@ -151,6 +151,9 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) if credentials.Username != "tjulian" || credentials.Password != "token-1" { t.Fatalf("stored credential entry = %#v, want tjulian/token-1", credentials) } + if credentials.URL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL) + } if binding.LocalVaultPath != "/tmp/family.kdbx" { t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath) diff --git a/main.go b/main.go index 739d38b..5bc6890 100644 --- a/main.go +++ b/main.go @@ -395,6 +395,7 @@ type ui struct { recentRemoteClicks []widget.Clickable vaultRemoteProfileClicks []widget.Clickable vaultRemoteCredentialClicks []widget.Clickable + syncRemoteCredentialClicks []widget.Clickable removeCustomFields []widget.Clickable state appstate.State visible []entry @@ -444,6 +445,7 @@ type ui struct { syncRemotePath widget.Editor syncRemoteUsername widget.Editor syncRemotePassword widget.Editor + selectedSyncRemoteCredentialEntryID string syncDialogOpen bool syncMenuOpen bool mainMenuOpen bool @@ -2177,6 +2179,36 @@ func (u *ui) availableRemoteCredentialEntries() []vault.Entry { return entries } +func normalizeRemoteCredentialURL(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimRight(raw, "/") + return raw +} + +func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { + if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote { + return nil + } + baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text()) + if baseURL == "" { + return nil + } + var matches []vault.Entry + for _, entry := range u.availableRemoteCredentialEntries() { + if normalizeRemoteCredentialURL(entry.URL) != baseURL { + continue + } + matches = append(matches, entry) + } + return matches +} + +func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) { + u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID) + u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username)) + u.syncRemotePassword.SetText(entry.Password) +} + func (u *ui) selectVaultRemoteProfile(id string) { id = strings.TrimSpace(id) u.selectedVaultRemoteProfileID = id @@ -4023,6 +4055,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } } } + for i := range u.syncRemoteCredentialClicks { + for u.syncRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.matchingAdvancedSyncRemoteCredentialEntries() + if i < len(entries) { + u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) + } + } + } for u.openSelectedVaultRemote.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -4766,6 +4806,10 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { } func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { + matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) { + u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials)) + } return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -4814,7 +4858,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.syncSourceMode == syncSourceRemote { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children := []layout.FlexChild{ layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)), @@ -4824,6 +4868,40 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncPasswordField(gtx) }), + } + if len(matchingCredentials) > 0 { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + for i, entry := range matchingCredentials { + i := i + entry := entry + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := entry.Title + if strings.TrimSpace(entry.Username) != "" { + label += " ยท " + strings.TrimSpace(entry.Username) + } + selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), label) + lbl.Color = accentColor + return lbl.Layout(gtx) + }) + }) + }), + ) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children..., ) } if supportsDesktopFilePicker(runtime.GOOS) { diff --git a/main_test.go b/main_test.go index 96415dc..7f99476 100644 --- a/main_test.go +++ b/main_test.go @@ -5826,6 +5826,9 @@ func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) { t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path) } + if entry.URL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("credential URL = %q, want remote.php/dav URL", entry.URL) + } } } if !found { @@ -5843,6 +5846,53 @@ func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) } } +func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Bellagio", Username: "rustyryan", URL: "https://dav.example.invalid/remote.php/dav/", Path: []string{"Crew", "Internet"}}, + {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "entry-3", Title: "WebDAV Sign-In", Username: "tjulian", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Internet"}}, + }, + }) + u.syncSourceMode = syncSourceRemote + u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + + got := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(got) != 2 { + t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got)) + } + if got[0].ID != "entry-1" || got[1].ID != "entry-3" { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want Bellagio and WebDAV Sign-In matches", got) + } +} + +func TestUIApplyAdvancedSyncRemoteCredentialEntryFillsCredentials(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + entry := vault.Entry{ + ID: "remote-creds-1", + Title: "WebDAV Sign-In", + Username: "tjulian", + Password: "token-1", + URL: "https://dav.example.invalid/remote.php/dav", + } + + u.applyAdvancedSyncRemoteCredentialEntry(entry) + + if got := u.syncRemoteUsername.Text(); got != "tjulian" { + t.Fatalf("syncRemoteUsername = %q, want tjulian", got) + } + if got := u.syncRemotePassword.Text(); got != "token-1" { + t.Fatalf("syncRemotePassword = %q, want token-1", got) + } + if got := u.selectedSyncRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedSyncRemoteCredentialEntryID = %q, want remote-creds-1", got) + } +} + func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) { t.Parallel()