diff --git a/.gitignore b/.gitignore index 125a61f..5564ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ build/ *.apk +keepassgo +packaging/archlinux/keepassgo-git/*.pkg.tar.zst +packaging/archlinux/keepassgo-git/PKGBUILD +packaging/archlinux/keepassgo-git/pkg/ +packaging/archlinux/keepassgo-git/src/ +packaging/archlinux/keepassgo-git/keepassgo/ diff --git a/AGENTS.md b/AGENTS.md index 97599c8..8f8cfc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,9 @@ These features are product requirements, not “nice to have” ideas. - Phone should optimize for low tap count, not purity of mobile patterns. - The stacked phone layout is the current preferred phone direction. - Do not reintroduce the abandoned phone flow mode unless explicitly requested. +- Make all test strings `Heist Movie` themed. Use characters, crews, casinos, + vaults, and locations from heist movies so test fixtures stay obviously fake + and consistent with the product theme. ## Architecture diff --git a/Makefile b/Makefile index a5056a8..53ecf78 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,11 @@ ANDROID_MIN_SDK ?= 28 ANDROID_TARGET_SDK ?= 35 SIGNKEY ?= SIGNPASS ?= +ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git +ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl +ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD +ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)") +ARCH_REPO_DIR ?= $(CURDIR) GOGIO_SIGN_FLAGS := ifneq ($(strip $(SIGNKEY)),) @@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk +.PHONY: apk archlinux-pkgbuild apk: android/keepassgo-android.jar @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) -d "$$tmpdir" \ $$(find androidsrc -name '\''*.java'\'' | sort); \ "$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .' + +archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile + @mkdir -p "$(ARCH_PKG_DIR)" + @sed \ + -e 's|@PKGVER@|$(ARCH_PKGVER)|g' \ + -e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \ + "$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)" diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 0990299..8889103 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -22,3 +22,26 @@ android:name="android.accessibilityservice" android:resource="@xml/keepassgo_accessibility_service" /> + + + + + + + + + + + + + + + + diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar index 89509e5..556c6a8 100644 Binary files a/android/keepassgo-android.jar and b/android/keepassgo-android.jar differ diff --git a/android_share_android.go b/android_share_android.go new file mode 100644 index 0000000..865520e --- /dev/null +++ b/android_share_android.go @@ -0,0 +1,184 @@ +//go:build android + +package main + +/* +#cgo CFLAGS: -Werror +#cgo LDFLAGS: -landroid + +#include +#include + +static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { + return (*env)->GetObjectClass(env, obj); +} + +static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetMethodID(env, clazz, name, sig); +} + +static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetStaticMethodID(env, clazz, name, sig); +} + +static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + return (*env)->CallObjectMethodA(env, obj, method, args); +} + +static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) { + (*env)->CallStaticVoidMethodA(env, cls, methodID, args); +} + +static jvalue jni_ValueObject(jobject obj) { + jvalue value; + value.l = obj; + return value; +} + +static jthrowable jni_ExceptionOccurred(JNIEnv *env) { + return (*env)->ExceptionOccurred(env); +} + +static void jni_ExceptionClear(JNIEnv *env) { + (*env)->ExceptionClear(env); +} + +static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { + return (*env)->NewString(env, unicodeChars, len); +} + +static int jni_IsNull(jobject obj) { + return obj == NULL; +} +*/ +import "C" + +import ( + "fmt" + "strings" + "unicode/utf16" + "unsafe" + + "gioui.org/app" + _ "unsafe" +) + +type androidVaultSharer struct{} + +//go:linkname gioJavaVM gioui.org/app.javaVM +func gioJavaVM() *C.JavaVM + +//go:linkname gioRunInJVM gioui.org/app.runInJVM +func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv)) + +func newPlatformVaultSharer(goos string) vaultSharer { + return androidVaultSharer{} +} + +func (androidVaultSharer) ShareVault(path, title string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("vault path is required") + } + ctx := C.jobject(unsafe.Pointer(app.AppContext())) + if C.jni_IsNull(ctx) != 0 { + return fmt.Errorf("android app context is not available") + } + var callErr error + gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) { + sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare") + if err != nil { + callErr = err + return + } + methodName := cString("shareVault") + defer C.free(unsafe.Pointer(methodName)) + methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V") + defer C.free(unsafe.Pointer(methodSig)) + method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig) + if method == nil { + callErr = androidJNIError(env, "resolve shareVault method") + if callErr == nil { + callErr = fmt.Errorf("resolve shareVault method") + } + return + } + + jPath := androidJavaString(env, path) + jTitle := androidJavaString(env, title) + args := [3]C.jvalue{} + args[0] = C.jni_ValueObject(ctx) + args[1] = C.jni_ValueObject(C.jobject(jPath)) + args[2] = C.jni_ValueObject(C.jobject(jTitle)) + C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0]) + callErr = androidJNIError(env, "share vault") + }) + return callErr +} + +func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) { + var zeroClass C.jclass + contextClass := C.jni_GetObjectClass(env, ctx) + getClassLoaderName := cString("getClassLoader") + defer C.free(unsafe.Pointer(getClassLoaderName)) + getClassLoaderSig := cString("()Ljava/lang/ClassLoader;") + defer C.free(unsafe.Pointer(getClassLoaderSig)) + getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig) + if getClassLoader == nil { + if err := androidJNIError(env, "resolve getClassLoader"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve getClassLoader") + } + classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil) + if err := androidJNIError(env, "load class loader"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(classLoader) != 0 { + return zeroClass, fmt.Errorf("android class loader is nil") + } + + classLoaderClass := C.jni_GetObjectClass(env, classLoader) + loadClassName := cString("loadClass") + defer C.free(unsafe.Pointer(loadClassName)) + loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;") + defer C.free(unsafe.Pointer(loadClassSig)) + loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig) + if loadClass == nil { + if err := androidJNIError(env, "resolve loadClass"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve loadClass") + } + + jClassName := androidJavaString(env, name) + args := [1]C.jvalue{} + args[0] = C.jni_ValueObject(C.jobject(jClassName)) + loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0]) + if err := androidJNIError(env, "load AndroidShare class"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(loaded) != 0 { + return zeroClass, fmt.Errorf("load AndroidShare class returned nil") + } + return C.jclass(loaded), nil +} + +func androidJNIError(env *C.JNIEnv, action string) error { + if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 { + C.jni_ExceptionClear(env) + return fmt.Errorf("%s: Java exception", action) + } + return nil +} + +func androidJavaString(env *C.JNIEnv, s string) C.jstring { + chars := utf16.Encode([]rune(s)) + if len(chars) == 0 { + return C.jni_NewString(env, nil, 0) + } + return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars))) +} + +func cString(value string) *C.char { + return C.CString(value) +} diff --git a/android_share_stub.go b/android_share_stub.go new file mode 100644 index 0000000..80a101f --- /dev/null +++ b/android_share_stub.go @@ -0,0 +1,7 @@ +//go:build !android + +package main + +func newPlatformVaultSharer(goos string) vaultSharer { + return nil +} diff --git a/androidsrc/org/julianfamily/keepassgo/AndroidShare.java b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java new file mode 100644 index 0000000..9f62da8 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java @@ -0,0 +1,81 @@ +package org.julianfamily.keepassgo; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public final class AndroidShare { + private static final String DEFAULT_TITLE = "KeePassGO Vault"; + + private AndroidShare() { + } + + public static void shareVault(Context context, String path, String title) throws IOException { + File source = new File(path); + if (!source.isFile()) { + throw new IOException("vault file not found: " + path); + } + File shared = copyToSharedExport(context, source); + Uri uri = SharedVaultProvider.uriForFile(shared.getName()); + + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("application/x-keepass2"); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName())); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + Intent chooser = Intent.createChooser(send, "Share vault"); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(chooser); + } + + static File sharedDirectory(Context context) { + return new File(new File(context.getFilesDir(), "keepassgo"), "shared"); + } + + private static File copyToSharedExport(Context context, File source) throws IOException { + File dir = sharedDirectory(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File target = new File(dir, sanitizeFilename(source.getName())); + try (FileInputStream in = new FileInputStream(source); + FileOutputStream out = new FileOutputStream(target, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + return target; + } + + private static String sanitizeFilename(String name) { + String trimmed = name == null ? "" : name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + if (trimmed.endsWith(".kdbx")) { + return trimmed; + } + return trimmed + ".kdbx"; + } + + private static String sanitizeTitle(String title, String fallbackName) { + String trimmed = title == null ? "" : title.trim(); + if (!trimmed.isEmpty()) { + return trimmed; + } + String fallback = fallbackName == null ? "" : fallbackName.trim(); + if (!fallback.isEmpty()) { + return fallback; + } + return DEFAULT_TITLE; + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java new file mode 100644 index 0000000..b669fa7 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java @@ -0,0 +1,131 @@ +package org.julianfamily.keepassgo; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class SharedVaultImportActivity extends Activity { + private static final String TAG = "KeePassGOImport"; + private static final String DEFAULT_NAME = "shared-vault.kdbx"; + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + handleIntent(getIntent()); + launchMainActivity(); + finish(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + launchMainActivity(); + finish(); + } + + private void handleIntent(Intent intent) { + Uri uri = resolveSharedUri(intent); + if (uri == null) { + Log.i(TAG, "no shared vault URI on intent"); + return; + } + try { + persistPendingImport(uri); + Log.i(TAG, "queued shared vault import from " + uri); + } catch (IOException | RuntimeException err) { + Log.e(TAG, "failed to queue shared vault import", err); + } + } + + private Uri resolveSharedUri(Intent intent) { + if (intent == null) { + return null; + } + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + return intent.getParcelableExtra(Intent.EXTRA_STREAM); + } + if (Intent.ACTION_VIEW.equals(action)) { + return intent.getData(); + } + return null; + } + + private void persistPendingImport(Uri uri) throws IOException { + File dir = new File(getFilesDir(), "keepassgo"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File pendingFile = new File(dir, "pending-shared-vault.kdbx"); + try (InputStream in = getContentResolver().openInputStream(uri)) { + if (in == null) { + throw new IOException("failed to open shared vault stream"); + } + try (FileOutputStream out = new FileOutputStream(pendingFile, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + } + + File nameFile = new File(dir, "pending-shared-vault-name.txt"); + try (FileOutputStream out = new FileOutputStream(nameFile, false)) { + out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); + } + } + + private String resolveDisplayName(Uri uri) { + String displayName = queryDisplayName(uri); + if (!displayName.isEmpty()) { + return displayName; + } + String lastSegment = uri.getLastPathSegment(); + if (lastSegment != null && !lastSegment.trim().isEmpty()) { + return lastSegment.trim(); + } + return DEFAULT_NAME; + } + + private String queryDisplayName(Uri uri) { + Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index >= 0) { + String value = cursor.getString(index); + if (value != null) { + return value.trim(); + } + } + } + } catch (RuntimeException err) { + Log.w(TAG, "failed to query display name", err); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return ""; + } + + private void launchMainActivity() { + Intent launch = new Intent(); + launch.setClassName(this, "org.gioui.GioActivity"); + startActivity(launch); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java new file mode 100644 index 0000000..2793516 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java @@ -0,0 +1,100 @@ +package org.julianfamily.keepassgo; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import java.io.File; +import java.io.FileNotFoundException; + +public final class SharedVaultProvider extends ContentProvider { + private static final String AUTHORITY = "org.julianfamily.keepassgo.share"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + File file = resolveSharedFile(uri); + String[] columns = projection; + if (columns == null || columns.length == 0) { + columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + } + MatrixCursor cursor = new MatrixCursor(columns, 1); + Object[] row = new Object[columns.length]; + for (int i = 0; i < columns.length; i++) { + switch (columns[i]) { + case OpenableColumns.DISPLAY_NAME: + row[i] = file.getName(); + break; + case OpenableColumns.SIZE: + row[i] = file.length(); + break; + default: + row[i] = null; + break; + } + } + cursor.addRow(row); + return cursor; + } + + @Override + public String getType(Uri uri) { + return "application/x-keepass2"; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("insert is not supported"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("delete is not supported"); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("update is not supported"); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + File file = resolveSharedFile(uri); + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + static Uri uriForFile(String name) { + return new Uri.Builder() + .scheme("content") + .authority(AUTHORITY) + .appendPath(name) + .build(); + } + + private File resolveSharedFile(Uri uri) { + if (getContext() == null) { + throw new IllegalStateException("provider context is unavailable"); + } + String name = sanitizeFilename(uri.getLastPathSegment()); + return new File(AndroidShare.sharedDirectory(getContext()), name); + } + + private static String sanitizeFilename(String name) { + if (name == null) { + return "shared-vault.kdbx"; + } + String trimmed = name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + return new File(trimmed).getName(); + } +} diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go new file mode 100644 index 0000000..f460c56 --- /dev/null +++ b/appstate/remote_binding.go @@ -0,0 +1,141 @@ +package appstate + +import ( + "fmt" + "strings" + + "git.julianfamily.org/keepassgo/vault" +) + +type SyncMode string + +const ( + SyncModeManual SyncMode = "manual" + SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save" +) + +type RemoteBinding struct { + LocalVaultPath string `json:"localVaultPath"` + RemoteProfileID string `json:"remoteProfileId"` + CredentialEntryID string `json:"credentialEntryId"` + SyncMode SyncMode `json:"syncMode,omitempty"` +} + +type ResolvedRemoteBinding struct { + Profile vault.RemoteProfile + Credentials vault.Entry +} + +type RemoteBindingInput struct { + LocalVaultPath string + RemoteProfileID string + RemoteProfileName string + BaseURL string + RemotePath string + CredentialEntryID string + CredentialTitle string + Username string + Password string + CredentialPath []string + SyncMode SyncMode +} + +func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) { + profile, err := model.RemoteProfileByID(b.RemoteProfileID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err) + } + credentials, err := model.EntryByID(b.CredentialEntryID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err) + } + return ResolvedRemoteBinding{ + Profile: profile, + Credentials: credentials, + }, nil +} + +func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) { + if model == nil { + return RemoteBinding{}, fmt.Errorf("model is required") + } + + input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath) + input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID) + input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName) + input.BaseURL = strings.TrimSpace(input.BaseURL) + input.RemotePath = strings.TrimSpace(input.RemotePath) + input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID) + input.CredentialTitle = strings.TrimSpace(input.CredentialTitle) + input.Username = strings.TrimSpace(input.Username) + + switch { + case input.LocalVaultPath == "": + return RemoteBinding{}, fmt.Errorf("local vault path is required") + case input.RemoteProfileID == "": + return RemoteBinding{}, fmt.Errorf("remote profile id is required") + case input.BaseURL == "": + return RemoteBinding{}, fmt.Errorf("remote base URL is required") + case input.RemotePath == "": + return RemoteBinding{}, fmt.Errorf("remote path is required") + case input.CredentialEntryID == "": + return RemoteBinding{}, fmt.Errorf("credential entry id is required") + case input.Password == "": + return RemoteBinding{}, fmt.Errorf("credential password is required") + } + + if input.RemoteProfileName == "" { + input.RemoteProfileName = input.RemoteProfileID + } + if input.CredentialTitle == "" { + input.CredentialTitle = "Remote Sign-In" + } + + model.UpsertRemoteProfile(vault.RemoteProfile{ + ID: input.RemoteProfileID, + Name: input.RemoteProfileName, + Backend: vault.RemoteBackendWebDAV, + BaseURL: input.BaseURL, + Path: input.RemotePath, + }) + model.UpsertEntry(vault.Entry{ + ID: input.CredentialEntryID, + Title: input.CredentialTitle, + Username: input.Username, + Password: input.Password, + URL: input.BaseURL, + Path: append([]string(nil), input.CredentialPath...), + }) + + return RemoteBinding{ + LocalVaultPath: input.LocalVaultPath, + RemoteProfileID: input.RemoteProfileID, + CredentialEntryID: input.CredentialEntryID, + SyncMode: normalizeSyncMode(input.SyncMode), + }, 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: + return SyncModeAutomaticOnOpenSave + default: + return SyncModeManual + } +} diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go new file mode 100644 index 0000000..36009f2 --- /dev/null +++ b/appstate/remote_binding_test.go @@ -0,0 +1,250 @@ +package appstate + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.julianfamily.org/keepassgo/vault" +) + +func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "linuscaldwell-webdav", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }, + }, + } + + binding := RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "linuscaldwell-webdav", + SyncMode: SyncModeAutomaticOnOpenSave, + } + + resolved, err := binding.Resolve(model) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got) + } + if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" { + t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got) + } + if got := resolved.Credentials.Username; got != "linuscaldwell" { + t.Fatalf("resolved credentials username = %q, want linuscaldwell", got) + } + if got := resolved.Credentials.Password; got != "bellagio-pass-1" { + t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got) + } +} + +func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"}, + }, + } + + _, err := (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrRemoteProfileNotFound) { + t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err) + } + + model.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 = (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err) + } +} + +func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) { + t.Parallel() + + content, err := json.Marshal(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("json.Marshal(RemoteBinding) error = %v", err) + } + + text := string(content) + for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} { + if strings.Contains(text, disallowed) { + t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed) + } + } +} + +func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) { + t.Parallel() + + var model vault.Model + + binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemoteProfileName: "Family Vault", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("ConfigureRemoteBinding() error = %v", err) + } + + if len(model.RemoteProfiles) != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles)) + } + if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got) + } + + credentials, err := model.EntryByID("remote-creds-1") + if err != nil { + t.Fatalf("EntryByID(remote-creds-1) error = %v", err) + } + if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" { + t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-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) + } + if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" { + t.Fatalf("binding = %#v, want only vault references", binding) + } +} + +func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input RemoteBindingInput + }{ + { + name: "missing_local_vault_path", + input: RemoteBindingInput{ + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }, + }, + { + name: "missing_remote_base_url", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }, + }, + { + name: "missing_credential_entry_id", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + Password: "bellagio-pass-1", + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var model vault.Model + if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil { + t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input) + } + }) + } +} + +func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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 45dcc1a..7dfdc7f 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -133,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) { return apitokens.Entries(model) } +func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + profiles := slices.Clone(model.RemoteProfiles) + slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int { + switch { + case a.Name < b.Name: + return -1 + case a.Name > b.Name: + return 1 + case a.ID < b.ID: + return -1 + case a.ID > b.ID: + return 1 + default: + return 0 + } + }) + return profiles, nil +} + +func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + entries := slices.Clone(model.Entries) + slices.SortFunc(entries, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + case a.ID < b.ID: + return -1 + case a.ID > b.ID: + return 1 + default: + return 0 + } + }) + return entries, nil +} + func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { session, ok := s.Session.(MutableSession) if !ok { @@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas return nil } +func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error { + model, err := s.currentModel() + if err != nil { + return err + } + + resolved, err := binding.Resolve(model) + if err != nil { + return err + } + + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + return s.OpenRemoteVault(client, resolved.Profile.Path, key) +} + +func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) { + session, ok := s.Session.(MutableSession) + if !ok { + return RemoteBinding{}, fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return RemoteBinding{}, err + } + + binding, err := ConfigureRemoteBinding(&model, input) + if err != nil { + return RemoteBinding{}, err + } + + session.Replace(model) + s.Dirty = true + 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 270d5f3..3bf8e1c 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) { {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { } } +func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + 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", + }, + { + ID: "archive-webdav", + Name: "Archive Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/archive.kdbx", + }, + }, + }, + }, + } + + got, err := state.RemoteProfiles() + if err != nil { + t.Fatalf("RemoteProfiles() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got)) + } + if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got) + } +} + +func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}}, + }, + }, + }, + } + + got, err := state.RemoteCredentialEntries() + if err != nil { + t.Fatalf("RemoteCredentialEntries() error = %v", err) + } + if len(got) != 3 { + t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got)) + } + if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" { + t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got) + } +} + func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { t.Parallel() @@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -187,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { } if len(got) != 1 || got[0].Title != "Surveillance Console" { - t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) + t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got) } } @@ -200,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -350,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { model: vault.Model{ RecycleBin: []vault.Entry{ {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, - {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }, }, @@ -419,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }, @@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } @@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, } @@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } - if len(got) != 1 || got[0].Password != "token-1" { + if len(got) != 1 || got[0].Password != "bellagio-pass-1" { t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got) } @@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) { } } +func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + { + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + Dirty: true, + } + + err := state.OpenBoundRemoteVault(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: SyncModeAutomaticOnOpenSave, + }, vault.MasterKey{Password: "correct horse battery staple"}) + if err != nil { + t.Fatalf("OpenBoundRemoteVault() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remote client username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remote client password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.Dirty { + t.Fatal("Dirty = true, want false after bound remote open") + } +} + +func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{model: vault.Model{}} + state := State{Session: sess} + + err := state.OpenBoundRemoteVault(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "missing-profile", + CredentialEntryID: "remote-creds-1", + }, vault.MasterKey{Password: "correct horse battery staple"}) + if !errors.Is(err, vault.ErrRemoteProfileNotFound) { + t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err) + } +} + +func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{}} + state := State{Session: sess} + + binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemoteProfileName: "Family Vault", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("ConfigureRemoteBinding() error = %v", err) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after ConfigureRemoteBinding") + } + if got := binding.RemoteProfileID; got != "family-webdav" { + t.Fatalf("binding.RemoteProfileID = %q, want family-webdav", got) + } + if got := len(sess.model.RemoteProfiles); got != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", got) + } + credentials, err := sess.model.EntryByID("remote-creds-1") + if err != nil { + t.Fatalf("EntryByID(remote-creds-1) error = %v", err) + } + if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" { + t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials) + } +} + +func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) { + t.Parallel() + + state := State{Session: stubSession{model: vault.Model{}}} + _, err := state.ConfigureRemoteBinding(RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }) + if err == nil { + t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error") + } +} + +func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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() @@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) { SelectedEntryID: "vault-console", } - state.NavigateToPath([]string{"Root", "Home Assistant"}) + state.NavigateToPath([]string{"Root", "Security Office"}) - if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) { - t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath) + if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) { + t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID = %q, want empty", got) @@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) { if err != nil { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } @@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got) + if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after CreateGroup") @@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T) t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after DeleteCurrentGroup") @@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { SelectedEntryID: "bellagio", } - if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { + if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil { t.Fatalf("MoveSelectedEntry() error = %v", err) } - state.NavigateToPath([]string{"Root", "Home Assistant"}) + state.NavigateToPath([]string{"Root", "Security Office"}) got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) @@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model { return vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}}, }, } } @@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error { } type lifecycleStubSession struct { - createCalls int - openPath string - saveAsPath string - remotePath string - changedKey vault.MasterKey + createCalls int + model vault.Model + openPath string + saveAsPath string + remoteClient webdav.Client + remotePath string + changedKey vault.MasterKey } func (s *lifecycleStubSession) Current() (vault.Model, error) { - return vault.Model{}, nil + return s.model, nil } func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error { @@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error { return nil } -func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error { +func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + s.remoteClient = client s.remotePath = path return nil } diff --git a/docs/local-first-remote-sync-plan.md b/docs/local-first-remote-sync-plan.md new file mode 100644 index 0000000..2f2a260 --- /dev/null +++ b/docs/local-first-remote-sync-plan.md @@ -0,0 +1,148 @@ +# Local-First Remote Sync Plan + +## Goal + +Redesign remote-backed vault handling so every platform uses the same local-first model: + +- every vault is a local KDBX file first +- remote sync is an optional binding on top of that local file +- shared remote configuration lives in the vault +- user-specific remote credentials live in the vault +- app-local state stores only non-secret binding metadata + +Android adds only one platform-specific capability on top of that model: + +- share/import the initial local KDBX file between devices + +## Product Rules + +1. A remote-backed vault must always have a local cache KDBX file. +2. Opening a remote-backed vault should open the local KDBX first. +3. Shared remote configuration must be stored in the vault, not only in app state. +4. Remote credentials must not be stored in plaintext app-local state. +5. Remote credentials should be stored in the vault and resolved by a stable reference. +6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding. +7. Sync must support both manual and automatic modes. +8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle. + +## Target Data Model + +### In-Vault Shared Remote Profile + +Store a reusable remote profile in the vault with fields such as: + +- profile ID +- profile name +- backend type, initially WebDAV +- base URL +- remote object path +- optional notes or labels +- default sync policy, if shared defaults are desirable + +### In-Vault User Credential Binding + +Store user-specific credentials in the vault as normal vault data, referenced by: + +- remote profile ID +- credential entry UUID, or another stable internal reference +- optional username field override if needed + +The credential entry should contain the actual username/password or token. + +### Local App State + +Persist only non-secret binding state such as: + +- local vault path +- selected remote profile ID +- selected credential entry reference +- sync policy override +- last sync metadata +- conflict or recovery markers + +## Core Flows + +### Create Or Configure Remote Sync + +1. Open or create a local vault. +2. Create or edit a shared remote profile in that vault. +3. Create or select a credential entry in that vault. +4. Bind the local vault to the selected remote profile and credential reference. +5. Choose manual or automatic sync behavior. + +### Reopen Existing Remote-Backed Vault + +1. Open the local vault file from app state. +2. Resolve the selected remote profile from vault contents. +3. Resolve the credential entry from vault contents. +4. Offer or perform sync based on the binding policy. + +### Bootstrap A New Android Device + +1. Share the local KDBX file through Android Sharesheet. +2. Import and open that KDBX locally on the new device. +3. Select a remote profile stored in the vault. +4. Select or create that user’s credential entry in the vault. +5. Bind the local vault as the cache for the remote-backed setup. + +## Migration Requirements + +1. Migrate existing remote connections that save credentials in app state. +2. On first open after upgrade, move any recoverable remote credentials into the vault. +3. Replace saved plaintext credential state with a vault credential reference. +4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration. +5. Remove legacy local plaintext credential persistence after migration is complete. + +## Implementation Phases + +### Phase 1: Domain Model + +- define remote profile structures independent of Gio UI +- define credential reference structures independent of Gio UI +- define sync binding state independent of Gio UI +- add behavior tests for local-first remote-backed vaults + +### Phase 2: Vault Storage + +- persist remote profiles in the vault +- persist credential references in the vault +- resolve credentials from normal vault entries +- add behavior tests for read/write and lookup semantics + +### Phase 3: State And Open Flow + +- shrink app state to non-secret metadata only +- update open flows to always prefer the local cache vault +- update reopen behavior on all platforms to use the same model +- add migration coverage for old remote state + +### Phase 4: Sync Binding + +- bind a local vault to a selected remote profile +- support manual sync +- support automatic sync on open/save +- define conflict and remote-failure handling for the local cache model + +### Phase 5: Android Bootstrap + +- add Android Sharesheet export of the current local KDBX +- add Android import flow for a shared KDBX +- keep the remote pivot flow consistent with desktop after local open + +## Open Questions + +1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure? +2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier? +3. Should automatic sync run only on open/save initially, or also on app resume? +4. How should multiple remote profiles per vault be presented in the UI? +5. What should happen when the credential entry reference no longer resolves? + +## Recommended First Slice + +Implement the shared domain model and tests first: + +- model a local vault plus optional remote binding +- define in-vault remote profile and credential reference semantics +- add tests proving app state no longer needs plaintext remote credentials + +That slice standardizes the architecture before any Android-specific sharing work begins. diff --git a/main.go b/main.go index 6c47aa0..3a6b559 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" @@ -135,12 +137,14 @@ type attachmentItem struct { } type statePaths struct { - DefaultSaveAsPath string - RecentVaultsPath string - RecentRemotesPath string - SettingsPath string - UIPreferencesPath string - AutofillCachePath string + DefaultSaveAsPath string + RecentVaultsPath string + RecentRemotesPath string + SettingsPath string + UIPreferencesPath string + AutofillCachePath string + PendingSharedVaultPath string + PendingSharedVaultNamePath string } type recentVaultRecord struct { @@ -150,12 +154,17 @@ type recentVaultRecord struct { } type recentRemoteRecord struct { - BaseURL string `json:"baseUrl"` - Path string `json:"path"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - LastGroup []string `json:"lastGroup,omitempty"` - UsedAt string `json:"usedAt,omitempty"` + BaseURL string `json:"baseUrl"` + Path string `json:"path"` + LocalVaultPath string `json:"localVaultPath,omitempty"` + RemoteProfileID string `json:"remoteProfileId,omitempty"` + CredentialEntryID string `json:"credentialEntryId,omitempty"` + SyncMode string `json:"syncMode,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + LastGroup []string `json:"lastGroup,omitempty"` + UsedAt string `json:"usedAt,omitempty"` + NeedsMigration bool `json:"-"` } type uiPreferences struct { @@ -208,273 +217,297 @@ const ( syncDirectionPush syncDirection = "push" ) +type syncDialogPurpose string + +const ( + syncDialogPurposeAdvanced syncDialogPurpose = "advanced" + syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup" +) + type ui struct { - mode string - theme *material.Theme - fileExplorer *explorer.Explorer - logoHorizontal paint.ImageOp - splashSquare paint.ImageOp - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - groupParentPath widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - autofillBrowserAllowlist widget.Editor - autofillAppAllowlist widget.Editor - autofillPackageRules widget.Editor - list widget.List - groupList widget.List - detailList widget.List - apiPolicyList widget.List - lifecycleList widget.List - phonePanelList widget.List - securityDialogList widget.List - remotePrefsDialogList widget.List - recentVaultListState widget.List - recentRemoteListState widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - toggleMainMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - openRemotePrefsHelp widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - closeRemotePrefsHelp widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - settingsDensityDense widget.Clickable - settingsDensityComfortable widget.Clickable - settingsContrastStandard widget.Clickable - settingsContrastHigh widget.Clickable - settingsReducedMotionOff widget.Clickable - settingsReducedMotionOn widget.Clickable - settingsKeyboardFocusStandard widget.Clickable - settingsKeyboardFocusProminent widget.Clickable - showSettingsSyncLocal widget.Clickable - showSettingsSyncRemote widget.Clickable - showSettingsSyncPull widget.Clickable - showSettingsSyncPush widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - clearVaultSelection widget.Clickable - clearRemoteSelection widget.Clickable - dismissBanner widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable - createGroup widget.Clickable - moveGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - toggleLifecycleAdvanced widget.Clickable - toggleHistory widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - setStatusBannerShort widget.Clickable - setStatusBannerStandard widget.Clickable - setStatusBannerLong widget.Clickable - showAllAutofillNotices widget.Clickable - showApprovalAutofillOnly widget.Clickable - hideAutofillNotices widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showAbout widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - showAutofillApprovalAsk widget.Clickable - showAutofillApprovalAllow widget.Clickable - showAutofillApprovalBlock widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - cancelLifecycleProgress widget.Clickable - retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool - rememberRemoteAuth widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - settingsGroupControls widget.Bool - settingsLifecycleAdvanced widget.Bool - settingsHistory widget.Bool - settingsDenseLayout widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - apiAuditTokenFilters []widget.Clickable - apiAuditDecisionFilters []widget.Clickable - apiAuditOperationFilters []widget.Clickable - clearAPIAuditFilters widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - generatedPasswordDraft bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - useCurrentGroupForPolicy widget.Clickable - useSelectedEntryForPolicy widget.Clickable - clearAPIPolicyTarget widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - phoneGroupBrowserExpanded bool - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronRightIcon *widget.Icon - chevronDownIcon *widget.Icon - settingsIcon *widget.Icon - menuIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - loadingActionLabel string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalImportName string - syncLocalImportContent []byte - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - mainMenuOpen bool - selectedRemoteConnection bool - securityDialogOpen bool - remotePrefsDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - settingsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - syncDefaultSourceMode syncSourceMode - syncDefaultDirection syncDirection - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - denseLayout bool - statusBannerTTL time.Duration - autofillNoticePreference autofillNoticeMode - autofillFirstFillApprovalMode autofillFirstFillApprovalMode - accessibilityPrefs accessibilityPreferences - settingsDraft settingsDraft - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - recentVaultUsedAt map[string]time.Time - entriesState entriesSectionState - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string - backgroundResults chan backgroundActionResult - backgroundActionSerial int - activeBackgroundAction int - lastLifecycleAction string - requestMasterPassFocus bool - invalidate func() + mode string + theme *material.Theme + fileExplorer *explorer.Explorer + logoHorizontal paint.ImageOp + splashSquare paint.ImageOp + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + groupParentPath widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + autofillBrowserAllowlist widget.Editor + autofillAppAllowlist widget.Editor + autofillPackageRules widget.Editor + list widget.List + groupList widget.List + detailList widget.List + apiPolicyList widget.List + lifecycleList widget.List + phonePanelList widget.List + securityDialogList widget.List + remotePrefsDialogList widget.List + recentVaultListState widget.List + recentRemoteListState widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + toggleMainMenu widget.Clickable + openAdvancedSync widget.Clickable + useSavedAdvancedSyncRemote widget.Clickable + openSelectedVaultRemote widget.Clickable + saveCurrentRemoteBinding widget.Clickable + removeSelectedRemoteBinding widget.Clickable + openSecuritySettings widget.Clickable + openRemotePrefsHelp widget.Clickable + closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable + closeRemotePrefsHelp widget.Clickable + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + settingsDensityDense widget.Clickable + settingsDensityComfortable widget.Clickable + settingsContrastStandard widget.Clickable + settingsContrastHigh widget.Clickable + settingsReducedMotionOff widget.Clickable + settingsReducedMotionOn widget.Clickable + settingsKeyboardFocusStandard widget.Clickable + settingsKeyboardFocusProminent widget.Clickable + showSettingsSyncLocal widget.Clickable + showSettingsSyncRemote widget.Clickable + showSettingsSyncPull widget.Clickable + showSettingsSyncPush widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + importSharedVault widget.Clickable + shareCurrentVault widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable + createGroup widget.Clickable + moveGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + toggleLifecycleAdvanced widget.Clickable + toggleHistory widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + setStatusBannerShort widget.Clickable + setStatusBannerStandard widget.Clickable + setStatusBannerLong widget.Clickable + showAllAutofillNotices widget.Clickable + showApprovalAutofillOnly widget.Clickable + hideAutofillNotices widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showAbout widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + showAutofillApprovalAsk widget.Clickable + showAutofillApprovalAllow widget.Clickable + showAutofillApprovalBlock widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable + approvalPermanent widget.Bool + syncSetupAutomatic widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + settingsGroupControls widget.Bool + settingsLifecycleAdvanced widget.Bool + settingsHistory widget.Bool + settingsDenseLayout widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + apiAuditTokenFilters []widget.Clickable + apiAuditDecisionFilters []widget.Clickable + apiAuditOperationFilters []widget.Clickable + clearAPIAuditFilters widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + vaultRemoteProfileClicks []widget.Clickable + vaultRemoteCredentialClicks []widget.Clickable + syncRemoteCredentialClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + generatedPasswordDraft bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + useCurrentGroupForPolicy widget.Clickable + useSelectedEntryForPolicy widget.Clickable + clearAPIPolicyTarget widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + phoneGroupBrowserExpanded bool + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronRightIcon *widget.Icon + chevronDownIcon *widget.Icon + settingsIcon *widget.Icon + menuIcon *widget.Icon + clipboardWriter clipboard.Writer + vaultSharer vaultSharer + loadingMessage string + loadingActionLabel string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalImportName string + syncLocalImportContent []byte + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + selectedSyncRemoteCredentialEntryID string + syncDialogPurpose syncDialogPurpose + syncDialogOpen bool + syncMenuOpen bool + mainMenuOpen bool + selectedRemoteConnection bool + selectedVaultRemoteProfileID string + selectedVaultRemoteCredentialEntryID string + selectedVaultRemoteSyncMode appstate.SyncMode + securityDialogOpen bool + remotePrefsDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + settingsPath string + uiPreferencesPath string + recentRemotesPath string + autofillCachePath string + pendingSharedVaultPath string + pendingSharedVaultNamePath string + editingEntry bool + syncDefaultSourceMode syncSourceMode + syncDefaultDirection syncDirection + groupControlsHidden bool + lifecycleAdvancedHidden bool + historyHidden bool + denseLayout bool + statusBannerTTL time.Duration + autofillNoticePreference autofillNoticeMode + autofillFirstFillApprovalMode autofillFirstFillApprovalMode + accessibilityPrefs accessibilityPreferences + settingsDraft settingsDraft + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string + backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool + invalidate func() } type backgroundActionResult struct { @@ -484,6 +517,10 @@ type backgroundActionResult struct { id int } +type vaultSharer interface { + ShareVault(path, title string) error +} + var ( bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} @@ -606,6 +643,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, + pendingSharedVaultPath: paths.PendingSharedVaultPath, + pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, @@ -620,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, + vaultSharer: newPlatformVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, } @@ -646,6 +686,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() + if u.hasLegacyRecentRemoteCredentialMigration() { + u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.") + } + u.consumePendingSharedVaultImport() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() @@ -716,12 +760,14 @@ func defaultStatePaths(stateDir string) statePaths { baseDir = filepath.Join(configDir, "keepassgo") } return statePaths{ - DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), - RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), - RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), - SettingsPath: filepath.Join(baseDir, "settings.json"), - UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), - AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), + SettingsPath: filepath.Join(baseDir, "settings.json"), + UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), } } @@ -750,6 +796,14 @@ func supportsDesktopFilePicker(goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } +func supportsSharedVaultImport(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "android") +} + +func supportsVaultShare(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "android") +} + func (u *ui) selectedAttachmentItems() []attachmentItem { item, ok := u.selectedEntry() if !ok || len(item.Attachments) == 0 { @@ -971,6 +1025,13 @@ func (u *ui) createVaultAction() error { return err } if u.lifecycleMode == "local" { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil { return err } @@ -1002,6 +1063,10 @@ func (u *ui) openVaultAction() error { u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1039,6 +1104,10 @@ func (u *ui) startOpenVaultAction() { u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1051,6 +1120,9 @@ func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err } + if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil { + return err + } u.filter() return nil } @@ -1072,6 +1144,22 @@ func (u *ui) openRemoteAction() error { if err != nil { return err } + if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil { + return err + } else if ok { + if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { + return err + } + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), @@ -1080,12 +1168,12 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()), - strings.TrimSpace(u.remoteUsername.Text()), - u.remotePassword.Text(), - u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) @@ -1116,18 +1204,67 @@ func (u *ui) startOpenRemoteAction() { remotePath := strings.TrimSpace(u.remotePath.Text()) u.lastLifecycleAction = "open remote vault" u.runBackgroundAction("open remote vault", func() (func() error, error) { + binding, bindingOK := u.selectedVaultRemoteBinding() + if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" { + preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key) + if err != nil { + return nil, err + } + resolved, err := binding.Resolve(preparedLocal.Model) + if err != nil { + return nil, err + } + preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + }, resolved.Profile.Path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(preparedLocal) + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + manager.ApplyPreparedRemoteOpen(preparedRemote) + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + } + if u.hasOpenVault() { + if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + return nil, err + } else if ok { + client = webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + remotePath = resolved.Profile.Path + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + } + } prepared, err := session.PrepareRemoteOpen(client, remotePath, key) if err != nil { return nil, err } return func() error { manager.ApplyPreparedRemoteOpen(prepared) + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), remotePath, - strings.TrimSpace(u.remoteUsername.Text()), - u.remotePassword.Text(), - u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) @@ -1235,11 +1372,32 @@ func (u *ui) openAdvancedSyncDialog() { u.syncDialogOpen = true u.syncMenuOpen = false u.showSyncPassword = false + u.syncDialogPurpose = syncDialogPurposeAdvanced u.syncSourceMode = u.syncDefaultSourceMode u.syncDirection = u.syncDefaultDirection if strings.TrimSpace(u.syncLocalPath.Text()) == "" { u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() +} + +func (u *ui) openRemoteSyncSetupDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSourceMode = syncSourceRemote + u.syncDirection = syncDirectionPush + u.syncSetupAutomatic.Value = true + if strings.TrimSpace(u.syncLocalPath.Text()) == "" { + u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) + } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() + if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual { + u.syncSetupAutomatic.Value = false + } } func (u *ui) clearSyncLocalImport() { @@ -1345,17 +1503,48 @@ func (u *ui) startChooseSyncLocalSourceAction() { }) } +func (u *ui) startImportSharedVaultAction() { + if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil { + return + } + u.runBackgroundAction("import shared vault", func() (func() error, error) { + file, err := u.fileExplorer.ChooseFile(".kdbx") + if err != nil { + if errors.Is(err, explorer.ErrUserDecline) { + return func() error { return nil }, nil + } + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + return func() error { + return u.importSharedVaultBytesAction("shared-vault.kdbx", content) + }, nil + }) +} + func (u *ui) advancedSyncToAction() error { switch u.syncSourceMode { case syncSourceRemote: + baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.syncRemotePath.Text()) client := webdav.Client{ - BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + BaseURL: baseURL, Username: strings.TrimSpace(u.syncRemoteUsername.Text()), Password: u.syncRemotePassword.Text(), } - if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + if err := u.state.SynchronizeToRemote(client, remotePath); err != nil { return err } + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil { + return err + } + u.showStatusMessage("Remote sync is set up for this vault.") + } default: path := strings.TrimSpace(u.syncLocalPath.Text()) if path == "" { @@ -1371,6 +1560,48 @@ func (u *ui) advancedSyncToAction() error { return nil } +func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error { + baseURL = strings.TrimSpace(baseURL) + remotePath = strings.TrimSpace(remotePath) + if baseURL == "" || remotePath == "" { + return fmt.Errorf("remote setup requires base URL and path") + } + input := appstate.RemoteBindingInput{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + CredentialTitle: "WebDAV Sign-In" + func() string { + if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" { + return " · " + user + } + return "" + }(), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: u.syncSetupMode(), + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + u.remoteBaseURL.SetText(baseURL) + u.remotePath.SetText(remotePath) + u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text())) + u.remotePassword.SetText(u.syncRemotePassword.Text()) + u.noteRecentRemote(baseURL, remotePath) + return nil +} + func (u *ui) saveAsTargetPath() string { path := strings.TrimSpace(u.saveAsPath.Text()) if path != "" { @@ -1379,6 +1610,86 @@ func (u *ui) saveAsTargetPath() string { return u.defaultSaveAsPath } +func (u *ui) importedVaultDestination(name string) string { + baseTarget := u.saveAsTargetPath() + baseDir := filepath.Dir(baseTarget) + baseName := filepath.Base(strings.TrimSpace(name)) + switch { + case baseName == "" || baseName == "." || baseName == string(filepath.Separator): + return baseTarget + case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"): + return filepath.Join(baseDir, baseName) + default: + return baseTarget + } +} + +func (u *ui) consumePendingSharedVaultImport() { + path := strings.TrimSpace(u.pendingSharedVaultPath) + if path == "" { + return + } + content, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + } + return + } + name := "shared-vault.kdbx" + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + if rawName, err := os.ReadFile(namePath); err == nil { + if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" { + name = trimmed + } + } + } + if err := u.importSharedVaultBytesAction(name, content); err != nil { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + return + } + _ = os.Remove(path) + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + _ = os.Remove(namePath) + } +} + +func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { + target := u.importedVaultDestination(name) + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil { + return err + } + u.lifecycleMode = "local" + u.vaultPath.SetText(target) + u.noteRecentVault(target) + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + u.filter() + return nil +} + +func (u *ui) currentShareableVaultPath() string { + return strings.TrimSpace(u.vaultPath.Text()) +} + +func (u *ui) shareCurrentVaultAction() error { + if u.vaultSharer == nil { + return fmt.Errorf("vault sharing is not available on this platform") + } + path := u.currentShareableVaultPath() + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.Save(); err != nil { + return err + } + return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path)) +} + func (u *ui) noteRecentVault(path string) { path = strings.TrimSpace(path) if path == "" { @@ -1489,9 +1800,20 @@ func (u *ui) loadRecentRemotes() { for _, record := range records { record.BaseURL = strings.TrimSpace(record.BaseURL) record.Path = strings.TrimSpace(record.Path) + record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath) + record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + record.SyncMode = strings.TrimSpace(record.SyncMode) + record.Username = strings.TrimSpace(record.Username) + record.Password = strings.TrimSpace(record.Password) if record.BaseURL == "" || record.Path == "" { continue } + if record.Username != "" || record.Password != "" { + record.NeedsMigration = true + record.Username = "" + record.Password = "" + } key := record.BaseURL + "|" + record.Path if seen[key] { continue @@ -1509,6 +1831,15 @@ func (u *ui) loadRecentRemotes() { } } +func (u *ui) hasLegacyRecentRemoteCredentialMigration() bool { + for _, record := range u.recentRemotes { + if record.NeedsMigration { + return true + } + } + return false +} + func (u *ui) saveRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return @@ -1709,7 +2040,7 @@ func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { u.saveUIPreferences() } -func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { +func (u *ui) noteRecentRemote(baseURL, path string) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) if baseURL == "" || path == "" { @@ -1721,13 +2052,15 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember LastGroup: append([]string(nil), u.currentPath...), UsedAt: u.now().Format(time.RFC3339Nano), } + if binding, ok := u.selectedVaultRemoteBinding(); ok { + record.LocalVaultPath = binding.LocalVaultPath + record.RemoteProfileID = binding.RemoteProfileID + record.CredentialEntryID = binding.CredentialEntryID + record.SyncMode = string(binding.SyncMode) + } if len(record.LastGroup) == 0 { record.LastGroup = u.recentRemoteGroup(baseURL, path) } - if rememberAuth { - record.Username = strings.TrimSpace(username) - record.Password = password - } next := []recentRemoteRecord{record} for _, existing := range u.recentRemotes { if existing.BaseURL == baseURL && existing.Path == path { @@ -1761,12 +2094,15 @@ func (u *ui) restoreStartupLifecycleTarget() { remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() switch { - case hasRemote && (localPath == "" || remoteUsedAt.After(localUsedAt)): - u.lifecycleMode = "remote" - u.applyRecentRemoteRecord(remoteRecord) + case hasRemote && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)): + u.lifecycleMode = "local" + u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath)) case localPath != "": u.lifecycleMode = "local" u.vaultPath.SetText(localPath) + case hasRemote: + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(remoteRecord) } } @@ -1830,7 +2166,6 @@ func (u *ui) switchToLifecycleSelection(mode string) { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.selectedRemoteConnection = false default: u.vaultPath.SetText("") @@ -1838,7 +2173,6 @@ func (u *ui) switchToLifecycleSelection(mode string) { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.selectedRemoteConnection = false } u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() @@ -1861,10 +2195,8 @@ func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { func (u *ui) currentRemoteRecord() recentRemoteRecord { return recentRemoteRecord{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Path: strings.TrimSpace(u.remotePath.Text()), - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Path: strings.TrimSpace(u.remotePath.Text()), } } @@ -1884,33 +2216,651 @@ func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) { func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) - u.remoteUsername.SetText(record.Username) - u.remotePassword.SetText(record.Password) + u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath)) + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) u.remotePassword.Mask = '•' - u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" u.selectedRemoteConnection = true + if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" { + u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.") + } } func (u *ui) remotePreferencesCurrentSummary() string { - selected, hasSelected := u.selectedRecentRemoteRecord() switch { - case !u.rememberRemoteAuth.Value: - return "Current choice: KeePassGO will remember only the WebDAV location for this connection." - case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): - return "Current choice: a successful open will update the saved sign-in for this connection on this device." case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": - return "Current choice: a successful open will save the entered sign-in for this connection on this device." + return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." default: - return "Current choice: sign-in retention is enabled, but no username or password is entered yet." + return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." } } func (u *ui) remotePreferencesAlwaysSavedSummary() string { - return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." + return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." } func (u *ui) remotePreferencesRetentionSummary() string { - return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." + return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." +} + +func (u *ui) remotePreferencesPersistenceSummary() string { + return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." +} + +func (u *ui) availableRemoteProfiles() []vault.RemoteProfile { + profiles, err := u.state.RemoteProfiles() + if err != nil { + return nil + } + return profiles +} + +func (u *ui) availableRemoteCredentialEntries() []vault.Entry { + entries, err := u.state.RemoteCredentialEntries() + if err != nil { + return nil + } + 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 + } + remotePath := strings.TrimSpace(u.syncRemotePath.Text()) + entries := u.availableRemoteCredentialEntries() + byID := make(map[string]vault.Entry, len(entries)) + for _, entry := range entries { + byID[entry.ID] = entry + } + matches := make([]vault.Entry, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + appendMatch := func(entry vault.Entry) { + if strings.TrimSpace(entry.ID) == "" { + return + } + if _, ok := seen[entry.ID]; ok { + return + } + seen[entry.ID] = struct{}{} + matches = append(matches, entry) + } + for _, entry := range entries { + if normalizeRemoteCredentialURL(entry.URL) != baseURL { + continue + } + appendMatch(entry) + } + profilesByID := make(map[string]vault.RemoteProfile) + for _, profile := range u.availableRemoteProfiles() { + profilesByID[profile.ID] = profile + } + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + for _, record := range u.recentRemotes { + if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath { + continue + } + profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)] + if !ok { + continue + } + if normalizeRemoteCredentialURL(profile.BaseURL) != baseURL { + continue + } + if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath { + continue + } + entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)] + if !ok { + continue + } + appendMatch(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) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) { + if !u.hasOpenVault() { + return appstate.ResolvedRemoteBinding{}, false + } + _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() + if err != nil || !ok { + return appstate.ResolvedRemoteBinding{}, false + } + return resolved, true +} + +func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() { + resolved, ok := u.savedAdvancedSyncRemoteBinding() + if !ok { + return + } + u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL) + u.syncRemotePath.SetText(resolved.Profile.Path) + u.applyAdvancedSyncRemoteCredentialEntry(resolved.Credentials) +} + +func (u *ui) syncDialogTitle() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Remote Sync Settings" + } + return "Set Up Remote Sync" + } + return "Advanced Sync" +} + +func (u *ui) syncDialogDescription() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Review or change this vault's saved WebDAV target, credentials, and sync mode." + } + return "Send this local vault to a WebDAV target, then use that target for future sync." + } + return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings." +} + +func (u *ui) syncDialogConfirmButtonLabel() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Save Remote Sync Settings" + } + return "Set Up Remote Sync" + } + return "Synchronize" +} + +func (u *ui) shouldShowSyncDirectionChoices() bool { + return u.syncDialogPurpose != syncDialogPurposeRemoteSetup +} + +func (u *ui) shouldShowSyncSourceChoices() bool { + return u.syncDialogPurpose != syncDialogPurposeRemoteSetup +} + +func (u *ui) syncSetupMode() appstate.SyncMode { + if u.syncSetupAutomatic.Value { + return appstate.SyncModeAutomaticOnOpenSave + } + return appstate.SyncModeManual +} + +func (u *ui) selectVaultRemoteProfile(id string) { + id = strings.TrimSpace(id) + u.selectedVaultRemoteProfileID = id + for _, profile := range u.availableRemoteProfiles() { + if profile.ID != id { + continue + } + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + return + } +} + +func (u *ui) selectVaultRemoteCredentialEntry(id string) { + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id) +} + +func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) { + selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + profiles := u.availableRemoteProfiles() + for _, profile := range profiles { + if profile.ID == selectedID { + return profile, true + } + } + if selectedID == "" && len(profiles) == 1 { + return profiles[0], true + } + return vault.RemoteProfile{}, false +} + +func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) { + selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + entries := u.availableRemoteCredentialEntries() + for _, entry := range entries { + if entry.ID == selectedID { + return entry, true + } + } + if selectedID == "" && len(entries) == 1 { + return entries[0], true + } + return vault.Entry{}, false +} + +func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { + profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if profileID != "" && entryID != "" { + return appstate.RemoteBinding{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: profileID, + CredentialEntryID: entryID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + }, true + } + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return appstate.RemoteBinding{}, false + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return appstate.RemoteBinding{}, false + } + return appstate.RemoteBinding{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: profile.ID, + CredentialEntryID: entry.ID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + }, true +} + +func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode { + switch mode { + case appstate.SyncModeAutomaticOnOpenSave: + return appstate.SyncModeAutomaticOnOpenSave + default: + return appstate.SyncModeManual + } +} + +func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode { + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + return appstate.SyncModeAutomaticOnOpenSave + } + if u.selectedVaultRemoteSyncMode == "" { + return appstate.SyncModeAutomaticOnOpenSave + } + return appstate.SyncModeManual +} + +func (u *ui) syncSavedRemoteBindingSelection() { + profiles := u.availableRemoteProfiles() + entries := u.availableRemoteCredentialEntries() + + profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + if profileID != "" { + var found bool + for _, profile := range profiles { + if profile.ID == profileID { + found = true + break + } + } + if !found { + u.selectedVaultRemoteProfileID = "" + } + } + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" && len(profiles) == 1 { + u.selectedVaultRemoteProfileID = profiles[0].ID + } + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } + + entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if entryID != "" { + var found bool + for _, entry := range entries { + if entry.ID == entryID { + found = true + break + } + } + if !found { + u.selectedVaultRemoteCredentialEntryID = "" + } + } + if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 { + u.selectedVaultRemoteCredentialEntryID = entries[0].ID + } + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" { + if record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())); ok { + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } + } + } + if binding, ok := u.selectedVaultRemoteBinding(); ok { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) { + continue + } + if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) { + continue + } + if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) { + continue + } + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + return + } + } + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual +} + +func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { + path = strings.TrimSpace(path) + if path == "" { + return recentRemoteRecord{}, false + } + var matches []recentRemoteRecord + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != path { + continue + } + if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" { + continue + } + matches = append(matches, record) + } + if len(matches) != 1 { + return recentRemoteRecord{}, false + } + return matches[0], true +} + +func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { + profiles := u.availableRemoteProfiles() + entries := u.availableRemoteCredentialEntries() + if len(profiles) == 0 || len(entries) == 0 { + return false + } + return len(profiles) > 1 || len(entries) > 1 +} + +func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return "", "", "", false + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return "", "", "", false + } + credentialLabel = entry.Title + if strings.TrimSpace(entry.Username) != "" { + credentialLabel += " · " + strings.TrimSpace(entry.Username) + } + syncLabel = "Sync manually when you choose Use Remote Sync." + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + syncLabel = "Syncs automatically on open and save." + } + return profile.Name, credentialLabel, syncLabel, true +} + +func (u *ui) savedRemoteBindingHeading() string { + if !u.shouldShowSavedRemoteBindingSelectors() { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (u *ui) openSelectedVaultRemoteButtonLabel() string { + if !u.shouldShowSavedRemoteBindingSelectors() { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + _, ok := u.selectedVaultRemoteBinding() + return ok +} + +func (u *ui) directRemoteSyncShortcutLabel() string { + return "Use Remote Sync" +} + +func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + _, ok := u.selectedVaultRemoteBinding() + return ok +} + +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 + } + _, ok := u.selectedVaultRemoteBinding() + return !ok +} + +func (u *ui) remoteSyncSetupShortcutLabel() string { + return "Set Up Remote Sync" +} + +func remoteBindingSuffix(baseURL, path, username string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username))) + return hex.EncodeToString(sum[:8]) +} + +func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + + switch { + case localVaultPath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required") + case baseURL == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required") + case remotePath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required") + case username == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required") + case password == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required") + } + + suffix := remoteBindingSuffix(baseURL, remotePath, username) + credentialTitle := "WebDAV Sign-In" + if username != "" { + credentialTitle += " · " + username + } + + return appstate.RemoteBindingInput{ + LocalVaultPath: localVaultPath, + RemoteProfileID: "remote-profile-" + suffix, + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + suffix, + CredentialTitle: credentialTitle, + Username: username, + Password: password, + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: u.newRemoteBindingSyncMode(), + }, nil +} + +func (u *ui) saveCurrentRemoteBindingAction() error { + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + 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" +} + +func (u *ui) saveCurrentRemoteBindingButtonLabel() string { + return "Save Remote In Vault" +} + +func (u *ui) materializeCurrentRemoteCache() error { + cachePath := strings.TrimSpace(u.vaultPath.Text()) + if cachePath == "" { + cachePath = u.saveAsTargetPath() + } + if cachePath == "" { + return nil + } + u.vaultPath.SetText(cachePath) + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.noteRecentVault(cachePath) + + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if username == "" && password == "" { + return nil + } + + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + +func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + if u.hasOpenVault() { + return u.resolvedSelectedVaultRemoteBinding() + } + + binding, ok := u.selectedVaultRemoteBinding() + if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil +} + +func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil } func (u *ui) noteCurrentRemotePath() { @@ -2333,17 +3283,141 @@ func (u *ui) remoteOpenRetryAvailable() bool { return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:") } +func (u *ui) selectedRemoteUsesLocalCache() bool { + return u.hasSelectedRemoteTarget() && + strings.TrimSpace(u.vaultPath.Text()) != "" && + strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && + strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" +} + +func (u *ui) currentSessionIsRemote() bool { + session, ok := u.state.Session.(interface{ IsRemote() bool }) + return ok && session.IsRemote() +} + +func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() + if err == nil || !ok { + return binding, resolved, ok, err + } + message := err.Error() + if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err +} + +func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) synchronizeSelectedRemoteBindingOnSave() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error { + _, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{ + LocalVaultPath: binding.LocalVaultPath, + RemoteProfileID: resolved.Profile.ID, + RemoteProfileName: resolved.Profile.Name, + BaseURL: resolved.Profile.BaseURL, + RemotePath: resolved.Profile.Path, + CredentialEntryID: resolved.Credentials.ID, + CredentialTitle: resolved.Credentials.Title, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + CredentialPath: append([]string(nil), resolved.Credentials.Path...), + SyncMode: binding.SyncMode, + }) + if err != nil { + return err + } + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + +func (u *ui) remoteLifecycleMessage() string { + if u.selectedRemoteUsesLocalCache() { + return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." + } + return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." +} + func (u *ui) remoteOpenButtonLabel() string { switch { case u.lifecycleBusy(): - return "Opening Remote Vault..." + if u.selectedRemoteUsesLocalCache() { + return "Opening Cached Vault..." + } + return "Creating Local Cache..." case u.remoteOpenRetryAvailable(): - return "Retry Remote Vault" + if u.selectedRemoteUsesLocalCache() { + return "Retry Cached Vault" + } + return "Retry Local Cache Setup" default: - return "Open Remote Vault" + if u.selectedRemoteUsesLocalCache() { + return "Open Cached Vault" + } + return "Create Local Cache" } } +func (u *ui) remoteLifecycleSetupSummary() string { + return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." +} + func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": @@ -3198,6 +4272,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) } + for u.importSharedVault.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.startImportSharedVaultAction() + } for u.pickKeyFile.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -3231,6 +4311,48 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } } } + for i := range u.vaultRemoteProfileClicks { + for u.vaultRemoteProfileClicks[i].Clicked(gtx) { + profiles := u.availableRemoteProfiles() + if i < len(profiles) { + u.selectVaultRemoteProfile(profiles[i].ID) + } + } + } + for i := range u.vaultRemoteCredentialClicks { + for u.vaultRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.availableRemoteCredentialEntries() + if i < len(entries) { + u.selectVaultRemoteCredentialEntry(entries[i].ID) + } + } + } + for i := range u.syncRemoteCredentialClicks { + for u.syncRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.matchingAdvancedSyncRemoteCredentialEntries() + if i < len(entries) { + u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) + } + } + } + for u.useSavedAdvancedSyncRemote.Clicked(gtx) { + u.openRemoteSyncSetupDialog() + } + for u.openSelectedVaultRemote.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.startOpenRemoteAction() + } + 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) + } for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -3257,7 +4379,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true @@ -3708,7 +4829,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { - return syncDialogSummaryCard(gtx, u.theme, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) + return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { @@ -3875,7 +4996,7 @@ func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions { }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx) + return approvalFact(u.theme, "How Persistence Works", u.remotePreferencesPersistenceSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { @@ -3963,55 +5084,83 @@ 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 { - lbl := material.Label(u.theme, unit.Sp(20), "Advanced Sync") + lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle()) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings.") + lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + if !u.shouldShowSyncDirectionChoices() { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) + }), + ) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + if !u.shouldShowSyncDirectionChoices() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !u.shouldShowSyncSourceChoices() { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) + }), + ) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection) + if !u.shouldShowSyncSourceChoices() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection) }), 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)), @@ -4021,6 +5170,50 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncPasswordField(gtx) }), + } + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save") + check.Color = accentColor + return check.Layout(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) { @@ -4032,7 +5225,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.runAdvancedSync, "Synchronize") + return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel()) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -4340,18 +5533,154 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { } func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { + profiles := u.availableRemoteProfiles() + credentials := u.availableRemoteCredentialEntries() + if len(u.vaultRemoteProfileClicks) < len(profiles) { + u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles)) + } + if len(u.vaultRemoteCredentialClicks) < len(credentials) { + u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) + } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !supportsVaultShare(runtime.GOOS) || u.vaultSharer == nil || strings.TrimSpace(u.currentShareableVaultPath()) == "" { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), - ) + } + if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.savedRemoteBindingHeading()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + if !u.shouldShowSavedRemoteBindingSelectors() { + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + return layout.Dimensions{} + } + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), profileLabel) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+credentialLabel) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), syncLabel) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }) + }), + ) + } else { + for i, profile := range profiles { + i := i + profile := profile + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(u.selectedVaultRemoteProfileID) == profile.ID + return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), profile.Name) + lbl.Color = accentColor + return lbl.Layout(gtx) + }) + }) + }) + }), + ) + } + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + for i, entry := range credentials { + i := i + entry := entry + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == entry.ID + label := entry.Title + if strings.TrimSpace(entry.Username) != "" { + label += " · " + strings.TrimSpace(entry.Username) + } + return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.vaultRemoteCredentialClicks[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) + }) + }) + }) + }), + ) + } + } + if _, ok := u.selectedVaultRemoteProfile(); ok { + if _, ok := u.selectedVaultRemoteCredentialEntry(); ok { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel()) + }), + ) + } + } + } + if u.hasOpenVault() { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if baseURL != "" && remotePath != "" && username != "" && password != "" { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.saveCurrentRemoteBindingHeading()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, u.saveCurrentRemoteBindingButtonLabel()) + }), + ) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) }) } @@ -5541,56 +6870,95 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { pathSource = append([]string{}, u.currentPath...) } crumbs, indices := u.visibleBreadcrumbs(pathSource) - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { - children := make([]layout.FlexChild, 0, len(crumbs)*2) - for i, name := range crumbs { - index := i - label := name - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.breadcrumbs[index].Clicked(gtx) { - target := indices[index] - if target == 0 { - root := u.hiddenVaultRoot() - if root == "" { - u.setCurrentPath(nil) - } else { - u.setCurrentPath([]string{root}) - } - } else { - nextPath := pathSource[:target] - root := u.hiddenVaultRoot() - if root != "" { - nextPath = append([]string{root}, nextPath...) - } - u.setCurrentPath(nextPath) - } - u.filter() - } - btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) - btn.TextSize = unit.Sp(11) - if u.mode == "phone" { - btn.TextSize = unit.Sp(9) - btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} - } else { - btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} - } - return btn.Layout(gtx) - })) - if i < len(crumbs)-1 { + crumbBar := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(crumbs)*2) + for i, name := range crumbs { + index := i + label := name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "/") - lbl.Color = mutedColor - inset := unit.Dp(6) - if u.mode == "phone" { - inset = unit.Dp(4) + for u.breadcrumbs[index].Clicked(gtx) { + target := indices[index] + if target == 0 { + root := u.hiddenVaultRoot() + if root == "" { + u.setCurrentPath(nil) + } else { + u.setCurrentPath([]string{root}) + } + } else { + nextPath := pathSource[:target] + root := u.hiddenVaultRoot() + if root != "" { + nextPath = append([]string{root}, nextPath...) + } + u.setCurrentPath(nextPath) + } + u.filter() } - return layout.UniformInset(inset).Layout(gtx, lbl.Layout) + btn := material.Button(u.theme, &u.breadcrumbs[index], label) + btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) + btn.TextSize = unit.Sp(11) + if u.mode == "phone" { + btn.TextSize = unit.Sp(9) + btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} + } else { + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + } + return btn.Layout(gtx) })) + if i < len(crumbs)-1 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "/") + lbl.Color = mutedColor + inset := unit.Dp(6) + if u.mode == "phone" { + inset = unit.Dp(4) + } + return layout.UniformInset(inset).Layout(gtx, lbl.Layout) + })) + } } + return children + }()...) + } + if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() { + return crumbBar(gtx) + } + children := []layout.FlexChild{ + layout.Rigid(crumbBar), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + } + if u.shouldShowDirectRemoteSyncShortcut() { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + })) + } + if u.shouldShowRemoteSyncSetupShortcut() { + if u.shouldShowDirectRemoteSyncShortcut() { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } - return children - }()...) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + })) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() { + 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.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...) } func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { @@ -5921,7 +7289,10 @@ func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { } } -func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions { +func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string { + if purpose == syncDialogPurposeRemoteSetup { + return "Push this local vault to a WebDAV target and save that target for future sync." + } sourceLabel := "another local vault file" if source == syncSourceRemote { sourceLabel = "another WebDAV-backed vault" @@ -5930,6 +7301,10 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo if direction == syncDirectionPush { action = "Push the current vault into" } + return action + " " + sourceLabel + "." +} + +func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -5940,7 +7315,7 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(th, unit.Sp(14), action+" "+sourceLabel+".") + lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction)) lbl.Color = th.Palette.Fg return lbl.Layout(gtx) }), @@ -6181,5 +7556,19 @@ func runFilePicker(name string, args ...string) (string, error) { if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return parsePickedFilePath(output) +} + +func parsePickedFilePath(output []byte) (string, error) { + lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { + return line, nil + } + } + return "", fmt.Errorf("file picker did not return a path") } diff --git a/main_test.go b/main_test.go index f41920d..0c95931 100644 --- a/main_test.go +++ b/main_test.go @@ -83,6 +83,49 @@ func (s summarySession) HasVault() bool func (s summarySession) IsLocked() bool { return s.locked } func (s summarySession) IsRemote() bool { return s.remote } +type remoteOpenCaptureSession struct { + model vault.Model + remoteClient webdav.Client + remotePath string +} + +func (s *remoteOpenCaptureSession) Current() (vault.Model, error) { + return s.model, nil +} + +func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + s.remoteClient = client + s.remotePath = path + return nil +} + +type saveCaptureSession struct { + model vault.Model + saveCount int + saveErr error +} + +func (s *saveCaptureSession) Current() (vault.Model, error) { + return s.model, nil +} + +func (s *saveCaptureSession) Save() error { + s.saveCount++ + return s.saveErr +} + +type captureVaultSharer struct { + path string + title string + err error +} + +func (s *captureVaultSharer) ShareVault(path, title string) error { + s.path = path + s.title = title + return s.err +} + func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { t.Parallel() @@ -90,7 +133,7 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "3", Title: "Surveillance Console", Username: "bashertarr", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }) @@ -128,7 +171,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u := newUIWithModel(mode, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, - {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "entry-2", Title: "Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, Templates: []vault.Entry{ {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, @@ -136,7 +179,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) }, RecycleBin: []vault.Entry{ {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}}, - {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }) @@ -144,8 +187,8 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u.state.NavigateToPath([]string{"Root", "Internet"}) u.search.SetText("climate") u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) { - t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Vent"}) { + t.Fatalf("entries filteredTitles() = %v, want [Vault Vent]", got) } u.showTemplatesSection() @@ -162,11 +205,11 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u.showRecycleBinSection() u.search.SetText("climate") u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) { - t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Vault Vent"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted Vault Vent]", got) } - if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) { - t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got) + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Safe House"}) { + t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Safe House]", got) } }) } @@ -246,7 +289,12 @@ func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) started := make(chan struct{}) release := make(chan struct{}) runs := 0 @@ -282,7 +330,12 @@ func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.vaultPath.SetText("/tmp/example.kdbx") u.lastLifecycleAction = "open vault" @@ -327,21 +380,26 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, - {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, {ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }) u.state.NavigateToPath([]string{"Crew"}) - if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("childGroups() = %v, want [Internet Security Office]", got) } } func TestUIAPITokenLifecycleManagement(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -386,7 +444,12 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) { func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -524,7 +587,12 @@ func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) { func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -537,7 +605,7 @@ func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Fatalf("issueAPITokenAction() error = %v", err) } u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) - u.apiPolicyPath.SetText("Crew / codex") + u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { @@ -576,7 +644,7 @@ func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection( } firstID := u.state.SelectedEntryID u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) - u.apiPolicyPath.SetText("Crew / codex") + u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { @@ -1096,7 +1164,7 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1175,7 +1243,12 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { t.Parallel() manager := &session.Manager{} - u := newUIWithSession("desktop", manager) + u := newUIWithState("desktop", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -1247,7 +1320,12 @@ func TestUISaveSettingsPersistsUIPreferences(t *testing.T) { func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -1322,7 +1400,7 @@ func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1369,7 +1447,13 @@ func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { t.Fatalf("WriteFile(updated.key) error = %v", err) } - u := newUIWithSession("desktop", &session.Manager{}) + stateDir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(stateDir, "default.kdbx"), + RecentVaultsPath: filepath.Join(stateDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(stateDir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(stateDir, "ui-prefs.json"), + }) u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) u.masterPassword.SetText("old-password") if err := u.createVaultAction(); err != nil { @@ -1520,7 +1604,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -1547,10 +1631,17 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { })) defer server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) @@ -1560,7 +1651,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1585,7 +1676,7 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, @@ -1605,7 +1696,12 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { defer server.Close() manager := &session.Manager{} - u := newUIWithSession("desktop", manager) + u := newUIWithState("desktop", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText(key.Password) u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") @@ -1643,7 +1739,12 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { url := server.URL server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(url) u.remotePath.SetText("vaults/main.kdbx") @@ -1658,6 +1759,783 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { } } +func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + sess := &remoteOpenCaptureSession{ + model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + }, + } + + u := newUIWithSession("desktop", sess) + u.masterPassword.SetText("correct horse battery staple") + u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath editor = %q, want resolved profile path", got) + } +} + +func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { + t.Parallel() + + sess := &remoteOpenCaptureSession{ + model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + }, + } + + u := newUIWithSession("desktop", sess) + u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText("/vaults/family.kdbx") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } +} + +func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + } +} + +func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + localKey := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "", + Path: "files/family/keepass.kdbx", + }}, + } + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + localModel.RemoteProfiles[0].BaseURL = server.URL + + manager := &session.Manager{} + if err := manager.Create(localModel, localKey); err != nil { + t.Fatalf("manager.Create() error = %v", err) + } + + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(localKey.Password) + u.vaultPath.SetText(localPath) + u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + + u.startOpenRemoteAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want server URL from selected profile", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want selected profile path", got) + } +} + +func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { + t.Parallel() + + localKey := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "", + Path: "files/family/keepass.kdbx", + }}, + } + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + localModel.RemoteProfiles[0].BaseURL = server.URL + + manager := &session.Manager{} + if err := manager.Create(localModel, localKey); err != nil { + t.Fatalf("manager.Create() error = %v", err) + } + + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(localKey.Password) + u.vaultPath.SetText(localPath) + + u.startOpenRemoteAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want server URL from implicit profile", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want implicit profile path", got) + } +} + +func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + u.startOpenRemoteAction() + + if got := u.loadingMessage; got != "Open remote vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + } +} + +func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + } + writeKDBXMainTestFile(t, path, model, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + u.selectedVaultRemoteProfileID = "stale-profile" + u.selectedVaultRemoteCredentialEntryID = "stale-credential" + u.remoteBaseURL.SetText("https://stale.example.invalid") + u.remotePath.SetText("stale/path.kdbx") + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got) + } +} + +func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + } + writeKDBXMainTestFile(t, path, model, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + u.selectedVaultRemoteProfileID = "stale-profile" + u.selectedVaultRemoteCredentialEntryID = "stale-credential" + u.remoteBaseURL.SetText("https://stale.example.invalid") + u.remotePath.SetText("stale/path.kdbx") + + u.startOpenVaultAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } +} + +func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ + Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("vault-console"); err != nil { + t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) + } +} + +func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Local Cache", Path: []string{"Root", "Internet"}}, + {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v, want local open to succeed even if auto-sync fails", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want local vault opened", err) + } + if got := u.state.StatusMessage; !strings.Contains(got, "Remote sync on open failed:") { + t.Fatalf("StatusMessage = %q, want nonfatal remote sync failure notice", got) + } + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage = %q, want empty for nonfatal remote sync failure", got) + } +} + +func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var ( + savedRemote []byte + putCount int + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", "\"v1\"") + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + var err error + savedRemote, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if err := u.saveAction(); err != nil { + t.Fatalf("saveAction() error = %v", err) + } + + if putCount == 0 { + t.Fatal("remote PUT count = 0, want automatic remote synchronize on save") + } + loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key) + if err != nil { + t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err) + } + if _, err := loaded.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err) + } +} + +func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { + t.Parallel() + + output := strings.Join([]string{ + "(zenity:1): Gdk-DEBUG: Ignoring portal setting", + "/home/tester/vaults/family.kdbx", + "", + }, "\n") + + got, err := parsePickedFilePath([]byte(output)) + if err != nil { + t.Fatalf("parsePickedFilePath() error = %v", err) + } + if got != "/home/tester/vaults/family.kdbx" { + t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/family.kdbx", got) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel() @@ -1668,7 +2546,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -1694,10 +2572,17 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { })) defer server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) @@ -1706,7 +2591,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -2077,7 +2962,7 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, - {ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}}, + {ID: "entry-2", Title: "Security Office", Path: []string{"Root", "Security Office"}}, }, }) u.showEntriesSection() @@ -2088,8 +2973,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { if err := u.createGroupAction(); err != nil { t.Fatalf("createGroupAction() error = %v", err) } - if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) { + t.Fatalf("childGroups() after create = %v, want [Finance Internet Security Office]", got) } u.state.EnterGroup("Finance") @@ -2104,8 +2989,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { u.state.NavigateToPath([]string{"Root"}) u.filter() - if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Internet", "Security Office"}) { + t.Fatalf("childGroups() after rename = %v, want [Budget Internet Security Office]", got) } u.state.NavigateToPath([]string{"Root", "Budget"}) @@ -2117,8 +3002,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { if !slices.Equal(u.state.CurrentPath, []string{"Root"}) { t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath) } - if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("childGroups() after delete = %v, want [Internet Security Office]", got) } } @@ -2182,7 +3067,7 @@ func TestUIParentGroupDoesNotShowDescendantEntries(t *testing.T) { {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }) u.showEntriesSection() @@ -2251,17 +3136,17 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, { ID: "ha", - Title: "Home Assistant", + Title: "Security Office", Username: "rustyryan", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://ha.example.test", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, }) @@ -2270,7 +3155,7 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - u.entryPath.SetText("Root / Home Assistant") + u.entryPath.SetText("Root / Security Office") if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) @@ -2282,10 +3167,10 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { t.Fatalf("filteredTitles() in source group = %v, want empty after move", got) } - u.state.NavigateToPath([]string{"Root", "Home Assistant"}) + u.state.NavigateToPath([]string{"Root", "Security Office"}) u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) { - t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Security Office", "Vault Console"}) { + t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Security Office]", got) } } @@ -2298,7 +3183,7 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2309,14 +3194,14 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - u.entryPassword.SetText("token-2") + u.entryPassword.SetText("bellagio-pass-2") if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) } u.filter() - if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" { - t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry) + if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-2" { + t.Fatalf("selectedEntry() = %#v, want updated password bellagio-pass-2", entry) } if err := u.duplicateSelectedEntryAction(); err != nil { @@ -2358,7 +3243,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { u.entryID.SetText("bellagio") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryNotes.SetText("Registrar account") u.entryTags.SetText("dns, registrar") @@ -2381,7 +3266,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { if !ok { t.Fatal("selectedEntry() ok = false, want created entry") } - if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" { + if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "bellagio-pass-1" || item.URL != "https://bellagio.example.invalid" { t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item) } if item.Notes != "Registrar account" { @@ -2439,7 +3324,7 @@ func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2522,7 +3407,7 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { u.entryID.SetText("entry-1") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryPath.SetText("Root / Internet") if err := u.instantiateSelectedTemplateAction(); err != nil { @@ -2658,7 +3543,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T u.entryID.SetText("entry-1") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryPath.SetText("Root / Internet") if err := u.instantiateSelectedTemplateAction(); err != nil { @@ -2786,7 +3671,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ @@ -2794,7 +3679,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2813,8 +3698,8 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { t.Fatalf("restoreSelectedHistoryAction() error = %v", err) } u.filter() - if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" { - t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry) + if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-1" { + t.Fatalf("selectedEntry() = %#v, want restored password bellagio-pass-1", entry) } } @@ -2827,7 +3712,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ @@ -2835,7 +3720,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Notes: "previous token", @@ -2844,7 +3729,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console-h0", Title: "Vault Console", Username: "dannyocean", - Password: "token-0", + Password: "bellagio-pass-0", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Notes: "oldest token", @@ -2875,8 +3760,8 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { if !ok { t.Fatal("selectedHistoryEntry() ok = false, want true") } - if selected.Password != "token-0" { - t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0") + if selected.Password != "bellagio-pass-0" { + t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "bellagio-pass-0") } } func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { @@ -2888,7 +3773,7 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -3011,7 +3896,7 @@ func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -3106,7 +3991,7 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { u = newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, }, }) u.clipboardWriter = &memoryClipboardWriter{} @@ -3185,7 +4070,7 @@ func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, @@ -3636,7 +4521,7 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, - {ID: "2", Title: "Home Assistant", Path: []string{"keepass", "Crew", "Home"}}, + {ID: "2", Title: "Security Office", Path: []string{"keepass", "Crew", "Safe House"}}, }, }) @@ -3772,8 +4657,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { first.currentPath = []string{"Root", "Internet"} first.syncedPath = []string{"Root", "Internet"} first.noteRecentVault("/tmp/one.kdbx") - first.currentPath = []string{"Root", "Home Assistant"} - first.syncedPath = []string{"Root", "Home Assistant"} + first.currentPath = []string{"Root", "Security Office"} + first.syncedPath = []string{"Root", "Security Office"} first.noteRecentVault("/tmp/two.kdbx") first.currentPath = []string{"Root", "Finance"} first.syncedPath = []string{"Root", "Finance"} @@ -3790,8 +4675,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got) } - if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) { - t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got) + if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Security Office"}) { + t.Fatalf("recentVaultGroup(two) = %v, want [Root Security Office]", got) } } @@ -3812,7 +4697,7 @@ func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -3853,11 +4738,11 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { first.recentRemotesPath = configPath first.recentRemotes = nil first.currentPath = []string{"Root", "Internet"} - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) - first.currentPath = []string{"Root", "Home"} - first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") + first.currentPath = []string{"Root", "Safe House"} + first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx") first.currentPath = []string{"Root", "Finance"} - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentRemotesPath = configPath @@ -3867,17 +4752,164 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { if got := len(second.recentRemotes); got != 2 { t.Fatalf("len(recentRemotes) = %d, want 2", got) } - if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" { - t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got) + if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { + t.Fatalf("recentRemotes[0] = %#v, want updated location-only record", got) } if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got) } - if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" { - t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got) + if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Safe House"}) { + t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Safe House]", got) } - if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Home"}) { - t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Home]", got) +} + +func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "recent-remotes.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.recentRemotesPath = configPath + first.recentRemotes = nil + first.currentPath = []string{"Root", "Internet"} + first.vaultPath.SetText("/vaults/family.kdbx") + first.selectedVaultRemoteProfileID = "remote-profile-1" + first.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") + + second := newUIWithSession("desktop", &session.Manager{}) + second.recentRemotesPath = configPath + second.recentRemotes = nil + second.loadRecentRemotes() + + if got := len(second.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := second.recentRemotes[0] + if record.LocalVaultPath != "/vaults/family.kdbx" { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want /vaults/family.kdbx", record.LocalVaultPath) + } + if record.RemoteProfileID != "remote-profile-1" { + t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want remote-profile-1", record.RemoteProfileID) + } + if record.CredentialEntryID != "remote-creds-1" { + t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID) + } + if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) { + t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode) + } +} + +func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "recent-remotes.json") + content := `[ + { + "baseUrl": "https://dav.example.com", + "path": "vaults/home.kdbx", + "username": "debbieocean", + "password": "secret-1", + "lastGroup": ["Root", "Internet"] + } +]` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.recentRemotesPath = configPath + u.recentRemotes = nil + u.loadRecentRemotes() + + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + if got := u.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { + t.Fatalf("recentRemotes[0] = %#v, want location-only record", got) + } + if !u.recentRemotes[0].NeedsMigration { + t.Fatal("recentRemotes[0].NeedsMigration = false, want true for legacy saved credentials") + } + if got := u.recentRemotes[0].Username; got != "" { + t.Fatalf("recentRemotes[0].Username = %q, want empty after migration strip", got) + } + if got := u.recentRemotes[0].Password; got != "" { + t.Fatalf("recentRemotes[0].Password = %q, want empty after migration strip", got) + } +} + +func TestUINewUIShowsMigrationStatusForLegacyRecentRemoteCredentials(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + recentRemotesPath := filepath.Join(dir, "recent-remotes.json") + content := `[ + { + "baseUrl": "https://dav.example.com", + "path": "vaults/home.kdbx", + "username": "debbieocean", + "password": "secret-1" + } +]` + if err := os.WriteFile(recentRemotesPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) + } + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "default.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: recentRemotesPath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + + if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { + t.Fatalf("StatusMessage = %q, want legacy recent-remote migration notice for the selected startup remote", got) + } +} + +func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }) + + if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" { + t.Fatalf("vaultPath = %q, want /vaults/family.kdbx", got) + } + if got := u.selectedVaultRemoteProfileID; got != "remote-profile-1" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want remote-profile-1", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) + } +} + +func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + NeedsMigration: true, + }) + + if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { + t.Fatalf("StatusMessage = %q, want legacy per-record migration notice", got) } } @@ -3898,24 +4930,21 @@ func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) { first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } first.noteRecentVault("/tmp/local.kdbx") first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}, paths) - if got := second.lifecycleMode; got != "remote" { - t.Fatalf("lifecycleMode = %q, want remote", got) + if got := second.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode = %q, want local", got) } - if got := second.remoteBaseURL.Text(); got != "https://dav.example.com" { - t.Fatalf("remoteBaseURL = %q, want https://dav.example.com", got) + if got := second.vaultPath.Text(); got != "/tmp/local.kdbx" { + t.Fatalf("vaultPath = %q, want /tmp/local.kdbx", got) } - if got := second.remotePath.Text(); got != "vaults/home.kdbx" { - t.Fatalf("remotePath = %q, want vaults/home.kdbx", got) + if got := second.remoteUsername.Text(); got != "" { + t.Fatalf("remoteUsername = %q, want empty for location-only recent remote", got) } - if got := second.remoteUsername.Text(); got != "alice" { - t.Fatalf("remoteUsername = %q, want alice", got) - } - if got := second.remotePassword.Text(); got != "secret-1" { - t.Fatalf("remotePassword = %q, want secret-1", got) + if got := second.remotePassword.Text(); got != "" { + t.Fatalf("remotePassword = %q, want empty for location-only recent remote", got) } } @@ -4311,13 +5340,13 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.recentRemotes = []recentRemoteRecord{{ - BaseURL: "https://dav.example.com", - Path: "vaults/home.kdbx", - Username: "alice", - Password: "secret-1", + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", }} u.recentRemoteClicks = make([]widget.Clickable, 1) + u.remoteUsername.SetText("debbieocean") + u.remotePassword.SetText("secret-1") u.remotePassword.Mask = 0 u.recentRemoteClicks[0].Click() @@ -4326,15 +5355,18 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { record := u.recentRemotes[0] u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) - u.remoteUsername.SetText(record.Username) - u.remotePassword.SetText(record.Password) u.remotePassword.Mask = '•' - u.rememberRemoteAuth.Value = true } if got := u.remotePassword.Mask; got != '•' { t.Fatalf("remotePassword.Mask = %q, want bullet mask", got) } + if got := u.remoteUsername.Text(); got != "debbieocean" { + t.Fatalf("remoteUsername = %q, want preserved manual username", got) + } + if got := u.remotePassword.Text(); got != "secret-1" { + t.Fatalf("remotePassword = %q, want preserved manual password", got) + } } func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { @@ -4387,6 +5419,31 @@ func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T) } } +func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.vaultPath.SetText("") + u.recentVaults = []string{"/tmp/older.kdbx"} + u.recentVaultUsedAt["/tmp/older.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/tmp/family-cache.kdbx", + UsedAt: time.Date(2026, time.April, 5, 2, 2, 3, 0, time.UTC).Format(time.RFC3339Nano), + }} + + u.restoreStartupLifecycleTarget() + + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode after restore = %q, want local", got) + } + if got := u.vaultPath.Text(); got != "/tmp/family-cache.kdbx" { + t.Fatalf("vaultPath after restore = %q, want /tmp/family-cache.kdbx", got) + } +} + func TestShowLocalVaultChooser(t *testing.T) { t.Parallel() @@ -4411,7 +5468,13 @@ func TestShowLocalVaultChooser(t *testing.T) { func TestShowRemoteConnectionChooser(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("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.lifecycleMode = "remote" u.remoteBaseURL.SetText("") u.remotePath.SetText("") @@ -4442,16 +5505,20 @@ func TestShowRemoteConnectionChooser(t *testing.T) { func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("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"), + }) if u.hasSelectedRemoteTarget() { t.Fatal("hasSelectedRemoteTarget() = true, want false before selecting a saved remote connection") } u.applyRecentRemoteRecord(recentRemoteRecord{ - BaseURL: "https://dav.crew.example.invalid", - Path: "vaults/bellagio.kdbx", - Username: "dannyocean", - Password: "topsecret", + BaseURL: "https://dav.crew.example.invalid", + Path: "vaults/bellagio.kdbx", }) if !u.hasSelectedRemoteTarget() { @@ -4459,6 +5526,1157 @@ func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { } } +func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + 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", + }, + { + ID: "archive-webdav", + Name: "Archive Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/archive.kdbx", + }, + }, + }) + + got := u.availableRemoteProfiles() + if len(got) != 2 { + t.Fatalf("len(availableRemoteProfiles()) = %d, want 2", len(got)) + } + if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + t.Fatalf("availableRemoteProfiles() = %#v, want profiles sorted by name/id", got) + } +} + +func TestUIAvailableRemoteCredentialEntriesUsesVaultEntries(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + }, + }) + + got := u.availableRemoteCredentialEntries() + if len(got) != 2 { + t.Fatalf("len(availableRemoteCredentialEntries()) = %d, want 2", len(got)) + } + if got[0].ID != "cred-1" || got[1].ID != "cred-2" { + t.Fatalf("availableRemoteCredentialEntries() = %#v, want entries sorted by title", got) + } +} + +func TestUIAvailableRemoteProfilesReturnsEmptyWhenLocked(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", summarySession{locked: true}) + if got := u.availableRemoteProfiles(); len(got) != 0 { + t.Fatalf("availableRemoteProfiles() = %#v, want empty when locked", got) + } +} + +func TestUISelectVaultRemoteProfileUpdatesSelectionAndTargetFields(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + 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.selectVaultRemoteProfile("family-webdav") + + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } +} + +func TestUISelectVaultRemoteCredentialEntryUpdatesSelection(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + }) + + u.selectVaultRemoteCredentialEntry("remote-creds-1") + + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } +} + +func TestUIShouldShowSavedRemoteBindingSelectorsWhenMultipleChoices(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{ + {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, + {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, + }, + }) + + if !u.shouldShowSavedRemoteBindingSelectors() { + t.Fatal("shouldShowSavedRemoteBindingSelectors() = false, want true with multiple profiles and credentials") + } +} + +func TestUIShouldHideSavedRemoteBindingSelectorsForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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", + }}, + }) + + if u.shouldShowSavedRemoteBindingSelectors() { + t.Fatal("shouldShowSavedRemoteBindingSelectors() = true, want false with a single saved binding choice") + } +} + +func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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", + }}, + }) + + profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + t.Fatal("savedRemoteBindingSummary() ok = false, want true") + } + if profileLabel != "Family Vault" { + t.Fatalf("profileLabel = %q, want Family Vault", profileLabel) + } + if credentialLabel != "Bellagio WebDAV Sign-In · linuscaldwell" { + t.Fatalf("credentialLabel = %q, want Bellagio WebDAV Sign-In · linuscaldwell", credentialLabel) + } + if syncLabel != "Sync manually when you choose Use Remote Sync." { + t.Fatalf("syncLabel = %q, want manual sync summary", syncLabel) + } +} + +func TestUISavedRemoteBindingSummaryMentionsAutomaticSyncMode(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave + + _, _, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + t.Fatal("savedRemoteBindingSummary() ok = false, want true") + } + if syncLabel != "Syncs automatically on open and save." { + t.Fatalf("syncLabel = %q, want automatic sync summary", syncLabel) + } +} + +func TestUISavedRemoteBindingHeadingUsesSyncLanguageForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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", + }}, + }) + + if got := u.savedRemoteBindingHeading(); got != "Use this vault's saved remote sync target" { + t.Fatalf("savedRemoteBindingHeading() = %q, want sync-target guidance", got) + } +} + +func TestUIOpenSelectedVaultRemoteButtonLabelUsesSyncLanguageForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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", + }}, + }) + + if got := u.openSelectedVaultRemoteButtonLabel(); got != "Use Remote Sync" { + t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Use Remote Sync", got) + } +} + +func TestUIOpenSelectedVaultRemoteButtonLabelUsesSavedRemoteLanguageForMultipleChoices(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{ + {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, + {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, + }, + }) + + if got := u.openSelectedVaultRemoteButtonLabel(); got != "Open Saved Remote" { + t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Open Saved Remote", got) + } +} + +func TestUIShouldShowDirectRemoteSyncShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true for an opened vault with a saved remote binding") + } +} + +func TestUIRemoteSyncShortcutsHaveParityAcrossModes(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"desktop", "phone"} { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true") + } + if !u.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true") + } + if !u.shouldShowRemoveRemoteSyncShortcut() { + t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true") + } + if u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when a binding exists") + } + + if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { + t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) + } + if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { + t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) + } + if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" { + t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got) + } + }) + } +} + +func TestUIShouldHideDirectRemoteSyncShortcutWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.state.Section = appstate.SectionEntries + + if u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a saved remote binding") + } +} + +func TestUIDirectRemoteSyncShortcutLabelUsesSyncLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { + t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) + } +} + +func TestUIShouldShowRemoteSyncSetupShortcutForOpenedLocalVaultWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Path: []string{"Crew", "Internet"}, + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true for opened local vault without saved binding") + } +} + +func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"desktop", "phone"} { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Path: []string{"Crew", "Internet"}, + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true") + } + if u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a binding") + } + if u.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = true, want false without a binding") + } + if u.shouldShowRemoveRemoteSyncShortcut() { + t.Fatal("shouldShowRemoveRemoteSyncShortcut() = true, want false without a binding") + } + if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { + t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) + } + }) + } +} + +func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when saved binding already exists") + } +} + +func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { + t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) + } +} + +func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true when a saved binding exists") + } +} + +func TestUIRemoteSyncSettingsShortcutLabelUsesClearLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { + t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) + } +} + +func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + 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() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + }) + u.vaultPath.SetText("/vaults/family.kdbx") + + u.openRemoteSyncSetupDialog() + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want true") + } + if got := u.syncDialogPurpose; got != syncDialogPurposeRemoteSetup { + t.Fatalf("syncDialogPurpose = %q, want remote setup", got) + } + if got := u.syncSourceMode; got != syncSourceRemote { + t.Fatalf("syncSourceMode = %q, want remote", got) + } + if got := u.syncDirection; got != syncDirectionPush { + t.Fatalf("syncDirection = %q, want push", got) + } + if got := u.syncLocalPath.Text(); got != "/vaults/family.kdbx" { + t.Fatalf("syncLocalPath = %q, want current vault path", got) + } + if got := u.syncRemoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("syncRemoteBaseURL = %q, want saved remote base URL", got) + } + if got := u.syncRemotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("syncRemotePath = %q, want saved remote path", got) + } + if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { + t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) + } + if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { + t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { + t.Parallel() + + 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"), + }) + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Open this vault to set up a WebDAV sync target for it." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want setup guidance", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsAutomaticSync(t *testing.T) { + t.Parallel() + + 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.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Syncs automatically on open and save." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want automatic sync guidance", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsManualSync(t *testing.T) { + t.Parallel() + + 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.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Sync manually when you choose Use Remote Sync." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want manual sync guidance", got) + } +} + +func TestUISyncDialogUsesRemoteSetupCopy(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSetupAutomatic.Value = true + + if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) + } + if got := u.syncDialogDescription(); got != "Send this local vault to a WebDAV target, then use that target for future sync." { + t.Fatalf("syncDialogDescription() = %q, want remote setup guidance", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Set Up Remote Sync", got) + } + if u.shouldShowSyncDirectionChoices() { + t.Fatal("shouldShowSyncDirectionChoices() = true, want false for remote setup") + } + if u.shouldShowSyncSourceChoices() { + t.Fatal("shouldShowSyncSourceChoices() = true, want false for remote setup") + } + if got := syncDialogSummaryText(syncDialogPurposeRemoteSetup, syncSourceRemote, syncDirectionPush); got != "Push this local vault to a WebDAV target and save that target for future sync." { + t.Fatalf("syncDialogSummaryText(remote setup) = %q, want setup-specific summary", got) + } + if got := u.syncSetupMode(); got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("syncSetupMode() = %q, want automatic_on_open_save", got) + } +} + +func TestUISyncDialogUsesRemoteSettingsCopyForExistingBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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", + }}, + }) + u.state.Section = appstate.SectionEntries + u.vaultPath.SetText("/vaults/family.kdbx") + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + + u.openRemoteSyncSetupDialog() + + if got := u.syncDialogTitle(); got != "Remote Sync Settings" { + t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) + } + if got := u.syncDialogDescription(); got != "Review or change this vault's saved WebDAV target, credentials, and sync mode." { + t.Fatalf("syncDialogDescription() = %q, want settings guidance", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Save Remote Sync Settings" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Save Remote Sync Settings", got) + } + if u.syncSetupAutomatic.Value { + t.Fatal("syncSetupAutomatic.Value = true, want false for an existing manual binding") + } +} + +func TestUISyncDialogUsesAdvancedCopy(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.syncDialogPurpose = syncDialogPurposeAdvanced + + if got := u.syncDialogTitle(); got != "Advanced Sync" { + t.Fatalf("syncDialogTitle() = %q, want Advanced Sync", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Synchronize" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Synchronize", got) + } + if !u.shouldShowSyncDirectionChoices() { + t.Fatal("shouldShowSyncDirectionChoices() = false, want true for advanced sync") + } + if !u.shouldShowSyncSourceChoices() { + t.Fatal("shouldShowSyncSourceChoices() = false, want true for advanced sync") + } + if got := syncDialogSummaryText(syncDialogPurposeAdvanced, syncSourceRemote, syncDirectionPush); got != "Push the current vault into another WebDAV-backed vault." { + t.Fatalf("syncDialogSummaryText(advanced) = %q, want advanced summary", got) + } +} + +func TestUIRemoteSyncSetupPersistsBindingAfterSuccessfulPush(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: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPut: + w.Header().Set("ETag", "\"v1\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + 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) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openRemoteSyncSetupDialog() + u.syncRemoteBaseURL.SetText(server.URL) + u.syncRemotePath.SetText("vaults/other.kdbx") + u.syncRemoteUsername.SetText("linuscaldwell") + u.syncRemotePassword.SetText("bellagio-pass-1") + + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + if got := u.selectedVaultRemoteProfileID; got == "" { + t.Fatal("selectedVaultRemoteProfileID = empty, want saved binding") + } + if got := u.selectedVaultRemoteCredentialEntryID; got == "" { + t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want saved credential binding") + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) + } + if got := u.remotePath.Text(); got != "vaults/other.kdbx" { + t.Fatalf("remotePath = %q, want vaults/other.kdbx", got) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + if got := u.recentRemotes[0].LocalVaultPath; got != currentPath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", got, currentPath) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) + } + if got := u.state.StatusMessage; got != "Remote sync is set up for this vault." { + t.Fatalf("StatusMessage = %q, want setup success message", got) + } + if u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true after setup, want false") + } + if !u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false after setup, want true") + } + + var reopened session.Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopened.Open(currentPath) error = %v", err) + } + reopenedModel, err := reopened.Current() + if err != nil { + t.Fatalf("reopened.Current() error = %v", err) + } + profiles := reopenedModel.RemoteProfiles + if len(profiles) != 1 { + t.Fatalf("len(reopened.RemoteProfiles) = %d, want 1 persisted profile", len(profiles)) + } + if profiles[0].BaseURL != server.URL || profiles[0].Path != "vaults/other.kdbx" { + t.Fatalf("reopened.RemoteProfiles[0] = %#v, want persisted setup target", profiles[0]) + } + cred, err := reopenedModel.EntryByID(u.selectedVaultRemoteCredentialEntryID) + if err != nil { + t.Fatalf("reopened.EntryByID(saved credential) error = %v", err) + } + if cred.Username != "linuscaldwell" || cred.Password != "bellagio-pass-1" { + t.Fatalf("reopened saved credential = %#v, want linuscaldwell/bellagio-pass-1", cred) + } +} + +func TestUIRemoteSyncSetupCanPersistManualSyncMode(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: "entry-current", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, + }, key) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPut: + w.Header().Set("ETag", "\"v1\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + 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) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openRemoteSyncSetupDialog() + u.syncSetupAutomatic.Value = false + u.syncRemoteBaseURL.SetText(server.URL) + u.syncRemotePath.SetText("vaults/manual.kdbx") + u.syncRemoteUsername.SetText("linuscaldwell") + u.syncRemotePassword.SetText("bellagio-pass-1") + + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got) + } + if got := u.recentRemotes[0].SyncMode; got != string(appstate.SyncModeManual) { + t.Fatalf("recentRemotes[0].SyncMode = %q, want manual", got) + } +} + +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: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-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() + + u := newUIWithModel("desktop", vault.Model{}) + u.currentPath = []string{"Crew", "Internet"} + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + if err := u.saveCurrentRemoteBindingAction(); err != nil { + t.Fatalf("saveCurrentRemoteBindingAction() error = %v", err) + } + + profiles := u.availableRemoteProfiles() + if len(profiles) != 1 { + t.Fatalf("len(availableRemoteProfiles()) = %d, want 1", len(profiles)) + } + if profiles[0].BaseURL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("saved profile = %#v, want persisted base URL", profiles[0]) + } + + entries := u.availableRemoteCredentialEntries() + var found bool + for _, entry := range entries { + if entry.Username == "linuscaldwell" && entry.Password == "bellagio-pass-1" { + found = true + 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 { + t.Fatalf("availableRemoteCredentialEntries() = %#v, want persisted linuscaldwell/bellagio-pass-1 entry", entries) + } + + if got := u.selectedVaultRemoteProfileID; got == "" { + t.Fatal("selectedVaultRemoteProfileID = empty, want selected saved profile") + } + if got := u.selectedVaultRemoteCredentialEntryID; got == "" { + t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want selected saved credential entry") + } + if !u.state.Dirty { + t.Fatal("state.Dirty = false, want true after saving binding into vault") + } +} + +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: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", 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 Bellagio WebDAV Sign-In matches", got) + } +} + +func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { + t.Parallel() + + localVaultPath := filepath.Join(t.TempDir(), "family.kdbx") + u := newUIWithState("desktop", &uiSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}}, + {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", 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", + }}, + }}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultPath.SetText(localVaultPath) + u.syncSourceMode = syncSourceRemote + u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.syncRemotePath.SetText("files/family/keepass.kdbx") + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: localVaultPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }} + + got := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(got) != 1 { + t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 1 from saved binding", len(got)) + } + if got[0].ID != "remote-creds-1" { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want remote-creds-1 from saved binding", got) + } +} + +func TestUIApplyAdvancedSyncRemoteCredentialEntryFillsCredentials(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + entry := vault.Entry{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + URL: "https://dav.example.invalid/remote.php/dav", + } + + u.applyAdvancedSyncRemoteCredentialEntry(entry) + + if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { + t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) + } + if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { + t.Fatalf("syncRemotePassword = %q, want bellagio-pass-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() + + u := newUIWithModel("desktop", vault.Model{}) + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + + if err := u.saveCurrentRemoteBindingAction(); err == nil { + t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error") + } +} + func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { t.Parallel() @@ -4469,7 +6687,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("dannyocean") u.remotePassword.SetText("topsecret") - u.rememberRemoteAuth.Value = true u.masterPassword.SetText("correct horse battery staple") u.keyFilePath.SetText("/vaults/keyfile.keyx") u.search.SetText("crew") @@ -4501,9 +6718,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } - if u.rememberRemoteAuth.Value { - t.Fatal("rememberRemoteAuth = true, want false") - } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword = %q, want empty", got) } @@ -4537,7 +6751,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) { u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("rustyryan") u.remotePassword.SetText("topsecret") - u.rememberRemoteAuth.Value = true u.switchToLifecycleSelection("remote") @@ -4562,9 +6775,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) { if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } - if u.rememberRemoteAuth.Value { - t.Fatal("rememberRemoteAuth = true, want false") - } } func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { @@ -4644,90 +6854,43 @@ func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) { } } -func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - record recentRemoteRecord - want string - }{ - { - name: "location_only", - record: recentRemoteRecord{}, - want: "location only", - }, - { - name: "username_only", - record: recentRemoteRecord{Username: "alice"}, - want: "saved username", - }, - { - name: "password_only", - record: recentRemoteRecord{Password: "token-1"}, - want: "saved password", - }, - { - name: "full_sign_in", - record: recentRemoteRecord{Username: "alice", Password: "token-1"}, - want: "saved username and password", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want { - t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want) - } - }) - } -} - -func TestUIRemotePreferencesCurrentSummaryExplainsWhatWillBeRemembered(t *testing.T) { +func TestUIRemotePreferencesCurrentSummaryExplainsVaultBackedCredentialFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.remoteBaseURL.SetText("https://dav.example.com") u.remotePath.SetText("vaults/home.kdbx") - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO will remember only the WebDAV location for this connection." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only guidance", got) + if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." { + t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only vault guidance", got) } - u.rememberRemoteAuth.Value = true - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: sign-in retention is enabled, but no username or password is entered yet." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want empty-sign-in guidance", got) - } - - u.remoteUsername.SetText("alice") - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will save the entered sign-in for this connection on this device." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want pending-save guidance", got) - } - - u.recentRemotes = []recentRemoteRecord{{ - BaseURL: "https://dav.example.com", - Path: "vaults/home.kdbx", - Username: "alice", - Password: "secret-1", - }} - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will update the saved sign-in for this connection on this device." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want saved-sign-in guidance", got) + u.remoteUsername.SetText("debbieocean") + if got := u.remotePreferencesCurrentSummary(); got != "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." { + t.Fatalf("remotePreferencesCurrentSummary() = %q, want vault-storage guidance", got) } } -func TestUIRemotePreferencesHelpExplainsSavedFieldsAndRetention(t *testing.T) { +func TestUIRemotePreferencesHelpExplainsLocationOnlyRetention(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) - if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." { + if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." { t.Fatalf("remotePreferencesAlwaysSavedSummary() = %q, want saved-fields guidance", got) } - if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." { - t.Fatalf("remotePreferencesRetentionSummary() = %q, want retention guidance", got) + if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." { + t.Fatalf("remotePreferencesRetentionSummary() = %q, want vault retention guidance", got) + } +} + +func TestUIRemotePreferencesPersistenceSummaryExplainsVaultBindingFlow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remotePreferencesPersistenceSummary(); got != "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." { + t.Fatalf("remotePreferencesPersistenceSummary() = %q, want local-first vault-binding guidance", got) } } @@ -4760,13 +6923,171 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" - if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Create Local Cache" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Create Local Cache") } u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" - if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Retry Local Cache Setup" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Local Cache Setup") + } + + u.loadingMessage = "Opening..." + u.state.ErrorMessage = "" + if got := u.remoteOpenButtonLabel(); got != "Creating Local Cache..." { + t.Fatalf("remoteOpenButtonLabel() while busy = %q, want %q", got, "Creating Local Cache...") + } +} + +func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + + if got := u.remoteLifecycleMessage(); got != "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." { + t.Fatalf("remoteLifecycleMessage() = %q, want local-cache guidance", got) + } +} + +func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + + if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." { + t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got) + } +} + +func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." { + t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got) + } +} + +func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { + t.Fatalf("saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) + } +} + +func TestUISaveCurrentRemoteBindingButtonLabelUsesSyncLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { + t.Fatalf("saveCurrentRemoteBindingButtonLabel() = %q, want sync-target language", got) + } +} + +func TestUIRemoteOpenButtonLabelUsesLocalCacheLanguageForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + + if got := u.remoteOpenButtonLabel(); got != "Open Cached Vault" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Cached Vault") + } + + u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" + if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault") + } +} + +func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + LastGroup: []string{"Root", "Internet"}, + }} + + if got := u.selectedRemoteCardHeading(); got != "CACHED VAULT" { + t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "CACHED VAULT") + } + if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx" { + t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx") + } + gotDetails := u.selectedRemoteCardDetailLines() + wantDetails := []string{ + "/vaults/cache", + "Sync target: home.kdbx · dav.example.invalid", + "Last group: Root / Internet", + } + if !slices.Equal(gotDetails, wantDetails) { + t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) + } +} + +func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LastGroup: []string{"Root", "Internet"}, + }} + + if got := u.selectedRemoteCardHeading(); got != "SELECTED CONNECTION" { + t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "SELECTED CONNECTION") + } + if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx · dav.example.invalid" { + t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx · dav.example.invalid") + } + gotDetails := u.selectedRemoteCardDetailLines() + wantDetails := []string{ + "Path: vaults/home.kdbx", + "Server: https://dav.example.invalid", + "Last group: Root / Internet", + } + if !slices.Equal(gotDetails, wantDetails) { + t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) } } @@ -4796,9 +7117,14 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) })) defer server.Close() - first := newUIWithSession("desktop", &session.Manager{}) - first.recentRemotesPath = statePath - first.recentRemotes = nil + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: statePath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + + first := newUIWithState("desktop", &session.Manager{}, paths) first.lifecycleMode = "remote" first.masterPassword.SetText("correct horse battery staple") first.remoteBaseURL.SetText(server.URL) @@ -4811,10 +7137,7 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) first.syncedPath = []string{"Root", "Internet"} first.noteCurrentRemotePath() - reopened := newUIWithSession("desktop", &session.Manager{}) - reopened.recentRemotesPath = statePath - reopened.recentRemotes = nil - reopened.loadRecentRemotes() + reopened := newUIWithState("desktop", &session.Manager{}, paths) reopened.lifecycleMode = "remote" reopened.masterPassword.SetText("correct horse battery staple") reopened.remoteBaseURL.SetText(server.URL) @@ -4828,6 +7151,161 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) } } +func TestUIOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cachePath := filepath.Join(dir, "remote-cache.kdbx") + paths := statePaths{ + DefaultSaveAsPath: cachePath, + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + key := vault.MasterKey{Password: "correct horse battery staple"} + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + u := newUIWithState("desktop", &session.Manager{}, paths) + u.lifecycleMode = "remote" + u.masterPassword.SetText(key.Password) + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vault.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := u.vaultPath.Text(); got != cachePath { + t.Fatalf("vaultPath = %q, want %q", got, cachePath) + } + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("Stat(cachePath) error = %v", err) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := u.recentRemotes[0] + if record.LocalVaultPath != cachePath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) + } + if record.RemoteProfileID == "" || record.CredentialEntryID == "" { + t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) + } + + var reopened session.Manager + if err := reopened.Open(cachePath, key); err != nil { + t.Fatalf("Open(cachePath) error = %v", err) + } + model, err := reopened.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + if got := len(model.RemoteProfiles); got != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", got) + } + if got := model.RemoteProfiles[0].BaseURL; got != server.URL { + t.Fatalf("RemoteProfiles[0].BaseURL = %q, want %q", got, server.URL) + } + entry, err := model.EntryByID(record.CredentialEntryID) + if err != nil { + t.Fatalf("EntryByID(%q) error = %v", record.CredentialEntryID, err) + } + if entry.Username != "linuscaldwell" || entry.Password != "bellagio-pass-1" { + t.Fatalf("credential entry = %#v, want linuscaldwell/bellagio-pass-1", entry) + } +} + +func TestUIStartOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cachePath := filepath.Join(dir, "remote-cache.kdbx") + paths := statePaths{ + DefaultSaveAsPath: cachePath, + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + key := vault.MasterKey{Password: "correct horse battery staple"} + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + u := newUIWithState("desktop", &session.Manager{}, paths) + u.lifecycleMode = "remote" + u.masterPassword.SetText(key.Password) + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vault.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + u.startOpenRemoteAction() + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.vaultPath.Text(); got != cachePath { + t.Fatalf("vaultPath = %q, want %q", got, cachePath) + } + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("Stat(cachePath) error = %v", err) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := u.recentRemotes[0] + if record.LocalVaultPath != cachePath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) + } + if record.RemoteProfileID == "" || record.CredentialEntryID == "" { + t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) + } +} + func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { t.Parallel() @@ -4852,6 +7330,191 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) } + if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") { + t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx")) + } + if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { + t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) + } +} + +func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + }) + + got := u.importedVaultDestination("shared-home.kdbx") + want := filepath.Join(filepath.Dir(u.defaultSaveAsPath), "shared-home.kdbx") + if got != want { + t.Fatalf("importedVaultDestination() = %q, want %q", got, want) + } +} + +func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := 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"), + } + key := vault.MasterKey{Password: "correct horse battery staple"} + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + u := newUIWithState("phone", &session.Manager{}, paths) + u.lifecycleMode = "remote" + + if err := u.importSharedVaultBytesAction("shared-home.kdbx", encoded.Bytes()); err != nil { + t.Fatalf("importSharedVaultBytesAction() error = %v", err) + } + + wantPath := filepath.Join(dir, "shared-home.kdbx") + if got := u.vaultPath.Text(); got != wantPath { + t.Fatalf("vaultPath = %q, want %q", got, wantPath) + } + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode = %q, want local", got) + } + if !u.hasSelectedLifecycleTarget() { + t.Fatal("hasSelectedLifecycleTarget() = false, want true after import") + } + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("Stat(imported vault) error = %v", err) + } + + reopened := newUIWithState("phone", &session.Manager{}, paths) + reopened.vaultPath.SetText(wantPath) + reopened.masterPassword.SetText(key.Password) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("openVaultAction(imported) error = %v", err) + } + reopened.state.NavigateToPath([]string{"Root", "Internet"}) + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() = %v, want [Vault Console]", got) + } +} + +func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := 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"), + PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"), + } + key := vault.MasterKey{Password: "correct horse battery staple"} + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err) + } + + u := newUIWithState("phone", &session.Manager{}, paths) + + wantPath := filepath.Join(dir, "crew-shared.kdbx") + if got := u.vaultPath.Text(); got != wantPath { + t.Fatalf("vaultPath = %q, want %q", got, wantPath) + } + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode = %q, want local", got) + } + if !u.hasSelectedLifecycleTarget() { + t.Fatal("hasSelectedLifecycleTarget() = false, want true after pending import") + } + if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err) + } + if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", err) + } + + reopened := newUIWithState("phone", &session.Manager{}, paths) + reopened.vaultPath.SetText(wantPath) + reopened.masterPassword.SetText(key.Password) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("openVaultAction(imported) error = %v", err) + } + reopened.state.NavigateToPath([]string{"Crew", "Internet"}) + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) + } +} + +func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &session.Manager{}) + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if got := u.currentShareableVaultPath(); got != "/vaults/crew-shared.kdbx" { + t.Fatalf("currentShareableVaultPath() = %q, want %q", got, "/vaults/crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionSavesAndSharesCurrentVault(t *testing.T) { + t.Parallel() + + session := &saveCaptureSession{} + sharer := &captureVaultSharer{} + u := newUIWithSession("phone", session) + u.vaultSharer = sharer + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if err := u.shareCurrentVaultAction(); err != nil { + t.Fatalf("shareCurrentVaultAction() error = %v", err) + } + if session.saveCount != 1 { + t.Fatalf("shareCurrentVaultAction() saveCount = %d, want 1", session.saveCount) + } + if got := sharer.path; got != "/vaults/crew-shared.kdbx" { + t.Fatalf("ShareVault path = %q, want %q", got, "/vaults/crew-shared.kdbx") + } + if got := sharer.title; got != "crew-shared.kdbx" { + t.Fatalf("ShareVault title = %q, want %q", got, "crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionRequiresVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &saveCaptureSession{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultSharer = &captureVaultSharer{} + + err := u.shareCurrentVaultAction() + if err == nil || err.Error() != errVaultPathRequired { + t.Fatalf("shareCurrentVaultAction() error = %v, want %q", err, errVaultPathRequired) + } } func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { @@ -4966,6 +7629,17 @@ func TestSupportsDesktopFilePicker(t *testing.T) { } } +func TestSupportsSharedVaultImport(t *testing.T) { + t.Parallel() + + if got := supportsSharedVaultImport("android"); !got { + t.Fatal("supportsSharedVaultImport(android) = false, want true") + } + if got := supportsSharedVaultImport("linux"); got { + t.Fatal("supportsSharedVaultImport(linux) = true, want false") + } +} + func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) { t.Parallel() @@ -5030,7 +7704,7 @@ func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) { var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, }, }, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) @@ -5057,8 +7731,8 @@ func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}}, - {ID: "bellagio", Title: "Bellagio", Password: "token-2", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, + {ID: "bellagio", Title: "Bellagio", Password: "bellagio-pass-2", Path: []string{"Root", "Internet"}}, }, }) u.showEntriesSection() @@ -5143,7 +7817,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5157,7 +7831,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test want string }{ {name: "username", target: clipboard.TargetUsername, label: "copy username", want: "dannyocean"}, - {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"}, + {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "bellagio-pass-1"}, {name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"}, } @@ -5198,7 +7872,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5215,7 +7889,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() { t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error()) } - if strings.Contains(u.state.ErrorMessage, "token-1") { + if strings.Contains(u.state.ErrorMessage, "bellagio-pass-1") { t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage) } if u.state.StatusMessage != "" { @@ -5232,7 +7906,7 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5250,8 +7924,8 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { } generated := u.entryPassword.Text() - if generated == "token-1" { - t.Fatal("entryPassword.Text() = token-1, want a newly generated password") + if generated == "bellagio-pass-1" { + t.Fatal("entryPassword.Text() = bellagio-pass-1, want a newly generated password") } if len(generated) < passwords.DefaultProfiles()["strong"].Length { t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length) @@ -5279,7 +7953,7 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, @@ -5289,13 +7963,13 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" - if got := u.detailPasswordValue(); got != "••••••••" { - t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, "••••••••") + if got, want := u.detailPasswordValue(), strings.Repeat("•", len("bellagio-pass-1")); got != want { + t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, want) } u.showPassword = true - if got := u.detailPasswordValue(); got != "token-1" { - t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "token-1") + if got := u.detailPasswordValue(); got != "bellagio-pass-1" { + t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "bellagio-pass-1") } if err := u.lockAction(); err != nil { @@ -5351,7 +8025,7 @@ func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -5600,18 +8274,18 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "lights", Title: "Home Assistant", Path: []string{"Crew", "codex"}}, + {ID: "lights", Title: "Security Office", Path: []string{"Crew", "bashertarr"}}, }, }) - u.state.NavigateToPath([]string{"Crew", "codex"}) + u.state.NavigateToPath([]string{"Crew", "bashertarr"}) u.filter() u.state.SelectedEntryID = "lights" if err := u.useCurrentGroupForPolicyAction(); err != nil { t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) } - if got := u.apiPolicyPath.Text(); got != "codex" { - t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "codex") + if got := u.apiPolicyPath.Text(); got != "bashertarr" { + t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") } if !u.apiPolicyGroupScopeW.Value { t.Fatal("apiPolicyGroupScopeW.Value = false, want true") diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl similarity index 89% rename from packaging/archlinux/keepassgo-git/PKGBUILD rename to packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 03e6578..358b845 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -1,5 +1,5 @@ pkgname=keepassgo-git -pkgver=r165.1c72a50 +pkgver=@PKGVER@ pkgrel=1 pkgdesc='KeePass-compatible password manager written in Go' arch=('x86_64' 'aarch64') @@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git') sha256sums=('SKIP') _repo_dir() { - if [[ -d "${srcdir}/keepassgo/.git" ]]; then - printf '%s\n' "${srcdir}/keepassgo" - return - fi - - cd "${startdir}/../../.." || exit 1 - pwd + printf '%s\n' "@REPO_DIR@" } pkgver() { diff --git a/ui_forms.go b/ui_forms.go index a31f9f1..20f1264 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -21,7 +21,6 @@ import ( func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() - showRemoteChooser := u.showRemoteConnectionChooser() selectedLocalPath := strings.TrimSpace(u.vaultPath.Text()) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -31,154 +30,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - message := "Choose a recent vault or enter a .kdbx path, then unlock it." - if u.lifecycleMode == "remote" { - message = "Connect to a remote vault, then unlock it with the KeePass master key." - } + message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens." lbl := material.Label(u.theme, unit.Sp(14), message) lbl.Color = accentColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local") - } - return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote") - } - return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote") - }), - ) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleMode == "remote" { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "LOCATION") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if showRemoteChooser || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if showRemoteChooser && !busy { - return u.recentRemoteList(gtx) - } - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device") - box.Color = accentColor - return box.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help") - }) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - ) - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { @@ -200,6 +58,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return u.recentVaultList(gtx) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault") + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { return layout.Dimensions{} @@ -296,29 +166,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleMode == "remote" { - label := u.remoteOpenButtonLabel() - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveTonedButton(gtx, u.theme, label) - } - return tonedButton(gtx, u.theme, &u.openRemote, label) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return u.selectedRemoteConnectionCard(gtx) - }), - ) - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Open Vault" @@ -361,58 +208,81 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { - record := u.currentRemoteRecord() - lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) + heading := u.selectedRemoteCardHeading() + primary := u.selectedRemoteCardPrimaryText() + details := u.selectedRemoteCardDetailLines() return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION") + lbl := material.Label(u.theme, unit.Sp(12), heading) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record)) + lbl := material.Label(u.theme, unit.Sp(14), primary) lbl.Color = accentColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path)) + } + for _, line := range details { + line := line + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout)) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), line) lbl.Color = mutedColor return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{ - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), - })) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(lastGroup) == 0 { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), + })) + } + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection") }), ) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) }) }) } +func (u *ui) selectedRemoteCardHeading() string { + if u.selectedRemoteUsesLocalCache() { + return "CACHED VAULT" + } + return "SELECTED CONNECTION" +} + +func (u *ui) selectedRemoteCardPrimaryText() string { + record := u.currentRemoteRecord() + if u.selectedRemoteUsesLocalCache() { + path := strings.TrimSpace(u.vaultPath.Text()) + if label := friendlyRecentVaultLabel(path); label != "" { + return label + } + } + return friendlyRecentRemoteLabel(record) +} + +func (u *ui) selectedRemoteCardDetailLines() []string { + record := u.currentRemoteRecord() + lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) + lines := make([]string, 0, 3) + if u.selectedRemoteUsesLocalCache() { + if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" { + lines = append(lines, dir) + } + lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record)) + } else { + lines = append(lines, "Path: "+strings.TrimSpace(record.Path)) + lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL)) + } + if len(lastGroup) > 0 { + lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) + } + return lines +} + func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions { lastGroup := u.recentVaultGroup(path) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { @@ -446,6 +316,11 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime lbl.Color = mutedColor return lbl.Layout(gtx) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault") @@ -455,6 +330,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime }) } +func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string { + if record, ok := u.boundRecentRemoteForLocalVault(path); ok { + summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record) + if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave { + return summary + " · Syncs automatically on open and save." + } + return summary + " · Sync manually when you choose Use Remote Sync." + } + return "Open this vault to set up a WebDAV sync target for it." +} + func (u *ui) lifecycleSecuritySettingsSummary() string { return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." } @@ -617,11 +503,6 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { lbl.Color = mutedColor return lbl.Layout(gtx) }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(record.LastGroup) == 0 { return layout.Dimensions{} @@ -750,21 +631,6 @@ func normalizedRemoteHost(baseURL string) string { return strings.TrimSuffix(host, "/") } -func recentRemoteStoredAuthSummary(record recentRemoteRecord) string { - username := strings.TrimSpace(record.Username) - hasPassword := record.Password != "" - switch { - case username != "" && hasPassword: - return "saved username and password" - case username != "": - return "saved username" - case hasPassword: - return "saved password" - default: - return "location only" - } -} - func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() if len(items) == 0 { diff --git a/vault/kdbx.go b/vault/kdbx.go index ff63bda..b4e5850 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -3,6 +3,7 @@ package vault import ( "crypto/rand" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -23,9 +24,10 @@ type KDBXConfig struct { var ErrInvalidMasterKey = errors.New("invalid master key") const ( - templatesRoot = "Templates" - recycleBinRoot = "Recycle Bin" - keepassGOIDField = "KeePassGO-ID" + templatesRoot = "Templates" + recycleBinRoot = "Recycle Bin" + keepassGOIDField = "KeePassGO-ID" + remoteProfilesKey = "keepassgo.remoteProfiles" ) func LoadKDBX(r io.Reader, password string) (Model, error) { @@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) db.Credentials = credentials db.Content.Meta = gokeepasslib.NewMetaData() + db.Content.Meta.CustomData = customDataForModel(model) db.Content.Root = &gokeepasslib.RootData{} if config != nil && config.Header != nil { db.Header = cloneHeader(config.Header) @@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) for _, group := range db.Content.Root.Groups { appendGroupEntries(&model, db, group, nil) } + model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta) return model, &KDBXConfig{ Header: cloneHeader(db.Header), @@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) }, nil } +func customDataForModel(model Model) []gokeepasslib.CustomData { + if len(model.RemoteProfiles) == 0 { + return nil + } + + content, err := json.Marshal(model.RemoteProfiles) + if err != nil { + return nil + } + + return []gokeepasslib.CustomData{{ + Key: remoteProfilesKey, + Value: string(content), + }} +} + +func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile { + if meta == nil { + return nil + } + for _, item := range meta.CustomData { + if item.Key != remoteProfilesKey { + continue + } + var profiles []RemoteProfile + if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil { + return nil + } + return profiles + } + return nil +} + func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) { switch { case key.Password != "" && len(key.KeyFileData) > 0: diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index f856c72..371edbf 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) { mustGroup("Root", mustGroup("Internet", mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"), - mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"), + mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"), ), - mustGroup("Home Assistant", - mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"), + mustGroup("Security Office", + mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"), ), ), }, @@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) { } groups := model.ChildGroups([]string{"Root"}) - if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups) + if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups) } } func TestLoadKDBXPreservesEntryDetails(t *testing.T) { t.Parallel() - entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2") + entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2") entry.Tags = "automation; home" entry.Values = append(entry.Values, mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), @@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Home Assistant", entry)), + mustGroup("Root", mustGroup("Security Office", entry)), }, }, }, @@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) { t.Fatalf("LoadKDBX failed: %v", err) } - got := model.EntriesInPath([]string{"Root", "Home Assistant"}) + got := model.EntriesInPath([]string{"Root", "Security Office"}) if len(got) != 1 { t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) } - if got[0].Password != "token-2" { - t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2") + if got[0].Password != "bellagio-pass-2" { + t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2") } if got[0].Notes != "Long-lived token used by Codex for home automation tasks." { @@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Notes: "Personal git server token entry used for automation and CLI auth.", Tags: []string{"git", "infra"}, @@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { { ID: "entry-2", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", Notes: "Long-lived token used by Codex for home automation tasks.", Tags: []string{"automation", "home"}, - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation") } - homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"}) if len(homeAssistant) != 1 { - t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant)) + t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant)) } - if homeAssistant[0].Password != "token-2" { - t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2") + if homeAssistant[0].Password != "bellagio-pass-2" { + t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2") } } @@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) { } } +func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) { + t.Parallel() + + model := Model{ + RemoteProfiles: []RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + if len(loaded.RemoteProfiles) != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles)) + } + + got := loaded.RemoteProfiles[0] + if got.ID != "family-webdav" || got.Name != "Family Vault" { + t.Fatalf("loaded remote profile = %#v, want family-webdav Family Vault", got) + } + if got.Backend != RemoteBackendWebDAV { + t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV) + } + if got.BaseURL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL) + } + if got.Path != "files/family/keepass.kdbx" { + t.Fatalf("remote path = %q, want files/family/keepass.kdbx", got.Path) + } +} + func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) { t.Parallel() @@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) { { ID: "entry-1", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) { t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console") } - if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { - t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path) + if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" { + t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path) } if len(loaded.Entries) != 0 { @@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))), }, }, }, @@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) { } got := model.Search("vault") - if len(got) != 1 || got[0].Entry.Password != "token-1" { + if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" { t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got) } } @@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))), + mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))), }, }, }, @@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) { t.Fatalf("LoadKDBXWithKey() error = %v", err) } - got := model.EntriesInPath([]string{"Root", "Home Assistant"}) - if len(got) != 1 || got[0].Password != "token-2" { - t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got) + got := model.EntriesInPath([]string{"Root", "Security Office"}) + if len(got) != 1 || got[0].Password != "bellagio-pass-2" { + t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got) } } @@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))), }, }, }, @@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) { } got := loaded.Search("vault") - if len(got) != 1 || got[0].Entry.Password != "token-1" { + if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" { t.Fatalf("round-trip with key file = %#v, want vault entry with password", got) } } @@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) { { ID: "surveillance-console", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) { t.Fatalf("LoadKDBXWithKey() error = %v", err) } - got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) - if len(got) != 1 || got[0].Password != "token-2" { - t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got) + got := loaded.EntriesInPath([]string{"Root", "Security Office"}) + if len(got) != 1 || got[0].Password != "bellagio-pass-2" { + t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got) } } @@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Attachments: map[string][]byte{ @@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Notes: "Current credential", Path: []string{"Root", "Internet"}, @@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { ID: "entry-1-history-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Notes: "Original credential", Path: []string{"Root", "Internet"}, diff --git a/vault/model.go b/vault/model.go index e2d12c9..31e171f 100644 --- a/vault/model.go +++ b/vault/model.go @@ -8,6 +8,21 @@ import ( var ErrEntryNotFound = errors.New("entry not found") var ErrGroupNotEmpty = errors.New("group is not empty") +var ErrRemoteProfileNotFound = errors.New("remote profile not found") + +type RemoteBackend string + +const ( + RemoteBackendWebDAV RemoteBackend = "webdav" +) + +type RemoteProfile struct { + ID string + Name string + Backend RemoteBackend + BaseURL string + Path string +} type Entry struct { ID string @@ -29,10 +44,11 @@ type SearchResult struct { } type Model struct { - Entries []Entry - Templates []Entry - RecycleBin []Entry - Groups [][]string + Entries []Entry + Templates []Entry + RecycleBin []Entry + Groups [][]string + RemoteProfiles []RemoteProfile } func (m Model) ChildGroups(path []string) []string { @@ -168,6 +184,57 @@ 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 { + return cloneEntry(entry), nil + } + } + return Entry{}, ErrEntryNotFound +} + +func (m *Model) UpsertRemoteProfile(profile RemoteProfile) { + for i := range m.RemoteProfiles { + if m.RemoteProfiles[i].ID != profile.ID { + continue + } + m.RemoteProfiles[i] = profile + return + } + 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 { + return profile, nil + } + } + return RemoteProfile{}, ErrRemoteProfileNotFound +} + func (m *Model) UpsertTemplate(entry Entry) { for i := range m.Templates { if m.Templates[i].ID != entry.ID {