Add remote sync removal flow

This commit is contained in:
Joe Julian
2026-04-06 20:53:14 -07:00
parent 26748d4b84
commit 84512172f3
7 changed files with 322 additions and 1 deletions
+16
View File
@@ -115,6 +115,22 @@ func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (Remot
}, nil }, nil
} }
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
if model == nil {
return fmt.Errorf("model is required")
}
if strings.TrimSpace(binding.RemoteProfileID) == "" {
return fmt.Errorf("remote profile id is required")
}
if strings.TrimSpace(binding.CredentialEntryID) == "" {
return fmt.Errorf("credential entry id is required")
}
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
model.RemoveEntryByID(binding.CredentialEntryID)
return nil
}
func normalizeSyncMode(mode SyncMode) SyncMode { func normalizeSyncMode(mode SyncMode) SyncMode {
switch mode { switch mode {
case SyncModeAutomaticOnOpenSave: case SyncModeAutomaticOnOpenSave:
+36
View File
@@ -212,3 +212,39 @@ func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
}) })
} }
} }
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Password: "token-1",
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
}},
}
err := RemoveRemoteBinding(&model, RemoteBinding{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if got := len(model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
+20
View File
@@ -920,6 +920,26 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding,
return binding, nil return binding, nil
} }
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := RemoveRemoteBinding(&model, binding); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) CreateGroup(name string) error { func (s *State) CreateGroup(name string) error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
+40
View File
@@ -1168,6 +1168,46 @@ func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
} }
} }
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Password: "token-1",
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
}},
}}
state := State{Session: sess}
err := state.RemoveRemoteBinding(RemoteBinding{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
}
if got := len(sess.model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) { func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel() t.Parallel()
+64 -1
View File
@@ -296,6 +296,7 @@ type ui struct {
useSavedAdvancedSyncRemote widget.Clickable useSavedAdvancedSyncRemote widget.Clickable
openSelectedVaultRemote widget.Clickable openSelectedVaultRemote widget.Clickable
saveCurrentRemoteBinding widget.Clickable saveCurrentRemoteBinding widget.Clickable
removeSelectedRemoteBinding widget.Clickable
openSecuritySettings widget.Clickable openSecuritySettings widget.Clickable
openRemotePrefsHelp widget.Clickable openRemotePrefsHelp widget.Clickable
closeAdvancedSync widget.Clickable closeAdvancedSync widget.Clickable
@@ -2646,6 +2647,14 @@ func (u *ui) remoteSyncSettingsShortcutLabel() string {
return "Remote Sync Settings" return "Remote Sync Settings"
} }
func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool {
return u.shouldShowRemoteSyncSettingsShortcut()
}
func (u *ui) removeRemoteSyncShortcutLabel() string {
return "Stop Using Remote Sync"
}
func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { func (u *ui) shouldShowRemoteSyncSetupShortcut() bool {
if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return false return false
@@ -2719,6 +2728,49 @@ func (u *ui) saveCurrentRemoteBindingAction() error {
return nil return nil
} }
func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) {
localPath := strings.TrimSpace(binding.LocalVaultPath)
profileID := strings.TrimSpace(binding.RemoteProfileID)
credentialID := strings.TrimSpace(binding.CredentialEntryID)
for i := range u.recentRemotes {
record := &u.recentRemotes[i]
if strings.TrimSpace(record.LocalVaultPath) != localPath {
continue
}
if strings.TrimSpace(record.RemoteProfileID) != profileID {
continue
}
if strings.TrimSpace(record.CredentialEntryID) != credentialID {
continue
}
record.LocalVaultPath = ""
record.RemoteProfileID = ""
record.CredentialEntryID = ""
record.SyncMode = ""
}
}
func (u *ui) removeSelectedRemoteBindingAction() error {
binding, ok := u.selectedVaultRemoteBinding()
if !ok {
return fmt.Errorf("no saved remote sync target is selected")
}
if err := u.state.RemoveRemoteBinding(binding); err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.stripRecentRemoteBinding(binding)
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.showStatusMessage("Remote sync is no longer set up for this vault.")
return nil
}
func (u *ui) saveCurrentRemoteBindingHeading() string { func (u *ui) saveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target" return "Bind this local vault to the current remote target"
} }
@@ -4291,6 +4343,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.saveCurrentRemoteBinding.Clicked(gtx) { for u.saveCurrentRemoteBinding.Clicked(gtx) {
u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) u.runAction("save remote binding", u.saveCurrentRemoteBindingAction)
} }
for u.removeSelectedRemoteBinding.Clicked(gtx) {
u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction)
}
for u.shareCurrentVault.Clicked(gtx) { for u.shareCurrentVault.Clicked(gtx) {
u.runAction("share vault", u.shareCurrentVaultAction) u.runAction("share vault", u.shareCurrentVaultAction)
} }
@@ -6857,7 +6912,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
return children return children
}()...) }()...)
} }
if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() { if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() {
return crumbBar(gtx) return crumbBar(gtx)
} }
children := []layout.FlexChild{ children := []layout.FlexChild{
@@ -6885,6 +6940,14 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
})) }))
} }
if u.shouldShowRemoveRemoteSyncShortcut() {
if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() || u.shouldShowRemoteSyncSettingsShortcut() {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
} }
+124
View File
@@ -5904,6 +5904,41 @@ func TestUIRemoteSyncSettingsShortcutLabelUsesClearLanguage(t *testing.T) {
} }
} }
func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Path: []string{"Crew", "Internet"},
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
}},
})
u.state.Section = appstate.SectionEntries
if !u.shouldShowRemoveRemoteSyncShortcut() {
t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true when a saved binding exists")
}
}
func TestUIRemoveRemoteSyncShortcutLabelUsesClearLanguage(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" {
t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got)
}
}
func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) { func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -6280,6 +6315,95 @@ func TestUIRemoteSyncSetupCanPersistManualSyncMode(t *testing.T) {
} }
} }
func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
writeKDBXMainTestFile(t, currentPath, vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "WebDAV Sign-In",
Username: "tjulian",
Password: "token-1",
Path: []string{"Crew", "Internet"},
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
}},
}, key)
dir := t.TempDir()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
})
u.masterPassword.SetText(key.Password)
u.vaultPath.SetText(currentPath)
u.recentRemotes = []recentRemoteRecord{{
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
LocalVaultPath: currentPath,
RemoteProfileID: "family-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
}}
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if err := u.removeSelectedRemoteBindingAction(); err != nil {
t.Fatalf("removeSelectedRemoteBindingAction() error = %v", err)
}
if got := u.selectedVaultRemoteProfileID; got != "" {
t.Fatalf("selectedVaultRemoteProfileID = %q, want empty", got)
}
if got := u.selectedVaultRemoteCredentialEntryID; got != "" {
t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want empty", got)
}
if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual {
t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got)
}
if got := u.recentRemotes[0].RemoteProfileID; got != "" {
t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want empty", got)
}
if got := u.recentRemotes[0].CredentialEntryID; got != "" {
t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want empty", got)
}
if got := u.recentRemotes[0].SyncMode; got != "" {
t.Fatalf("recentRemotes[0].SyncMode = %q, want empty", got)
}
if got := u.state.StatusMessage; got != "Remote sync is no longer set up for this vault." {
t.Fatalf("StatusMessage = %q, want removal status", got)
}
if u.shouldShowDirectRemoteSyncShortcut() {
t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false after removing binding")
}
if !u.shouldShowRemoteSyncSetupShortcut() {
t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true after removing binding")
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.masterPassword.SetText(key.Password)
reopened.vaultPath.SetText(currentPath)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("reopened.openVaultAction() error = %v", err)
}
if got := len(reopened.availableRemoteProfiles()); got != 0 {
t.Fatalf("len(reopened.availableRemoteProfiles()) = %d, want 0", got)
}
if got := len(reopened.availableRemoteCredentialEntries()); got != 0 {
t.Fatalf("len(reopened.availableRemoteCredentialEntries()) = %d, want 0", got)
}
}
func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) { func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) {
t.Parallel() t.Parallel()
+22
View File
@@ -184,6 +184,17 @@ func (m *Model) UpsertEntry(entry Entry) {
m.Entries = append(m.Entries, cloneEntry(entry)) m.Entries = append(m.Entries, cloneEntry(entry))
} }
func (m *Model) RemoveEntryByID(id string) bool {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
return true
}
return false
}
func (m *Model) EntryByID(id string) (Entry, error) { func (m *Model) EntryByID(id string) (Entry, error) {
for _, entry := range m.Entries { for _, entry := range m.Entries {
if entry.ID == id { if entry.ID == id {
@@ -204,6 +215,17 @@ func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
m.RemoteProfiles = append(m.RemoteProfiles, profile) m.RemoteProfiles = append(m.RemoteProfiles, profile)
} }
func (m *Model) RemoveRemoteProfileByID(id string) bool {
for i := range m.RemoteProfiles {
if m.RemoteProfiles[i].ID != id {
continue
}
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
return true
}
return false
}
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) { func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
for _, profile := range m.RemoteProfiles { for _, profile := range m.RemoteProfiles {
if profile.ID == id { if profile.ID == id {