diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go index e315d51..f460c56 100644 --- a/appstate/remote_binding.go +++ b/appstate/remote_binding.go @@ -115,6 +115,22 @@ func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (Remot }, 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 { switch mode { case SyncModeAutomaticOnOpenSave: diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go index de0e021..22b6ab9 100644 --- a/appstate/remote_binding_test.go +++ b/appstate/remote_binding_test.go @@ -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) + } +} diff --git a/appstate/state.go b/appstate/state.go index 6533172..7dfdc7f 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -920,6 +920,26 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, 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 { session, ok := s.Session.(MutableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index 1da9d45..cab5a76 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -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) { t.Parallel() diff --git a/main.go b/main.go index c634376..ef447a6 100644 --- a/main.go +++ b/main.go @@ -296,6 +296,7 @@ type ui struct { useSavedAdvancedSyncRemote widget.Clickable openSelectedVaultRemote widget.Clickable saveCurrentRemoteBinding widget.Clickable + removeSelectedRemoteBinding widget.Clickable openSecuritySettings widget.Clickable openRemotePrefsHelp widget.Clickable closeAdvancedSync widget.Clickable @@ -2646,6 +2647,14 @@ func (u *ui) remoteSyncSettingsShortcutLabel() string { 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 { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false @@ -2719,6 +2728,49 @@ func (u *ui) saveCurrentRemoteBindingAction() error { 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 { 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) { 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) { u.runAction("share vault", u.shareCurrentVaultAction) } @@ -6857,7 +6912,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { return children }()...) } - if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() { + if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() { return crumbBar(gtx) } 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()) })) } + 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...) } diff --git a/main_test.go b/main_test.go index b95bbed..77a19ea 100644 --- a/main_test.go +++ b/main_test.go @@ -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) { 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) { t.Parallel() diff --git a/vault/model.go b/vault/model.go index 22e1cc1..31e171f 100644 --- a/vault/model.go +++ b/vault/model.go @@ -184,6 +184,17 @@ func (m *Model) UpsertEntry(entry 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) { for _, entry := range m.Entries { if entry.ID == id { @@ -204,6 +215,17 @@ func (m *Model) UpsertRemoteProfile(profile RemoteProfile) { 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) { for _, profile := range m.RemoteProfiles { if profile.ID == id {