diff --git a/apps/desktop/src/components/config/ConfigPassphraseDialog.vue b/apps/desktop/src/components/config/ConfigPassphraseDialog.vue
index 7b4c1541e7..eeb91607de 100644
--- a/apps/desktop/src/components/config/ConfigPassphraseDialog.vue
+++ b/apps/desktop/src/components/config/ConfigPassphraseDialog.vue
@@ -11,11 +11,13 @@ const props = defineProps<{
open: boolean;
mode: "export" | "import";
externalError?: string;
+ busy?: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
confirm: [passphrase: string];
+ requestUnencrypted: [];
}>();
const { t } = useI18n();
@@ -41,6 +43,7 @@ watch(
);
function confirm() {
+ if (props.busy) return;
if (!passphrase.value) {
error.value = t("configExport.passphraseRequired");
return;
@@ -76,19 +79,22 @@ const displayError = computed(() => error.value || props.externalError || "");
-
+
-
+
{{ displayError }}
-
diff --git a/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts b/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts
new file mode 100644
index 0000000000..f322a0d1ea
--- /dev/null
+++ b/apps/desktop/src/components/config/__tests__/ConfigPassphraseDialog.spec.ts
@@ -0,0 +1,122 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type App } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import i18n from "@/i18n";
+
+vi.mock("@/components/ui/dialog", async () => {
+ const { defineComponent, h } = await import("vue");
+ const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", slots.default?.());
+ },
+ });
+ return { Dialog: passthrough, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough };
+});
+
+vi.mock("@/components/ui/button", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Button: defineComponent({
+ props: { disabled: Boolean },
+ emits: ["click"],
+ setup(props, { slots, emit }) {
+ return () => h("button", { disabled: props.disabled, onClick: () => emit("click") }, slots.default?.());
+ },
+ }),
+ };
+});
+
+vi.mock("@/components/ui/label", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Label: defineComponent({
+ setup(_props, { slots }) {
+ return () => h("label", slots.default?.());
+ },
+ }),
+ };
+});
+
+vi.mock("@/components/ui/PasswordInput.vue", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ default: defineComponent({
+ props: { modelValue: String, disabled: Boolean },
+ emits: ["update:modelValue"],
+ setup(props, { emit }) {
+ return () => h("input", { value: props.modelValue, disabled: props.disabled, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) });
+ },
+ }),
+ };
+});
+
+vi.mock("@lucide/vue", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Lock: defineComponent({
+ setup() {
+ return () => h("span");
+ },
+ }),
+ };
+});
+
+import ConfigPassphraseDialog from "@/components/config/ConfigPassphraseDialog.vue";
+
+const mountedApps: App[] = [];
+
+afterEach(() => {
+ for (const app of mountedApps.splice(0)) app.unmount();
+ while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
+});
+
+async function mountDialog(props: Record) {
+ i18n.global.locale.value = "en";
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(
+ defineComponent({
+ setup() {
+ return () => h(ConfigPassphraseDialog, props);
+ },
+ }),
+ );
+ mountedApps.push(app);
+ app.use(i18n);
+ app.mount(container);
+ await nextTick();
+}
+
+describe("ConfigPassphraseDialog", () => {
+ it("offers unencrypted export as an explicit secondary action", async () => {
+ const requestUnencrypted = vi.fn();
+ const confirm = vi.fn();
+ await mountDialog({ open: true, mode: "export", onRequestUnencrypted: requestUnencrypted, onConfirm: confirm });
+
+ const buttons = [...document.body.querySelectorAll("button")];
+ const unencrypted = buttons.find((button) => button.textContent?.includes("Export without encryption"));
+ expect(unencrypted).toBeTruthy();
+ unencrypted?.click();
+ expect(requestUnencrypted).toHaveBeenCalledOnce();
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("does not allow an empty encrypted passphrase", async () => {
+ const confirm = vi.fn();
+ await mountDialog({ open: true, mode: "export", onConfirm: confirm });
+
+ const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted"));
+ encrypted?.click();
+ await nextTick();
+ expect(confirm).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain("Passphrase is required");
+ });
+
+ it("keeps import mode free of the unencrypted export action", async () => {
+ await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() });
+
+ expect(document.body.textContent).not.toContain("Export without encryption");
+ expect(document.body.textContent).toContain("Decrypt");
+ });
+});
diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue
index 0ce01d03e6..8c33c2bfae 100644
--- a/apps/desktop/src/components/layout/AppDialogs.vue
+++ b/apps/desktop/src/components/layout/AppDialogs.vue
@@ -290,9 +290,23 @@ watch(
:open="dialogs.showConfigPassphraseDialog.value"
:mode="dialogs.configPassphraseMode.value"
:external-error="dialogs.configPassphraseError.value"
+ :busy="dialogs.configExportBusy.value"
@update:open="dialogs.onConfigPassphraseOpenChange"
+ @request-unencrypted="dialogs.onRequestUnencryptedExport"
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
/>
+