Match Advanced Sync credentials by URL

This commit is contained in:
Joe Julian
2026-04-06 18:13:30 -07:00
parent 8866d9c06f
commit fc59fb4869
4 changed files with 133 additions and 1 deletions
+1
View File
@@ -103,6 +103,7 @@ func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (Remot
Title: input.CredentialTitle, Title: input.CredentialTitle,
Username: input.Username, Username: input.Username,
Password: input.Password, Password: input.Password,
URL: input.BaseURL,
Path: append([]string(nil), input.CredentialPath...), Path: append([]string(nil), input.CredentialPath...),
}) })
+3
View File
@@ -151,6 +151,9 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T)
if credentials.Username != "tjulian" || credentials.Password != "token-1" { if credentials.Username != "tjulian" || credentials.Password != "token-1" {
t.Fatalf("stored credential entry = %#v, want tjulian/token-1", credentials) 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" { if binding.LocalVaultPath != "/tmp/family.kdbx" {
t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath) t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath)
+79 -1
View File
@@ -395,6 +395,7 @@ type ui struct {
recentRemoteClicks []widget.Clickable recentRemoteClicks []widget.Clickable
vaultRemoteProfileClicks []widget.Clickable vaultRemoteProfileClicks []widget.Clickable
vaultRemoteCredentialClicks []widget.Clickable vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable removeCustomFields []widget.Clickable
state appstate.State state appstate.State
visible []entry visible []entry
@@ -444,6 +445,7 @@ type ui struct {
syncRemotePath widget.Editor syncRemotePath widget.Editor
syncRemoteUsername widget.Editor syncRemoteUsername widget.Editor
syncRemotePassword widget.Editor syncRemotePassword widget.Editor
selectedSyncRemoteCredentialEntryID string
syncDialogOpen bool syncDialogOpen bool
syncMenuOpen bool syncMenuOpen bool
mainMenuOpen bool mainMenuOpen bool
@@ -2177,6 +2179,36 @@ func (u *ui) availableRemoteCredentialEntries() []vault.Entry {
return entries 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) { func (u *ui) selectVaultRemoteProfile(id string) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
u.selectedVaultRemoteProfileID = 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) { for u.openSelectedVaultRemote.Clicked(gtx) {
if u.lifecycleBusy() { if u.lifecycleBusy() {
continue continue
@@ -4766,6 +4806,10 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
} }
func (u *ui) syncDialogContent(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 material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote { 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(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(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)), 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncPasswordField(gtx) 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) { if supportsDesktopFilePicker(runtime.GOOS) {
+50
View File
@@ -5826,6 +5826,9 @@ func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T)
if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) { if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) {
t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path) 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 { 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) { func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) {
t.Parallel() t.Parallel()