feat(config): support optional plaintext connection exports

This commit is contained in:
jearhe authored and GitHub committed 2026-08-22 19:19:10 +08:00
1 parent 904efff6b3
commit 3118e0d617
19 files changed
+546 -49

No files matched your search

@@ -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 || "");
<div class="grid gap-2">
<Label>{{ t("configExport.passphrase") }}</Label>
<PasswordInput v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" @keydown.enter="mode === 'import' ? confirm() : undefined" />
<PasswordInput v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="mode === 'import' ? confirm() : undefined" />
</div>
<div v-if="mode === 'export'" class="grid gap-2">
<Label>{{ t("configExport.passphraseConfirm") }}</Label>
<PasswordInput v-model="passphraseConfirm" :placeholder="t('configExport.passphraseConfirmPlaceholder')" :toggle-tab-index="-1" @keydown.enter="confirm" />
<PasswordInput v-model="passphraseConfirm" :placeholder="t('configExport.passphraseConfirmPlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="confirm" />
</div>
<p v-if="displayError" class="text-sm text-destructive">{{ displayError }}</p>
</div>
<DialogFooter>
<Button @click="confirm">
<Button v-if="mode === 'export'" type="button" variant="outline" :disabled="busy" @click="emit('requestUnencrypted')">
{{ t("configExport.exportUnencrypted") }}
</Button>
<Button type="button" :disabled="busy" @click="confirm">
{{ mode === "export" ? t("configExport.exportEncrypted") : t("configExport.decryptImport") }}
</Button>
</DialogFooter>
@@ -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<string, unknown>) {
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");
});
});
@@ -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)"
/>
<Dialog v-if="dialogs.showConfigUnencryptedExportConfirm.value" :open="dialogs.showConfigUnencryptedExportConfirm.value" @update:open="dialogs.onConfigUnencryptedExportOpenChange">
<DialogContent class="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{{ t("configExport.unencryptedWarningTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ t("configExport.unencryptedWarningDescription") }}</p>
<DialogFooter>
<Button type="button" variant="outline" :disabled="dialogs.configExportBusy.value" @click="dialogs.onConfigUnencryptedExportCancel()">{{ t("dangerDialog.cancel") }}</Button>
<Button type="button" variant="destructive" :disabled="dialogs.configExportBusy.value" @click="dialogs.onConfigUnencryptedExportConfirm()">{{ t("configExport.confirmUnencryptedExport") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ConnectionConfig } from "@/types/database";
@@ -35,6 +35,11 @@ import { useDialogSources } from "@/composables/useDialogSources";
const mountedApps: App[] = [];
beforeEach(() => {
mocks.store.connections = [];
mocks.store.exportConnectionsToFile.mockReset().mockResolvedValue("saved");
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
vi.clearAllMocks();
@@ -84,4 +89,93 @@ describe("useDialogSources", () => {
await applyPromise;
await nextTick();
});
it("requires confirmation before an unencrypted export and preserves the flow when cancelled", async () => {
mocks.store.connections = [conn("selected")];
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
dialogs.onRequestUnencryptedExport();
expect(dialogs.showConfigPassphraseDialog.value).toBe(false);
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(true);
expect(mocks.store.exportConnectionsToFile).not.toHaveBeenCalled();
dialogs.onConfigUnencryptedExportOpenChange(false);
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false);
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(mocks.store.exportConnectionsToFile).not.toHaveBeenCalled();
dialogs.onRequestUnencryptedExport();
await dialogs.onConfigUnencryptedExportConfirm();
expect(mocks.store.exportConnectionsToFile).toHaveBeenCalledWith({ mode: "plaintext" }, ["selected"]);
});
it("keeps encrypted export state when the native save dialog is cancelled", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved");
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
await dialogs.onExportConfirm("passphrase");
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(mocks.toast).not.toHaveBeenCalled();
await dialogs.onExportConfirm("passphrase");
expect(mocks.store.exportConnectionsToFile.mock.calls).toEqual([
[{ mode: "encrypted", passphrase: "passphrase" }, ["selected"]],
[{ mode: "encrypted", passphrase: "passphrase" }, ["selected"]],
]);
expect(dialogs.showConfigPassphraseDialog.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledOnce();
});
it("keeps plaintext export state when the native save dialog is cancelled", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved");
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
dialogs.onRequestUnencryptedExport();
await dialogs.onConfigUnencryptedExportConfirm();
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(true);
expect(mocks.toast).not.toHaveBeenCalled();
await dialogs.onConfigUnencryptedExportConfirm();
expect(mocks.store.exportConnectionsToFile.mock.calls).toEqual([
[{ mode: "plaintext" }, ["selected"]],
[{ mode: "plaintext" }, ["selected"]],
]);
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledOnce();
});
it("prevents duplicate unencrypted exports while the file is being written", async () => {
let resolveExport!: () => void;
const exportPromise = new Promise<void>((resolve) => {
resolveExport = resolve;
});
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockReturnValue(exportPromise);
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
dialogs.onRequestUnencryptedExport();
const first = dialogs.onConfigUnencryptedExportConfirm();
const second = dialogs.onConfigUnencryptedExportConfirm();
expect(dialogs.configExportBusy.value).toBe(true);
expect(mocks.store.exportConnectionsToFile).toHaveBeenCalledTimes(1);
resolveExport();
await first;
await second;
expect(dialogs.configExportBusy.value).toBe(false);
});
});
@@ -22,6 +22,8 @@ const pendingImportLayout = ref<SidebarLayout | null>(null);
const showConfigPassphraseDialog = ref(false);
const configPassphraseMode = ref<"export" | "import">("export");
const configPassphraseError = ref("");
const showConfigUnencryptedExportConfirm = ref(false);
const configExportBusy = ref(false);
const pendingImportContent = ref("");
const showConfigConnectionSelectDialog = ref(false);
const configConnectionSelectMode = ref<"export" | "import">("export");
@@ -330,16 +332,57 @@ export function useDialogSources() {
}
async function onExportConfirm(passphrase: string) {
if (configExportBusy.value) return;
configExportBusy.value = true;
try {
await connectionStore.exportConnectionsToFile(passphrase, pendingExportConnectionIds.value);
const result = await connectionStore.exportConnectionsToFile({ mode: "encrypted", passphrase }, pendingExportConnectionIds.value);
if (result === "cancelled") return;
showConfigPassphraseDialog.value = false;
clearPendingExportState();
toast(t("configExport.exportSuccess"), 2000);
} catch (e: any) {
configPassphraseError.value = e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message || String(e);
configPassphraseError.value = e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message === "passphrase_required" ? t("configExport.passphraseRequired") : t("configExport.exportFailed");
} finally {
configExportBusy.value = false;
}
}
function onRequestUnencryptedExport() {
if (configExportBusy.value) return;
showConfigPassphraseDialog.value = false;
showConfigUnencryptedExportConfirm.value = true;
}
async function onConfigUnencryptedExportConfirm() {
if (configExportBusy.value) return;
configExportBusy.value = true;
try {
const result = await connectionStore.exportConnectionsToFile({ mode: "plaintext" }, pendingExportConnectionIds.value);
if (result === "cancelled") return;
showConfigUnencryptedExportConfirm.value = false;
clearPendingExportState();
toast(t("configExport.exportSuccess"), 2000);
} catch {
showConfigUnencryptedExportConfirm.value = false;
configPassphraseError.value = t("configExport.exportFailed");
showConfigPassphraseDialog.value = true;
} finally {
configExportBusy.value = false;
}
}
function onConfigUnencryptedExportCancel() {
if (configExportBusy.value) return;
showConfigUnencryptedExportConfirm.value = false;
showConfigPassphraseDialog.value = true;
}
function onConfigUnencryptedExportOpenChange(open: boolean) {
if (configExportBusy.value) return;
showConfigUnencryptedExportConfirm.value = open;
if (!open) showConfigPassphraseDialog.value = true;
}
async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" = "dbx") {
try {
const result = await connectionStore.readImportFile(source);
@@ -414,8 +457,9 @@ export function useDialogSources() {
function onConfigPassphraseOpenChange(open: boolean) {
showConfigPassphraseDialog.value = open;
if (open) return;
if (configPassphraseMode.value === "export") clearPendingExportState();
else if (!pendingImportPreview.value) clearPendingImportState();
if (configPassphraseMode.value === "export") {
if (!showConfigUnencryptedExportConfirm.value) clearPendingExportState();
} else if (!pendingImportPreview.value) clearPendingImportState();
}
return {
@@ -435,6 +479,8 @@ export function useDialogSources() {
showConfigPassphraseDialog,
configPassphraseMode,
configPassphraseError,
showConfigUnencryptedExportConfirm,
configExportBusy,
pendingImportContent,
showConfigConnectionSelectDialog,
applyingImportSelection,
@@ -489,6 +535,10 @@ export function useDialogSources() {
databaseExportAllDatabases,
onExportClick,
onExportConfirm,
onRequestUnencryptedExport,
onConfigUnencryptedExportConfirm,
onConfigUnencryptedExportCancel,
onConfigUnencryptedExportOpenChange,
onImportClick,
onImportConfirm,
onConfigConnectionSelectConfirm,
+6 -1
View File
@@ -2342,10 +2342,15 @@ export default {
passphraseRequired: "Passphrase is required",
passphraseMismatch: "Passphrases do not match",
passphraseTooShort: "Passphrase must be at least 4 characters",
exportEncrypted: "Export",
exportEncrypted: "Export encrypted",
exportUnencrypted: "Export without encryption",
unencryptedWarningTitle: "Export without encryption?",
unencryptedWarningDescription: "An unencrypted configuration file may contain database passwords, SSH Tunnel credentials, and other sensitive information. Anyone who can access the file may read these credentials. Use this only for temporary migration in a trusted environment.",
confirmUnencryptedExport: "Export without encryption",
decryptImport: "Decrypt",
wrongPassphrase: "Wrong passphrase or corrupted file",
cryptoUnavailable: "Encrypted config import is unavailable in this browser context. Use HTTPS, localhost, or DBX Desktop.",
exportFailed: "Failed to export connections",
exportSuccess: "Connections exported successfully",
importSuccess: "Imported {count} connection(s)",
importNavicatSuccess: "Imported {count} Navicat connection(s). Fill in any connection whose password is still empty before testing.",
+7 -1
View File
@@ -2277,10 +2277,16 @@ export default withEnglishFallback({
passphraseRequired: "La frase de paso es obligatoria",
passphraseMismatch: "Las frases de paso no coinciden",
passphraseTooShort: "La frase de paso debe tener al menos 4 caracteres",
exportEncrypted: "Exportar",
exportEncrypted: "Exportar cifrado",
exportUnencrypted: "Exportar sin cifrado",
unencryptedWarningTitle: "¿Exportar sin cifrado?",
unencryptedWarningDescription:
"El archivo de configuración sin cifrar puede contener contraseñas de bases de datos, credenciales de SSH Tunnel y otra información sensible. Cualquiera que acceda al archivo podría leer estas credenciales. Úsalo solo para una migración temporal en un entorno de confianza.",
confirmUnencryptedExport: "Exportar sin cifrado",
decryptImport: "Descifrar",
wrongPassphrase: "Frase de paso incorrecta o archivo corrupto",
cryptoUnavailable: "La importación cifrada no está disponible en este navegador. Usa HTTPS, localhost o DBX Desktop.",
exportFailed: "No se pudieron exportar las conexiones",
exportSuccess: "Conexiones exportadas exitosamente",
importSuccess: "Se importaron {count} conexión(es)",
importNavicatSuccess: "Se importaron {count} conexión(es) de Navicat. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.",
+7 -1
View File
@@ -2275,10 +2275,16 @@ export default withEnglishFallback({
passphraseRequired: "La passphrase è richiesta",
passphraseMismatch: "Le passphrase non corrispondono",
passphraseTooShort: "La passphrase deve essere di almeno 4 caratteri",
exportEncrypted: "Esporta",
exportEncrypted: "Esporta crittografato",
exportUnencrypted: "Esporta senza crittografia",
unencryptedWarningTitle: "Esportare senza crittografia?",
unencryptedWarningDescription:
"Il file di configurazione non crittografato può contenere password del database, credenziali SSH Tunnel e altre informazioni sensibili. Chiunque possa accedere al file potrebbe leggere queste credenziali. Usalo solo per una migrazione temporanea in un ambiente fidato.",
confirmUnencryptedExport: "Esporta senza crittografia",
decryptImport: "Decrittografa",
wrongPassphrase: "Passphrase errata o file corrotto",
cryptoUnavailable: "L'importazione cifrata non è disponibile in questo contesto del browser. Usa HTTPS, localhost o DBX Desktop.",
exportFailed: "Impossibile esportare le connessioni",
exportSuccess: "Connessioni esportate con successo",
importSuccess: "Importata/e {count} connessione/i",
importNavicatSuccess: "Importata/e {count} connessione/i Navicat. Inserisci le password mancanti prima di testare la connessione.",
+6 -1
View File
@@ -2302,10 +2302,15 @@ export default withEnglishFallback({
passphraseRequired: "パスフレーズは必須です",
passphraseMismatch: "パスフレーズが一致しません",
passphraseTooShort: "パスフレーズは4文字以上必要です",
exportEncrypted: "エクスポート",
exportEncrypted: "暗号化してエクスポート",
exportUnencrypted: "暗号化せずにエクスポート",
unencryptedWarningTitle: "暗号化せずにエクスポートしますか?",
unencryptedWarningDescription: "暗号化されていない設定ファイルには、データベースパスワード、SSH Tunnel の認証情報などの機密情報が含まれる場合があります。ファイルにアクセスできる人はこれらの認証情報を読めます。信頼できる環境での一時的な移行にのみ使用してください。",
confirmUnencryptedExport: "暗号化せずにエクスポート",
decryptImport: "復号",
wrongPassphrase: "パスフレーズが間違っているか、ファイルが破損しています",
cryptoUnavailable: "暗号化設定のインポートはこのブラウザ環境では利用できません。HTTPS、localhost、またはDBX Desktopを使用してください。",
exportFailed: "接続のエクスポートに失敗しました",
exportSuccess: "接続を正常にエクスポートしました",
importSuccess: "{count}件の接続をインポートしました",
importNavicatSuccess: "{count}件のNavicat接続をインポートしました。テスト前にパスワードが空の接続を入力してください。",
+6 -1
View File
@@ -2193,10 +2193,15 @@ export default withEnglishFallback({
passphraseRequired: "암호구는 필수입니다",
passphraseMismatch: "암호구가 일치하지 않습니다",
passphraseTooShort: "암호구는 최소 4자 이상이어야 합니다",
exportEncrypted: "내보내기",
exportEncrypted: "암호화하여 내보내기",
exportUnencrypted: "암호화하지 않고 내보내기",
unencryptedWarningTitle: "암호화하지 않고 내보내시겠습니까?",
unencryptedWarningDescription: "암호화되지 않은 구성 파일에는 데이터베이스 비밀번호, SSH Tunnel 자격 증명 및 기타 민감한 정보가 포함될 수 있습니다. 파일에 접근할 수 있는 사람은 이 자격 증명을 읽을 수 있습니다. 신뢰할 수 있는 환경에서 임시 마이그레이션을 할 때만 사용하세요.",
confirmUnencryptedExport: "암호화하지 않고 내보내기",
decryptImport: "복호화",
wrongPassphrase: "암호구가 잘못되었거나 파일이 손상되었습니다",
cryptoUnavailable: "이 브라우저 환경에서는 암호화된 설정 가져오기를 사용할 수 없습니다. HTTPS, localhost 또는 DBX 데스크톱을 사용하세요.",
exportFailed: "연결을 내보내지 못했습니다",
exportSuccess: "연결을 성공적으로 내보냈습니다",
importSuccess: "연결 {count}개를 가져왔습니다",
importNavicatSuccess: "Navicat 연결 {count}개를 가져왔습니다. 테스트하기 전에 비밀번호가 비어 있는 연결의 비밀번호를 입력하세요.",
+7 -1
View File
@@ -2277,10 +2277,16 @@ export default withEnglishFallback({
passphraseRequired: "A frase secreta é obrigatória",
passphraseMismatch: "As frases secretas não coincidem",
passphraseTooShort: "A frase secreta deve ter pelo menos 4 caracteres",
exportEncrypted: "Exportar",
exportEncrypted: "Exportar criptografado",
exportUnencrypted: "Exportar sem criptografia",
unencryptedWarningTitle: "Exportar sem criptografia?",
unencryptedWarningDescription:
"O arquivo de configuração sem criptografia pode conter senhas de banco de dados, credenciais de SSH Tunnel e outras informações sensíveis. Qualquer pessoa com acesso ao arquivo poderá ler essas credenciais. Use apenas para uma migração temporária em um ambiente confiável.",
confirmUnencryptedExport: "Exportar sem criptografia",
decryptImport: "Descriptografar",
wrongPassphrase: "Frase secreta incorreta ou arquivo corrompido",
cryptoUnavailable: "A importação criptografada não está disponível neste contexto do navegador. Use HTTPS, localhost ou DBX Desktop.",
exportFailed: "Falha ao exportar as conexões",
exportSuccess: "Conexões exportadas com sucesso",
importSuccess: "{count} conexão(ões) importada(s)",
importNavicatSuccess: "{count} conexão(ões) do Navicat importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.",
+6 -1
View File
@@ -2266,10 +2266,15 @@ export default withEnglishFallback({
passphraseRequired: "请输入密码短语",
passphraseMismatch: "两次输入的密码短语不一致",
passphraseTooShort: "密码短语至少需要 4 个字符",
exportEncrypted: "导出",
exportEncrypted: "加密导出",
exportUnencrypted: "不加密导出",
unencryptedWarningTitle: "确认不加密导出?",
unencryptedWarningDescription: "未加密的配置文件可能包含数据库密码、SSH Tunnel 等敏感信息。任何能够访问该文件的人都可能读取这些凭据。仅建议在可信环境下用于临时迁移。",
confirmUnencryptedExport: "仍然不加密导出",
decryptImport: "解密",
wrongPassphrase: "密码短语错误或文件已损坏",
cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。",
exportFailed: "连接配置导出失败",
exportSuccess: "连接配置导出成功",
importSuccess: "已导入 {count} 个连接",
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
+6 -1
View File
@@ -2276,10 +2276,15 @@ export default withEnglishFallback({
passphraseRequired: "請輸入加密密碼",
passphraseMismatch: "兩次輸入的加密密碼不一致",
passphraseTooShort: "加密密碼至少需要 4 個字元",
exportEncrypted: "匯出",
exportEncrypted: "加密匯出",
exportUnencrypted: "不加密匯出",
unencryptedWarningTitle: "確認不加密匯出?",
unencryptedWarningDescription: "未加密的設定檔可能包含資料庫密碼、SSH Tunnel 等敏感資訊。任何能存取該檔案的人都可能讀取這些憑據。僅建議在可信任環境中用於臨時遷移。",
confirmUnencryptedExport: "仍然不加密匯出",
decryptImport: "解密",
wrongPassphrase: "加密密碼錯誤或檔案已損壞",
cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。",
exportFailed: "連線設定匯出失敗",
exportSuccess: "連線設定匯出成功",
importSuccess: "已匯入 {count} 個連線",
importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線",
@@ -1,6 +1,8 @@
import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database";
import { filterSidebarLayoutByConnectionIds as filterLayoutByConnectionIds } from "@/lib/sidebar/sidebarLayout";
export type ConnectionExportProtection = { mode: "encrypted"; passphrase: string } | { mode: "plaintext" };
export interface ConnectionConfigBundle {
connections: ConnectionConfig[];
layout?: SidebarLayout;
@@ -344,7 +344,7 @@ describe("connectionStore timeout recovery", () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.initFromDisk();
await store.exportConnectionsToFile("test-passphrase");
await store.exportConnectionsToFile({ mode: "encrypted", passphrase: "test-passphrase" });
const exported = JSON.parse(encryptConfig.mock.calls[0]?.[0] as string);
expect(exported.connections[0]).toMatchObject({
@@ -356,6 +356,32 @@ describe("connectionStore timeout recovery", () => {
expect(click).toHaveBeenCalledOnce();
});
it("reports cancellation when the native export save dialog is dismissed", async () => {
const save = vi.fn().mockResolvedValue(null);
const writeTextFile = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.doMock("@tauri-apps/plugin-dialog", () => ({ save }));
vi.doMock("@tauri-apps/plugin-fs", () => ({ writeTextFile }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection()]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.initFromDisk();
await expect(store.exportConnectionsToFile({ mode: "plaintext" })).resolves.toBe("cancelled");
expect(writeTextFile).not.toHaveBeenCalled();
});
it("clears connection node loading when health check timeout forces reconnect failure", async () => {
const checkConnectionHealth = vi.fn(() => new Promise(() => undefined));
const connectDb = vi.fn().mockRejectedValue(new Error("reconnect failed"));
+24 -6
View File
@@ -62,7 +62,17 @@ import {
type DropPosition,
type ReorderEntriesOptions,
} from "@/lib/sidebar/sidebarLayout";
import { buildConnectionConfigBundle, filterSidebarLayoutByConnectionIds, filterTunnelProfilesByIds, parseConnectionConfigObject, referencedTunnelProfileIds, selectConnectionConfigBundle, snapshotConnectionsForExport, type ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer";
import {
buildConnectionConfigBundle,
filterSidebarLayoutByConnectionIds,
filterTunnelProfilesByIds,
parseConnectionConfigObject,
referencedTunnelProfileIds,
selectConnectionConfigBundle,
snapshotConnectionsForExport,
type ConnectionConfigBundle,
type ConnectionExportProtection,
} from "@/lib/connection/connectionConfigTransfer";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import * as api from "@/lib/backend/api";
@@ -7962,8 +7972,9 @@ export const useConnectionStore = defineStore("connection", () => {
await refreshNodes(treeNodes.value);
}
async function exportConnectionsToFile(passphrase: string, selectedConnectionIds?: Iterable<string>) {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
async function exportConnectionsToFile(protection: ConnectionExportProtection, selectedConnectionIds?: Iterable<string>) {
if (protection.mode === "encrypted" && !protection.passphrase) throw new Error("passphrase_required");
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
// Older DBX versions ignore inheritance flags, so always include the
@@ -7974,8 +7985,14 @@ export const useConnectionStore = defineStore("connection", () => {
});
const exportData = buildConnectionConfigBundle(exportedConnections, sidebarLayout.value, tunnelProfileStore.profiles, selectedConnectionIds);
const json = JSON.stringify(exportData);
const payload = await encryptConfig(json, passphrase);
const content = JSON.stringify(payload, null, 2);
let content: string;
if (protection.mode === "encrypted") {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
const payload = await encryptConfig(json, protection.passphrase);
content = JSON.stringify(payload, null, 2);
} else {
content = JSON.stringify(exportData, null, 2);
}
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -7984,7 +8001,7 @@ export const useConnectionStore = defineStore("connection", () => {
filters: [{ name: "JSON", extensions: ["json"] }],
defaultPath: "dbx-connections.json",
});
if (!path) return;
if (!path) return "cancelled" as const;
await writeTextFile(path, content);
} else {
const blob = new Blob([content], { type: "application/json" });
@@ -7995,6 +8012,7 @@ export const useConnectionStore = defineStore("connection", () => {
a.click();
URL.revokeObjectURL(url);
}
return "saved" as const;
}
function bytesToBase64(bytes: Uint8Array) {