feat(security): encrypt local credentials and guide data migration

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 11:02:52 +08:00
1 parent 19f36557b9
commit 250e0c0a10
58 files changed
+8831 -435

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate, type Connection
import { resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate";
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
import {
isBrowserReloadShortcut,
isCloseOtherTabsShortcut,
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import LoginPage from "@/components/auth/LoginPage.vue";
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
import { useMigrationStore } from "@/stores/migrationStore";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { apiUrl, webPath } from "@/lib/common/webPath";
// App setup installs listeners and instantiates business stores, so even its import
// is deferred until authentication and the security migration have completed.
const App = defineAsyncComponent(() => import("./App.vue"));
const { t } = useI18n();
const migration = useMigrationStore();
const { blocking } = migration;
const checkingAuth = ref(true);
const loginRequired = ref(false);
const setupRequired = ref(false);
const authFailed = ref(false);
const enteringApp = ref(false);
let launchTransitionTimer: number | undefined;
watch(
() => migration.state.entered,
(entered) => {
if (entered) {
enteringApp.value = true;
}
},
);
function appReady() {
window.clearTimeout(launchTransitionTimer);
launchTransitionTimer = window.setTimeout(() => {
enteringApp.value = false;
}, 650);
}
async function initialize() {
checkingAuth.value = true;
authFailed.value = false;
try {
if (!isTauriRuntime()) {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result = await response.json();
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
setupRequired.value = result.setup_required === true;
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
if (loginRequired.value) {
history.replaceState(null, "", webPath("/login"));
return;
}
}
await migration.initialize();
} catch {
authFailed.value = true;
} finally {
checkingAuth.value = false;
}
}
async function authenticated() {
history.replaceState(null, "", webPath("/"));
await initialize();
}
onMounted(initialize);
</script>
<template>
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
</div>
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
<div v-else class="relative min-h-screen">
<Suspense @resolve="appReady">
<App />
</Suspense>
<Transition name="startup-fade">
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
<div class="flex flex-col items-center gap-5 text-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
<div>
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
</div>
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.startup-fade-enter-active,
.startup-fade-leave-active {
transition: opacity 280ms ease;
}
.startup-fade-enter-from,
.startup-fade-leave-to {
opacity: 0;
}
.startup-progress {
animation: startup-progress 1.2s ease-in-out infinite;
}
@keyframes startup-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(200%);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-progress {
animation: none;
transform: translateX(50%);
}
}
</style>
@@ -4293,7 +4293,7 @@ async function testWebDav() {
async function uploadWebDavSnapshot() {
await runWebDavAction("upload", async () => {
const summary = await webdavSyncUpload(currentWebDavConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
const summary = await webdavSyncUpload(currentWebDavConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined, webdavSyncSecrets.value);
return t("settings.syncUploadSuccess", {
bytes: summary.bytes,
path: summary.remotePath,
@@ -4304,7 +4304,7 @@ async function uploadWebDavSnapshot() {
async function downloadWebDavSnapshot() {
if (!window.confirm(t("settings.syncDownloadConfirm"))) return;
await runWebDavAction("download", async () => {
const result = await webdavSyncDownload(currentWebDavConfig(), webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
const result = await webdavSyncDownload(currentWebDavConfig(), webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined, webdavSyncSecrets.value);
if (result.editorSettings && typeof result.editorSettings === "object") {
settingsStore.updateEditorSettings(result.editorSettings as any);
}
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { describe, it, expect, vi } from "vitest";
import { useMigrationStore } from "@/stores/migrationStore";
import Wizard from "./SecurityMigrationWizard.vue";
const mocks = vi.hoisted(() => ({ save: vi.fn().mockRejectedValue(new Error("unavailable")) }));
vi.mock("@/lib/backend/api", () => ({}));
vi.mock("@/i18n", () => ({ setLocale: vi.fn() }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: "en" }) }));
vi.mock("@/lib/export/saveTextFile", () => ({ saveTextFile: mocks.save }));
describe("migration failure actions", () => {
it("shows a redacted diagnostic even when file saving fails and routes status retries correctly", async () => {
const backend = { migrationStatus: vi.fn().mockRejectedValue(new Error("private transport detail")), migrationStart: vi.fn(), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() };
const store = useMigrationStore(backend);
await store.initialize();
store.state.step = 2;
const root = document.createElement("div");
const app = createApp(Wizard, { store });
app.mount(root);
try {
const button = (label: string) => Array.from(root.querySelectorAll("button")).find((b) => b.textContent === label)!;
expect(root.querySelector("textarea")?.value).toContain("statusFailed");
button("migration.exportDiagnostic").click();
await nextTick();
await nextTick();
expect(mocks.save).toHaveBeenCalledWith(expect.any(String), "dbx-migration-diagnostic.json", "JSON", "json");
expect(root.querySelector("textarea")?.value).toContain("statusFailed");
expect(root.querySelector("textarea")?.value).not.toContain("private transport detail");
button("migration.retryStatus").click();
await nextTick();
expect(backend.migrationStatus).toHaveBeenCalledTimes(2);
expect(backend.migrationRetry).not.toHaveBeenCalled();
} finally {
app.unmount();
}
});
it("updates the visible diagnostic after a failed action refreshes migration status", async () => {
const pendingStatus = {
migrationId: "secret-store-v1",
state: "pending" as const,
needsMigration: true,
keyProviderAvailable: true,
databasePlaintextCount: 1,
connectionCount: 1,
pluginSecretCount: 0,
aiSecretCount: 0,
tunnelSecretCount: 0,
syncCredentialCount: 0,
legacyJsonFiles: [],
backupRequired: false,
};
const failedStatus = { ...pendingStatus, state: "failed" as const, errorCode: "LEGACY_JSON_INVALID", errorMessage: "Repair connections.json" };
const status = vi.fn().mockResolvedValueOnce(pendingStatus).mockResolvedValueOnce(failedStatus);
const backend = { migrationStatus: status, migrationStart: vi.fn().mockRejectedValue(new Error("request failed")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() };
const store = useMigrationStore(backend);
await store.initialize();
const root = document.createElement("div");
const app = createApp(Wizard, { store });
app.mount(root);
try {
const start = Array.from(root.querySelectorAll("button")).find((button) => button.textContent === "migration.start")!;
start.click();
await vi.waitFor(() => expect(root.querySelector("textarea")?.value).toContain("LEGACY_JSON_INVALID"));
expect(root.querySelector("textarea")?.value).toContain('"state": "failed"');
} finally {
app.unmount();
}
});
});
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { setLocale, type Locale } from "@/i18n";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
import { saveTextFile } from "@/lib/export/saveTextFile";
import type { useMigrationStore } from "@/stores/migrationStore";
const props = defineProps<{ store: ReturnType<typeof useMigrationStore> }>();
const { t, locale } = useI18n({ useScope: "global" });
const status = computed(() => props.store.state.status);
const confirmingCleanup = ref(false);
const diagnosticText = computed(() => (props.store.state.error ? JSON.stringify(props.store.diagnostic(), null, 2) : ""));
const exportingDiagnostic = ref(false);
const canRevealPaths = computed(() => isTauriRuntime());
const errorCode = computed(() => props.store.state.errorCode);
const errorMessage = computed(() => props.store.state.errorMessage);
const errorAdviceKey = computed(() => {
if (props.store.state.error === "cleanupFailed") return "migration.errors.cleanupAdvice";
switch (errorCode.value) {
case "MISSING_MANAGED_KEY":
case "MISSING_EXTERNAL_KEY":
case "MISSING_PERSISTENT_KEY":
return "migration.errors.persistentKeyAdvice";
case "ENCRYPTED_DATA_KEY_MISSING":
case "SECRET_KEY_INVALID":
case "SECRET_KEY_MISMATCH":
case "KEY_FILE_UNAVAILABLE":
case "KEY_PROVIDER_UNAVAILABLE":
return "migration.errors.keyProviderAdvice";
case "BACKUP_FAILED":
return "migration.errors.backupAdvice";
case "LEGACY_JSON_INVALID":
return "migration.errors.jsonAdvice";
case "VERIFICATION_FAILED":
return "migration.errors.verifyAdvice";
default:
return "migration.errors.genericAdvice";
}
});
function changeLocale(event: Event) {
const value = (event.target as HTMLSelectElement).value as Locale;
void setLocale(value);
}
async function exitApp() {
if (isTauriRuntime()) {
try {
await api.completeAppClose("quit");
return;
} catch {
/* browser fallback */
}
}
window.close();
}
async function revealPath(path: string | null | undefined) {
if (!path || !isTauriRuntime()) return;
try {
await api.revealPathInFileManager(path);
} catch {
/* the path stays visible so it can still be copied manually */
}
}
async function diagnostic() {
if (exportingDiagnostic.value) return;
exportingDiagnostic.value = true;
try {
await saveTextFile(diagnosticText.value, "dbx-migration-diagnostic.json", "JSON", "json");
} catch {
// Keep a selectable, redacted report visible even if native saving fails.
} finally {
exportingDiagnostic.value = false;
}
}
</script>
<template>
<main class="fixed inset-0 z-[1000] flex items-center justify-center bg-background/95 p-6" role="dialog" aria-modal="true">
<section class="w-full max-w-2xl rounded-xl border bg-card p-8 shadow-2xl">
<div class="mb-8 flex items-start justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">{{ t("migration.eyebrow") }}</p>
<h1 class="mt-2 text-2xl font-semibold">{{ t("migration.title") }}</h1>
</div>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<span class="sr-only">{{ t("migration.language") }}</span>
<select class="h-8 rounded-md border bg-card px-2 text-xs text-foreground outline-none focus:ring-2 focus:ring-primary" :value="locale" :aria-label="t('migration.language')" @change="changeLocale">
<option v-for="option in LOCALE_OPTIONS" :key="option.value" :value="option.value">{{ option.flag }} {{ option.label }}</option>
</select>
</label>
</div>
<ol class="mb-8 grid grid-cols-3 gap-3" :aria-label="t('migration.stepsLabel')">
<li v-for="item in [1, 2, 3]" :key="item" class="flex min-w-0 items-center justify-center gap-2 text-center text-sm">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold" :class="item <= props.store.state.step ? 'border-primary bg-primary text-primary-foreground' : 'text-muted-foreground'">{{ item }}</span
><span :class="item <= props.store.state.step ? 'font-medium' : 'text-muted-foreground'">{{ t(`migration.step${item}`) }}</span>
</li>
</ol>
<div v-if="props.store.state.loading" class="py-10 text-center text-sm text-muted-foreground">{{ t("migration.checking") }}</div>
<template v-else>
<div v-if="props.store.state.step === 1" class="space-y-5">
<p class="text-sm text-muted-foreground">{{ t("migration.intro") }}</p>
<div class="rounded-lg border border-primary/20 bg-primary/5 p-4 text-sm">
<h2 class="font-medium">{{ t("migration.whyTitle") }}</h2>
<p class="mt-2 text-muted-foreground">{{ t("migration.whyBody") }}</p>
<ul class="mt-3 list-disc space-y-1 pl-5 text-muted-foreground">
<li>{{ t("migration.whyItem1") }}</li>
<li>{{ t("migration.whyItem2") }}</li>
</ul>
</div>
<div v-if="status" class="grid gap-3 rounded-lg border p-4 text-sm sm:grid-cols-2">
<div>
<span class="text-muted-foreground">{{ t("migration.connectionCountLabel") }}</span> {{ status.connectionCount }}
</div>
<div>
<span class="text-muted-foreground">{{ t("migration.secretCountLabel") }}</span> {{ status.databasePlaintextCount }}
</div>
<div>
<span class="text-muted-foreground">{{ t("migration.backupRequiredLabel") }}</span> {{ status.backupRequired ? t("migration.yes") : t("migration.no") }}
</div>
<div>
<span class="text-muted-foreground">{{ t("migration.keyProviderLabel") }}</span> {{ status.persistentKeyConfigured ? t("migration.ready") : t("migration.pending") }}
</div>
</div>
<p v-if="status?.dataDir" class="break-all text-xs text-muted-foreground">
{{ t("migration.dataDir")
}}<!--
--><button
type="button"
class="break-all text-left underline decoration-dotted underline-offset-2 hover:text-foreground disabled:cursor-default disabled:no-underline"
:disabled="!canRevealPaths"
:title="canRevealPaths ? t('migration.openDataDir') : status.dataDir"
@click="revealPath(status.dataDir)"
>
{{ status.dataDir }}
</button>
</p>
<p v-if="status?.keyProviderAvailable === false && !status.keyCreationAllowed" class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{{ t("migration.keyUnavailable") }}</p>
<p v-if="status?.keyFileConfigured === false && status?.persistentKeyConfigured === false && status?.keyCreationAllowed === false" class="text-xs text-muted-foreground">{{ t("migration.keyRecoveryHint") }}</p>
<div v-if="props.store.state.error" class="space-y-1 rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
<p>{{ t(`migration.${props.store.state.error === "statusFailed" ? "statusFailed" : props.store.state.error === "cleanupFailed" ? "cleanupFailed" : "failed"}`) }}</p>
<p v-if="errorCode">{{ t("migration.errorCode", { code: errorCode }) }}</p>
<p v-if="errorMessage">{{ errorMessage }}</p>
<p>{{ t(errorAdviceKey) }}</p>
</div>
<div class="flex flex-wrap gap-3">
<button v-if="props.store.state.error === 'statusFailed' || (status?.keyProviderAvailable === false && !status?.keyCreationAllowed)" class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50" :disabled="props.store.state.busy" @click="props.store.initialize">
{{ t("migration.retryStatus") }}</button
><button
v-else
class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
:disabled="props.store.state.busy || (status?.keyProviderAvailable === false && !status?.keyCreationAllowed)"
@click="status?.state === 'failed' ? props.store.retry() : props.store.start()"
>
{{ status?.state === "failed" ? t("migration.retryMigration") : t("migration.start") }}</button
><button v-if="props.store.state.error" class="rounded-md border px-4 py-2" @click="diagnostic">{{ t("migration.exportDiagnostic") }}</button
><button v-if="props.store.state.error && status?.dataDir && canRevealPaths" class="rounded-md border px-4 py-2" @click="revealPath(status.dataDir)">{{ t("migration.openDataDir") }}</button
><button v-if="props.store.state.error" class="rounded-md border px-4 py-2" @click="exitApp">{{ t("migration.exit") }}</button>
</div>
</div>
<div v-else-if="props.store.state.step === 2" class="space-y-5">
<div class="rounded-lg border p-5" :class="props.store.state.error ? 'border-destructive/40' : 'border-primary/30'">
<div class="flex items-center gap-3">
<span class="h-3 w-3 rounded-full" :class="props.store.state.busy ? 'animate-pulse bg-primary' : 'bg-destructive'" />
<h2 class="font-medium">{{ props.store.state.error === "statusFailed" ? t("migration.statusFailed") : props.store.state.error ? t("migration.failed") : t("migration.running") }}</h2>
</div>
<p v-if="errorCode" class="mt-3 text-sm">{{ t("migration.errorCode", { code: errorCode }) }}</p>
<p v-if="errorMessage" class="mt-2 text-sm">{{ errorMessage }}</p>
<p class="mt-3 text-sm text-muted-foreground">{{ props.store.state.error ? t(errorAdviceKey) : t("migration.progress") }}</p>
</div>
<div v-if="props.store.state.error" class="flex flex-wrap gap-3">
<button class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50" :disabled="props.store.state.busy" @click="props.store.state.error === 'statusFailed' ? props.store.initialize() : props.store.retry()">
{{ props.store.state.busy ? t("migration.running") : props.store.state.error === "statusFailed" ? t("migration.retryStatus") : t("migration.retryMigration") }}</button
><button class="rounded-md border px-4 py-2 disabled:opacity-50" :disabled="props.store.state.busy" @click="diagnostic">{{ t("migration.exportDiagnostic") }}</button
><button class="rounded-md border px-4 py-2 disabled:opacity-50" :disabled="props.store.state.busy" @click="exitApp">{{ t("migration.exit") }}</button>
</div>
</div>
<div v-else class="space-y-5">
<div class="rounded-lg border border-primary/30 bg-primary/5 p-5">
<h2 class="font-medium">{{ t("migration.successTitle") }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.successIntro") }}</p>
<p v-if="status?.backupPath" class="mt-3 break-all text-xs text-muted-foreground">
{{ t("migration.backupPath")
}}<!--
--><button
type="button"
class="break-all text-left underline decoration-dotted underline-offset-2 hover:text-foreground disabled:cursor-default disabled:no-underline"
:disabled="!canRevealPaths"
:title="canRevealPaths ? t('migration.openBackupDir') : status.backupPath"
@click="revealPath(status.backupPath)"
>
{{ status.backupPath }}
</button>
</p>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<button class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50" :disabled="props.store.state.busy" @click="props.store.enter">{{ t("migration.enter") }}</button
><button v-if="status?.backupPath && props.store.state.error !== 'cleanupFailed'" class="text-xs text-muted-foreground underline underline-offset-2 hover:text-destructive disabled:opacity-50" :disabled="props.store.state.busy" @click="confirmingCleanup = true">
{{ t("migration.deleteBackup") }}
</button>
</div>
<div v-if="confirmingCleanup" class="rounded border p-4 text-sm">
<p>{{ t("migration.confirmDelete") }}</p>
<div class="mt-3 flex gap-2">
<button
class="rounded border border-destructive bg-card px-3 py-1 text-destructive hover:bg-destructive/10"
@click="
confirmingCleanup = false;
props.store.cleanup();
"
>
{{ t("migration.confirm") }}</button
><button class="rounded border px-3 py-1" @click="confirmingCleanup = false">{{ t("migration.cancel") }}</button>
</div>
</div>
<div v-if="props.store.state.error === 'cleanupFailed'" class="space-y-3" role="alert">
<p class="text-sm text-destructive">{{ t("migration.cleanupFailed") }} {{ t("migration.errors.cleanupAdvice") }}</p>
<button class="rounded-md border px-4 py-2 disabled:opacity-50" :disabled="props.store.state.busy" @click="props.store.cleanup">{{ t("migration.retryCleanup") }}</button>
</div>
</div>
<div v-if="diagnosticText" class="mt-4 space-y-2">
<p class="text-sm font-medium">{{ t("migration.exportDiagnostic") }}</p>
<textarea readonly :value="diagnosticText" :aria-label="t('migration.exportDiagnostic')" class="h-40 w-full rounded border bg-background p-3 font-mono text-xs" />
</div>
</template>
</section>
</main>
</template>
@@ -1,7 +1,7 @@
import { onMounted, onUnmounted } from "vue";
import { useSettingsStore } from "@/stores/settingsStore";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { webdavSyncUpload } from "@/lib/backend/api";
import { webdavSyncSecretsStatus, webdavSyncUpload } from "@/lib/backend/api";
import { readWebDavAutoUploadConfig, WEB_DAV_AUTO_UPLOAD_STORAGE_KEYS } from "@/lib/webdav/webdavAutoUploadConfig";
export function useWebDavAutoUpload() {
@@ -32,7 +32,8 @@ export function useWebDavAutoUpload() {
uploading = true;
try {
const summary = await webdavSyncUpload(config.webDavConfig, settingsStore.editorSettings);
const secretsStatus = await webdavSyncSecretsStatus();
const summary = await webdavSyncUpload(config.webDavConfig, settingsStore.editorSettings, undefined, secretsStatus.enabled && secretsStatus.hasSavedPassphrase);
appendDebugLog("info", "[DBX][webdav:auto-upload:success]", {
bytes: summary.bytes,
remotePath: summary.remotePath,
+63
View File
@@ -10259,4 +10259,67 @@ export default withEnglishFallback({
titleLabel: "Başlıq (istəyə bağlı)",
titlePlaceholder: "Məsələn, sorğu nəticəsi",
},
migration: {
eyebrow: "Yeniləmədən sonra ilk açılış",
title: "Məlumat təhlükəsizliyi yeniləməsi",
stepsLabel: "Köçürmə addımları",
language: "Dil",
step1: "Yoxla və təsdiqlə",
step2: "Məlumatları köçür",
step3: "Tamamla və ehtiyat nüsxə",
successTitle: "Köçürmə tamamlandı",
connectionCountLabel: "Bağlantılar:",
secretCountLabel: "Qorunan elementlər:",
backupRequiredLabel: "Ehtiyat nüsxə:",
keyProviderLabel: "Açar vəziyyəti:",
ready: "hazır",
pending: "qurulmalıdır",
intro: "DBX-i yeniləyib yenidən başlatdınız. Bu yeniləmə yerli məlumatların təhlükəsizliyini artırır. Bir dəfəlik prosesi tamamladıqdan sonra bağlantılardan istifadə edə bilərsiniz.",
whyTitle: "Bu yeniləmə nə edir?",
whyBody: "Yeni versiya bağlantıları, plaginləri və digər həssas ayarları daha güclü şifrələmə ilə qoruyur. Bu proses yalnız bir dəfə işləyəcək.",
whyItem1: "Başlamazdan əvvəl ehtiyat nüsxə yaradılır.",
whyItem2: "Tamamlanmadan əvvəl nəticə yoxlanılır.",
connectionCount: "Bağlantılar: {count}",
secretCount: "Qorunan elementlər: {count}",
backupPath: "Ehtiyat nüsxə:",
dataDir: "Məlumat qovluğu:",
backupDir: "Ehtiyat qovluğu:",
yes: "bəli",
no: "xeyr",
keyFileStatus: "Açar faylı: {configured}; oxunaqlıdır: {readable}",
persistentKeyStatus: "Davamlı açar təminatçısı: {available}",
keyUnavailable: "Cihaz açarı əlçatan deyil. Açar zəncirinə girişə icazə verin və ya davamlı açar qurun.",
keyRecoveryHint: "Bu məlumat qovluğu üçün istifadə olunan orijinal açarı bərpa edin və ya mövcud məlumatları şifrələyən eyni xarici açarı konfiqurasiya edin. Onu yeni açarla əvəz etməyin.",
progress: "Ehtiyat nüsxə yaradılır, köçürülür və yoxlanılır…",
failed: "Köçürmə tamamlanmadı. İlkin məlumatlar saxlanıldı.",
cleanupFailed: "Köçürmə tamamlandı, lakin ehtiyat nüsxə silinmədi.",
retryCleanup: "Silməni yenidən yoxla",
statusFailed: "Köçürmə vəziyyəti yoxlanıla bilmədi.",
errorCode: "Xəta kodu: {code}",
retryStatus: "Vəziyyəti yenidən yoxla",
start: "Köçürməni başlat",
running: "Köçürülür…",
retryMigration: "Köçürməni yenidən yoxla",
enter: "DBX-ə daxil ol",
deleteBackup: "Ehtiyat nüsxəni sil",
exportDiagnostic: "Diaqnostikanı ixrac et",
openDataDir: "Məlumat qovluğunu aç",
exit: "DBX-dən çıx",
confirmDelete: "Köçürmə ehtiyat nüsxəsi silinsin?",
confirm: "Sil",
cancel: "Ləğv et",
checking: "Yerli məlumat təhlükəsizliyi yoxlanılır…",
authFailed: "DBX autentifikasiyası yoxlanıla bilmədi.",
retry: "Yenidən yoxla",
successIntro: "Köçürmə və yoxlama tamamlandı. Ehtiyat nüsxəni silməzdən əvvəl bağlantıları yoxlayın.",
errors: {
persistentKeyAdvice: "Davamlı açar qurun və vəziyyəti yenidən yoxlayın.",
cleanupAdvice: "Ehtiyat qovluğunun icazələrini yoxlayın və yenidən cəhd edin.",
genericAdvice: "Problemi həll edib yenidən cəhd edin.",
keyProviderAdvice: "Açar zəncirinə girişə icazə verin və ya davamlı açar faylı qurun.",
backupAdvice: "Məlumat qovluğunun yazıla bildiyini və boş yer olduğunu yoxlayın.",
jsonAdvice: "Göstərilən faylı düzəldib yenidən cəhd edin.",
verifyAdvice: "Yoxlama uğurlu olana qədər ehtiyat nüsxəni saxlayın.",
},
},
});
+65
View File
@@ -10874,4 +10874,69 @@ export default {
titleLabel: "Title (optional)",
titlePlaceholder: "e.g. Query result",
},
migration: {
eyebrow: "First launch after upgrade",
title: "Data security upgrade",
stepsLabel: "Migration steps",
language: "Language",
step1: "Review and confirm",
step2: "Migrate data",
step3: "Complete and backup",
successTitle: "Migration completed",
connectionCountLabel: "Connections: ",
secretCountLabel: "Sensitive items: ",
backupRequiredLabel: "Backup required: ",
keyProviderLabel: "Key status: ",
ready: "ready",
pending: "needs setup",
intro: "You have just upgraded and restarted DBX. This upgrade improves local data security; complete this one-time step to continue using your connections.",
whyTitle: "What does this upgrade do?",
whyBody: "The new version uses stronger encryption to protect connections, plugins, and other sensitive settings. This step runs once after the upgrade and will not appear again.",
whyItem1: "A backup is created before the upgrade so your data can be recovered if needed.",
whyItem2: "The result is verified before completion to confirm the security upgrade succeeded.",
successIntro: "Migration completed and ciphertext verification passed. Check your connections before deleting the backup.",
connectionCount: "Connections: {count}",
secretCount: "Sensitive items to migrate: {count}",
backupPath: "Backup: ",
dataDir: "Data directory: ",
backupDir: "Backup directory: ",
keyFileStatus: "Configured key file: {configured}; readable: {readable}",
persistentKeyStatus: "Persistent key provider available: {available}",
yes: "yes",
no: "no",
keyUnavailable: "The device key is unavailable. Allow keychain access or configure a persistent key.",
keyRecoveryHint: "Restore the original key used for this data directory, or configure the same external key that encrypted the existing data. Do not replace it with a new key.",
progress: "Creating backup, migrating, and verifying. Do not close DBX…",
failed: "Migration did not complete. Original data has been retained.",
cleanupFailed: "Migration completed, but the backup could not be deleted.",
retryCleanup: "Retry deleting backup",
statusFailed: "Unable to check migration status.",
errorCode: "Error code: {code}",
retryStatus: "Retry status check",
start: "Start migration",
running: "Migrating…",
retryMigration: "Retry migration",
enter: "Enter DBX",
launching: "Starting DBX…",
deleteBackup: "Delete backup",
exportDiagnostic: "Export redacted diagnostics",
openDataDir: "Open data directory",
openBackupDir: "Open backup location",
exit: "Exit DBX",
confirmDelete: "Delete this upgrade backup? Your connections and passwords are already stored on this device, so removing the backup does not delete your connection list or passwords, but you can no longer use it to roll back this upgrade.",
confirm: "Delete",
cancel: "Cancel",
errors: {
persistentKeyAdvice: "Configure DBX_SECRET_KEY_FILE or DBX_SECRET_KEY with a persistent key, then retry the status check.",
cleanupAdvice: "Check backup directory permissions and retry deleting the backup. Your migrated data is ready to use.",
genericAdvice: "Fix the issue above and retry. Your original data is still available.",
keyProviderAdvice: "Allow DBX to access the keychain or provide a persistent DBX_SECRET_KEY_FILE.",
backupAdvice: "Check that the data directory is writable and has enough free space.",
jsonAdvice: "Repair the reported legacy JSON file, then retry the migration.",
verifyAdvice: "Keep the backup and retry. Do not delete migration backups until verification succeeds.",
},
checking: "Checking local data security status…",
authFailed: "Unable to check DBX authentication.",
retry: "Retry",
},
};
+65
View File
@@ -10352,6 +10352,71 @@ export default withEnglishFallback({
titleLabel: "Título (opcional)",
titlePlaceholder: "p. ej. Resultado de consulta",
},
migration: {
eyebrow: "Primer inicio tras la actualización",
title: "Actualización de seguridad de datos",
stepsLabel: "Pasos de migración",
language: "Idioma",
step1: "Revisar y confirmar",
step2: "Migrar datos",
step3: "Finalizar y guardar copia",
successTitle: "Migración completada",
connectionCountLabel: "Conexiones:",
secretCountLabel: "Elementos protegidos:",
backupRequiredLabel: "Copia de seguridad:",
keyProviderLabel: "Estado de la clave:",
ready: "lista",
pending: "pendiente",
intro: "Acabas de actualizar y reiniciar DBX. Esta actualización mejora la seguridad de los datos locales. Completa este proceso único para seguir usando tus conexiones.",
whyTitle: "¿Qué hace esta actualización?",
whyBody: "La nueva versión usa un cifrado más sólido para proteger conexiones, plugins y otros ajustes sensibles. Este proceso se ejecuta una sola vez.",
whyItem1: "Se crea una copia de seguridad antes de empezar.",
whyItem2: "El resultado se verifica antes de finalizar.",
connectionCount: "Conexiones: {count}",
secretCount: "Elementos protegidos: {count}",
backupPath: "Copia de seguridad:",
dataDir: "Directorio de datos:",
backupDir: "Directorio de copias:",
yes: "sí",
no: "no",
keyFileStatus: "Archivo de clave configurado: {configured}; legible: {readable}",
persistentKeyStatus: "Proveedor de clave persistente: {available}",
keyUnavailable: "La clave del dispositivo no está disponible. Permite el acceso al llavero o configura una clave persistente.",
keyRecoveryHint: "Restaura la clave original usada por este directorio de datos o configura la misma clave externa que cifró los datos existentes. No la reemplaces por una clave nueva.",
progress: "Creando copia, migrando y verificando…",
failed: "La migración no se completó. Los datos originales se conservaron.",
cleanupFailed: "La migración terminó, pero no se pudo eliminar la copia.",
retryCleanup: "Reintentar eliminación",
statusFailed: "No se pudo comprobar el estado de la migración.",
errorCode: "Código de error: {code}",
retryStatus: "Reintentar comprobación",
start: "Iniciar migración",
running: "Migrando…",
retryMigration: "Reintentar migración",
enter: "Entrar en DBX",
deleteBackup: "Eliminar copia",
exportDiagnostic: "Exportar diagnóstico",
openDataDir: "Abrir directorio de datos",
exit: "Salir de DBX",
confirmDelete: "¿Eliminar la copia de migración?",
confirm: "Eliminar",
cancel: "Cancelar",
checking: "Comprobando la seguridad de los datos locales…",
authFailed: "No se pudo comprobar la autenticación de DBX.",
retry: "Reintentar",
successIntro: "La migración y la verificación terminaron. Comprueba las conexiones antes de eliminar la copia.",
errors: {
persistentKeyAdvice: "Configura una clave persistente y vuelve a comprobar el estado.",
cleanupAdvice: "Comprueba los permisos del directorio y vuelve a intentarlo.",
genericAdvice: "Soluciona el problema y vuelve a intentarlo.",
keyProviderAdvice: "Permite el acceso al llavero o configura un archivo de clave persistente.",
backupAdvice: "Comprueba que el directorio permite escribir y tiene espacio suficiente.",
jsonAdvice: "Repara el archivo indicado y vuelve a intentarlo.",
verifyAdvice: "Conserva la copia hasta que la verificación sea correcta.",
},
launching: "Iniciando DBX…",
openBackupDir: "Abrir ubicación de copia de seguridad",
},
solrAdmin: {
title: "Administración de Solr",
refresh: "Actualizar",
+65
View File
@@ -10352,6 +10352,71 @@ export default withEnglishFallback({
titleLabel: "Titolo (facoltativo)",
titlePlaceholder: "es. Risultato della query",
},
migration: {
eyebrow: "Primo avvio dopo l'aggiornamento",
title: "Aggiornamento della sicurezza dei dati",
stepsLabel: "Passaggi della migrazione",
language: "Lingua",
step1: "Controlla e conferma",
step2: "Migra dati",
step3: "Completa e crea backup",
successTitle: "Migrazione completata",
connectionCountLabel: "Connessioni:",
secretCountLabel: "Elementi protetti:",
backupRequiredLabel: "Backup:",
keyProviderLabel: "Stato della chiave:",
ready: "pronta",
pending: "da configurare",
intro: "Hai appena aggiornato e riavviato DBX. Questo aggiornamento migliora la sicurezza dei dati locali. Completa questa operazione una sola volta per continuare a usare le connessioni.",
whyTitle: "Cosa comporta questo aggiornamento?",
whyBody: "La nuova versione usa una crittografia più forte per proteggere connessioni, plugin e altre impostazioni sensibili. L'operazione viene eseguita una sola volta.",
whyItem1: "Prima dell'avvio viene creato un backup.",
whyItem2: "Il risultato viene verificato prima del completamento.",
connectionCount: "Connessioni: {count}",
secretCount: "Elementi protetti: {count}",
backupPath: "Backup:",
dataDir: "Directory dati:",
backupDir: "Directory backup:",
yes: "sì",
no: "no",
keyFileStatus: "File chiave configurato: {configured}; leggibile: {readable}",
persistentKeyStatus: "Provider di chiavi persistenti: {available}",
keyUnavailable: "La chiave del dispositivo non è disponibile. Consenti l'accesso al portachiavi o configura una chiave persistente.",
keyRecoveryHint: "Ripristina la chiave originale usata da questa directory dati oppure configura la stessa chiave esterna che ha cifrato i dati esistenti. Non sostituirla con una nuova chiave.",
progress: "Creazione del backup, migrazione e verifica in corso…",
failed: "Migrazione non completata. I dati originali sono stati conservati.",
cleanupFailed: "Migrazione completata, ma il backup non è stato eliminato.",
retryCleanup: "Riprova eliminazione",
statusFailed: "Impossibile controllare lo stato della migrazione.",
errorCode: "Codice errore: {code}",
retryStatus: "Riprova controllo",
start: "Avvia migrazione",
running: "Migrazione in corso…",
retryMigration: "Riprova migrazione",
enter: "Entra in DBX",
deleteBackup: "Elimina backup",
exportDiagnostic: "Esporta diagnostica",
openDataDir: "Apri directory dati",
exit: "Esci da DBX",
confirmDelete: "Eliminare il backup della migrazione?",
confirm: "Elimina",
cancel: "Annulla",
checking: "Controllo della sicurezza dei dati locali…",
authFailed: "Impossibile controllare l'autenticazione DBX.",
retry: "Riprova",
successIntro: "Migrazione e verifica completate. Controlla le connessioni prima di eliminare il backup.",
errors: {
persistentKeyAdvice: "Configura una chiave persistente e ricontrolla lo stato.",
cleanupAdvice: "Controlla i permessi della directory e riprova.",
genericAdvice: "Risolvi il problema e riprova.",
keyProviderAdvice: "Consenti l'accesso al portachiavi o configura un file chiave persistente.",
backupAdvice: "Controlla i permessi di scrittura e lo spazio disponibile.",
jsonAdvice: "Ripara il file indicato e riprova.",
verifyAdvice: "Conserva il backup finché la verifica non riesce.",
},
launching: "Avvio di DBX in corso…",
openBackupDir: "Apri posizione backup",
},
solrAdmin: {
title: "Amministrazione Solr",
refresh: "Aggiorna",
+65
View File
@@ -10402,6 +10402,71 @@ export default withEnglishFallback({
titleLabel: "タイトル(任意)",
titlePlaceholder: "例:クエリ結果",
},
migration: {
eyebrow: "アップグレード後の初回起動",
title: "データセキュリティのアップグレード",
stepsLabel: "移行ステップ",
language: "表示言語",
step1: "確認",
step2: "データを移行",
step3: "完了とバックアップ",
successTitle: "移行が完了しました",
connectionCountLabel: "接続数:",
secretCountLabel: "保護対象:",
backupRequiredLabel: "バックアップ:",
keyProviderLabel: "鍵の状態:",
ready: "準備完了",
pending: "要設定",
intro: "DBXをアップグレードして再起動しました。このアップグレードでローカルデータの安全性が向上します。1回の処理を完了すると接続を引き続き利用できます。",
whyTitle: "このアップグレードで何が変わりますか?",
whyBody: "新しいバージョンでは、接続、プラグイン、その他の機密設定をより強力な暗号化で保護します。この処理はアップグレード後に1回だけ実行され、以後は表示されません。",
whyItem1: "開始前にバックアップを作成するため、必要に応じてデータを復元できます。",
whyItem2: "完了前にデータを検証し、安全なアップグレードが成功したことを確認します。",
successIntro: "移行と検証が完了しました。バックアップを削除する前に接続を確認してください。",
connectionCount: "接続:{count}",
secretCount: "保護対象:{count}",
backupPath: "バックアップ:",
dataDir: "データディレクトリ:",
backupDir: "バックアップディレクトリ:",
keyFileStatus: "鍵ファイル設定済み:{configured};読み取り可能:{readable}",
persistentKeyStatus: "永続鍵プロバイダー:{available}",
yes: "はい",
no: "いいえ",
keyUnavailable: "デバイスの鍵を利用できません。キーチェーンへのアクセスを許可するか、永続鍵を設定してください。",
keyRecoveryHint: "このデータディレクトリで使用していた元のキーを復元するか、既存データの暗号化に使用した同じ外部キーを設定してください。新しいキーで置き換えないでください。",
progress: "バックアップを作成し、移行と検証を実行しています。DBXを終了しないでください…",
failed: "移行を完了できませんでした。元のデータは保持されています。",
cleanupFailed: "移行は完了しましたが、バックアップを削除できませんでした。",
retryCleanup: "バックアップ削除を再試行",
statusFailed: "移行状態を確認できません。",
errorCode: "エラーコード:{code}",
retryStatus: "状態確認を再試行",
start: "移行を開始",
running: "移行中…",
retryMigration: "移行を再試行",
enter: "DBXを開始",
deleteBackup: "バックアップを削除",
exportDiagnostic: "診断情報をエクスポート",
openDataDir: "データディレクトリを開く",
exit: "DBXを終了",
confirmDelete: "移行バックアップを削除しますか?削除後はロールバックに使用できません。",
confirm: "削除",
cancel: "キャンセル",
errors: {
persistentKeyAdvice: "永続鍵を設定してから状態確認を再試行してください。",
cleanupAdvice: "バックアップディレクトリの権限を確認して削除を再試行してください。移行済みデータは使用できます。",
genericAdvice: "問題を解決してから再試行してください。元のデータは保持されています。",
keyProviderAdvice: "キーチェーンへのアクセスを許可するか、永続鍵ファイルを設定してください。",
backupAdvice: "データディレクトリの書き込み権限と空き容量を確認してください。",
jsonAdvice: "表示されたファイルを修復してから移行を再試行してください。",
verifyAdvice: "バックアップを保持したまま再試行してください。検証が成功するまで削除しないでください。",
},
checking: "ローカルデータのセキュリティ状態を確認しています…",
authFailed: "DBXの認証状態を確認できません。",
retry: "再試行",
launching: "DBX を起動しています…",
openBackupDir: "バックアップの場所を開く",
},
solrAdmin: {
title: "Solr 管理",
refresh: "更新",
+65
View File
@@ -9809,6 +9809,71 @@ export default withEnglishFallback({
titleLabel: "제목 (선택)",
titlePlaceholder: "예: 쿼리 결과",
},
migration: {
eyebrow: "업그레이드 후 첫 실행",
title: "데이터 보안 업그레이드",
stepsLabel: "마이그레이션 단계",
language: "언어",
step1: "검토 및 확인",
step2: "데이터 마이그레이션",
step3: "완료 및 백업",
successTitle: "마이그레이션 완료",
connectionCountLabel: "연결:",
secretCountLabel: "보호 항목:",
backupRequiredLabel: "백업:",
keyProviderLabel: "키 상태:",
ready: "준비됨",
pending: "설정 필요",
intro: "DBX를 업그레이드하고 다시 시작했습니다. 이번 업그레이드는 로컬 데이터 보안을 강화합니다. 한 번의 처리를 완료하면 연결을 계속 사용할 수 있습니다.",
whyTitle: "이번 업그레이드는 무엇을 하나요?",
whyBody: "새 버전은 연결, 플러그인 및 기타 민감한 설정을 더 강력한 암호화로 보호합니다. 이 처리는 업그레이드 후 한 번만 실행됩니다.",
whyItem1: "시작 전에 백업을 만들어 필요할 때 복구할 수 있습니다.",
whyItem2: "완료 전에 결과를 확인하여 업그레이드 성공을 검증합니다.",
connectionCount: "연결: {count}",
secretCount: "보호 항목: {count}",
backupPath: "백업:",
dataDir: "데이터 디렉터리:",
backupDir: "백업 디렉터리:",
yes: "예",
no: "아니요",
keyFileStatus: "키 파일 설정: {configured}; 읽기 가능: {readable}",
persistentKeyStatus: "영구 키 제공자: {available}",
keyUnavailable: "장치 키를 사용할 수 없습니다. 키체인 접근을 허용하거나 영구 키를 설정하세요.",
keyRecoveryHint: "이 데이터 디렉터리에서 사용한 원래 키를 복원하거나 기존 데이터를 암호화한 동일한 외부 키를 구성하세요. 새 키로 교체하지 마세요.",
progress: "백업을 만들고 마이그레이션 및 확인을 진행 중입니다…",
failed: "마이그레이션을 완료하지 못했습니다. 원본 데이터는 보존되었습니다.",
cleanupFailed: "마이그레이션은 완료되었지만 백업을 삭제하지 못했습니다.",
retryCleanup: "백업 삭제 재시도",
statusFailed: "마이그레이션 상태를 확인할 수 없습니다.",
errorCode: "오류 코드: {code}",
retryStatus: "상태 확인 재시도",
start: "마이그레이션 시작",
running: "마이그레이션 중…",
retryMigration: "마이그레이션 재시도",
enter: "DBX 시작",
deleteBackup: "백업 삭제",
exportDiagnostic: "진단 정보 내보내기",
openDataDir: "데이터 디렉터리 열기",
exit: "DBX 종료",
confirmDelete: "마이그레이션 백업을 삭제할까요?",
confirm: "삭제",
cancel: "취소",
checking: "로컬 데이터 보안 상태 확인 중…",
authFailed: "DBX 인증 상태를 확인할 수 없습니다.",
retry: "재시도",
successIntro: "마이그레이션과 확인이 완료되었습니다. 백업을 삭제하기 전에 연결을 확인하세요.",
errors: {
persistentKeyAdvice: "영구 키를 설정한 후 상태 확인을 다시 시도하세요.",
cleanupAdvice: "백업 디렉터리 권한을 확인한 후 다시 시도하세요.",
genericAdvice: "문제를 해결한 후 다시 시도하세요.",
keyProviderAdvice: "키체인 접근을 허용하거나 영구 키 파일을 설정하세요.",
backupAdvice: "데이터 디렉터리의 쓰기 권한과 여유 공간을 확인하세요.",
jsonAdvice: "표시된 파일을 수정한 후 다시 시도하세요.",
verifyAdvice: "확인이 성공할 때까지 백업을 보관하세요.",
},
launching: "DBX를 시작하는 중…",
openBackupDir: "백업 위치 열기",
},
solrAdmin: {
title: "Solr 관리",
refresh: "새로 고침",
+65
View File
@@ -10353,6 +10353,71 @@ export default withEnglishFallback({
titleLabel: "Título (opcional)",
titlePlaceholder: "ex.: Resultado da consulta",
},
migration: {
eyebrow: "Primeiro início após a atualização",
title: "Atualização da segurança dos dados",
stepsLabel: "Etapas da migração",
language: "Idioma",
step1: "Revisar e confirmar",
step2: "Migrar dados",
step3: "Concluir e fazer backup",
successTitle: "Migração concluída",
connectionCountLabel: "Conexões:",
secretCountLabel: "Itens protegidos:",
backupRequiredLabel: "Backup:",
keyProviderLabel: "Estado da chave:",
ready: "pronta",
pending: "pendente",
intro: "Você acabou de atualizar e reiniciar o DBX. Esta atualização melhora a segurança dos dados locais. Conclua esta etapa única para continuar usando suas conexões.",
whyTitle: "O que esta atualização faz?",
whyBody: "A nova versão usa criptografia mais forte para proteger conexões, plugins e outras configurações sensíveis. Esta etapa é executada apenas uma vez.",
whyItem1: "Um backup é criado antes do início para permitir a recuperação dos dados.",
whyItem2: "O resultado é verificado antes da conclusão.",
connectionCount: "Conexões: {count}",
secretCount: "Itens protegidos: {count}",
backupPath: "Backup:",
dataDir: "Diretório de dados:",
backupDir: "Diretório de backup:",
yes: "sim",
no: "não",
keyFileStatus: "Arquivo de chave configurado: {configured}; legível: {readable}",
persistentKeyStatus: "Provedor de chave persistente: {available}",
keyUnavailable: "A chave do dispositivo não está disponível. Permita o acesso ao chaveiro ou configure uma chave persistente.",
keyRecoveryHint: "Restaure a chave original usada por este diretório de dados ou configure a mesma chave externa que criptografou os dados existentes. Não a substitua por uma chave nova.",
progress: "Criando backup, migrando e verificando…",
failed: "A migração não foi concluída. Os dados originais foram preservados.",
cleanupFailed: "A migração terminou, mas o backup não pôde ser excluído.",
retryCleanup: "Tentar excluir novamente",
statusFailed: "Não foi possível verificar o estado da migração.",
errorCode: "Código de erro: {code}",
retryStatus: "Tentar verificar novamente",
start: "Iniciar migração",
running: "Migrando…",
retryMigration: "Tentar migração novamente",
enter: "Entrar no DBX",
deleteBackup: "Excluir backup",
exportDiagnostic: "Exportar diagnóstico",
openDataDir: "Abrir diretório de dados",
exit: "Sair do DBX",
confirmDelete: "Excluir o backup da migração?",
confirm: "Excluir",
cancel: "Cancelar",
checking: "Verificando a segurança dos dados locais…",
authFailed: "Não foi possível verificar a autenticação do DBX.",
retry: "Tentar novamente",
successIntro: "A migração e a verificação foram concluídas. Confira as conexões antes de excluir o backup.",
errors: {
persistentKeyAdvice: "Configure uma chave persistente e verifique o estado novamente.",
cleanupAdvice: "Verifique as permissões do diretório e tente novamente.",
genericAdvice: "Resolva o problema e tente novamente.",
keyProviderAdvice: "Permita o acesso ao chaveiro ou configure um arquivo de chave persistente.",
backupAdvice: "Verifique as permissões de gravação e o espaço disponível.",
jsonAdvice: "Corrija o arquivo indicado e tente novamente.",
verifyAdvice: "Mantenha o backup até a verificação ser concluída.",
},
launching: "Iniciando o DBX…",
openBackupDir: "Abrir local do backup",
},
solrAdmin: {
title: "Administração do Solr",
refresh: "Atualizar",
+63
View File
@@ -10140,4 +10140,67 @@ export default withEnglishFallback({
titleLabel: "Başlık (isteğe bağlı)",
titlePlaceholder: "örn. Sorgu sonucu",
},
migration: {
eyebrow: "Güncelleme sonrası ilk açılış",
title: "Veri güvenliği güncellemesi",
stepsLabel: "Taşıma adımları",
language: "Dil",
step1: "İncele ve onayla",
step2: "Verileri taşı",
step3: "Tamamla ve yedekle",
successTitle: "Taşıma tamamlandı",
connectionCountLabel: "Bağlantılar:",
secretCountLabel: "Korunan öğeler:",
backupRequiredLabel: "Yedek:",
keyProviderLabel: "Anahtar durumu:",
ready: "hazır",
pending: "ayarlanmalı",
intro: "DBX'i güncellediniz ve yeniden başlattınız. Bu güncelleme yerel veri güvenliğini artırır. Bağlantıları kullanmaya devam etmek için bu tek seferlik işlemi tamamlayın.",
whyTitle: "Bu güncelleme ne yapar?",
whyBody: "Yeni sürüm bağlantıları, eklentileri ve diğer hassas ayarları daha güçlü şifrelemeyle korur. Bu işlem yalnızca bir kez çalışır.",
whyItem1: "Başlamadan önce verilerin kurtarılabilmesi için yedek oluşturulur.",
whyItem2: "Tamamlanmadan önce sonuç doğrulanır.",
connectionCount: "Bağlantılar: {count}",
secretCount: "Korunan öğeler: {count}",
backupPath: "Yedek:",
dataDir: "Veri dizini:",
backupDir: "Yedek dizini:",
yes: "evet",
no: "hayır",
keyFileStatus: "Anahtar dosyası yapılandırıldı: {configured}; okunabilir: {readable}",
persistentKeyStatus: "Kalıcı anahtar sağlayıcısı: {available}",
keyUnavailable: "Cihaz anahtarı kullanılamıyor. Anahtarlık erişimine izin verin veya kalıcı bir anahtar yapılandırın.",
keyRecoveryHint: "Bu veri dizini için kullanılan özgün anahtarı geri yükleyin veya mevcut verileri şifreleyen aynı harici anahtarı yapılandırın. Yeni bir anahtarla değiştirmeyin.",
progress: "Yedek oluşturuluyor, taşınıyor ve doğrulanıyor…",
failed: "Taşıma tamamlanamadı. Orijinal veriler korundu.",
cleanupFailed: "Taşıma tamamlandı ancak yedek silinemedi.",
retryCleanup: "Silmeyi yeniden dene",
statusFailed: "Taşıma durumu denetlenemedi.",
errorCode: "Hata kodu: {code}",
retryStatus: "Durumu yeniden dene",
start: "Taşımayı başlat",
running: "Taşınıyor…",
retryMigration: "Taşımayı yeniden dene",
enter: "DBX'e gir",
deleteBackup: "Yedeği sil",
exportDiagnostic: "Tanılamayı dışa aktar",
openDataDir: "Veri dizinini aç",
exit: "DBX'ten çık",
confirmDelete: "Taşıma yedeği silinsin mi?",
confirm: "Sil",
cancel: "İptal",
checking: "Yerel veri güvenliği denetleniyor…",
authFailed: "DBX kimlik doğrulaması denetlenemedi.",
retry: "Yeniden dene",
successIntro: "Taşıma ve doğrulama tamamlandı. Yedeği silmeden önce bağlantıları kontrol edin.",
errors: {
persistentKeyAdvice: "Kalıcı anahtar yapılandırın ve durumu yeniden denetleyin.",
cleanupAdvice: "Yedek dizini izinlerini kontrol edip yeniden deneyin.",
genericAdvice: "Sorunu düzeltip yeniden deneyin.",
keyProviderAdvice: "Anahtarlık erişimine izin verin veya kalıcı anahtar dosyası yapılandırın.",
backupAdvice: "Veri dizininin yazılabilir olduğunu ve yeterli alan bulunduğunu kontrol edin.",
jsonAdvice: "Belirtilen dosyayı düzeltip yeniden deneyin.",
verifyAdvice: "Doğrulama başarılı olana kadar yedeği saklayın.",
},
},
});
+65
View File
@@ -10841,4 +10841,69 @@ export default withEnglishFallback({
titleLabel: "标题(可选)",
titlePlaceholder: "例如:查询结果",
},
migration: {
eyebrow: "升级后的首次启动",
title: "数据安全升级",
stepsLabel: "迁移步骤",
language: "界面语言",
step1: "检查与确认",
step2: "执行迁移",
step3: "完成与备份",
successTitle: "迁移完成",
connectionCountLabel: "连接:",
secretCountLabel: "敏感项:",
backupRequiredLabel: "需要备份:",
keyProviderLabel: "密钥状态:",
ready: "已就绪",
pending: "待配置",
intro: "你刚刚完成了 DBX 升级并重新启动应用。本次升级会进一步提升本地数据安全,完成一次处理后即可继续使用连接。",
whyTitle: "本次升级会带来什么?",
whyBody: "新版本将使用更安全的加密方式保护连接、插件和其他敏感配置。首次启动需要完成一次数据处理,之后不会重复显示此页面。",
whyItem1: "开始前会自动创建备份,确保数据可恢复。",
whyItem2: "完成后会验证数据可以正常读取,确认安全升级成功。",
successIntro: "迁移已完成且密文验证通过。确认连接正常后再删除备份。",
connectionCount: "连接:{count} 个",
secretCount: "待迁移敏感项:{count} 个",
backupPath: "备份位置:",
dataDir: "数据目录:",
backupDir: "备份目录:",
keyFileStatus: "密钥文件已配置:{configured};可读取:{readable}",
persistentKeyStatus: "持久化密钥提供者可用:{available}",
yes: "是",
no: "否",
keyUnavailable: "当前设备密钥不可用,请允许钥匙串访问或配置持久化密钥。",
keyRecoveryHint: "请恢复该数据目录原有的密钥,或配置用于加密现有数据的同一外部密钥。不要使用新密钥替换原密钥。",
progress: "正在创建备份、迁移并验证,请勿关闭 DBX…",
failed: "迁移没有完成,原始数据已保留。",
cleanupFailed: "迁移已完成,但备份删除失败。",
retryCleanup: "重试删除备份",
statusFailed: "无法检查迁移状态。",
errorCode: "错误代码:{code}",
retryStatus: "重试状态检查",
start: "开始迁移",
running: "迁移中…",
retryMigration: "重试迁移",
enter: "进入 DBX",
launching: "正在启动 DBX…",
deleteBackup: "删除备份",
exportDiagnostic: "导出脱敏诊断",
openDataDir: "打开数据目录",
openBackupDir: "打开备份位置",
exit: "退出 DBX",
confirmDelete: "确认删除这份升级备份?连接和密码已经保存在本机,删除备份不会删除你的连接列表或密码,但之后无法再用它回滚本次升级。",
confirm: "确认删除",
cancel: "取消",
errors: {
persistentKeyAdvice: "请配置持久化的 DBX_SECRET_KEY_FILE 或 DBX_SECRET_KEY,然后重新检查状态。",
cleanupAdvice: "请检查备份目录权限后重试删除备份,迁移后的数据可正常使用。",
genericAdvice: "请修复上述问题后重试,原始数据仍然保留。",
keyProviderAdvice: "请允许 DBX 访问钥匙串,或配置持久化的 DBX_SECRET_KEY_FILE。",
backupAdvice: "请确认数据目录可写且有足够的剩余空间。",
jsonAdvice: "请修复提示的历史 JSON 文件后重试迁移。",
verifyAdvice: "请保留备份并重试,验证成功前不要删除迁移备份。",
},
checking: "正在检查本地数据安全状态…",
authFailed: "无法检查 DBX 登录状态。",
retry: "重试",
},
});
+65
View File
@@ -10348,6 +10348,71 @@ export default withEnglishFallback({
titleLabel: "標題(可選)",
titlePlaceholder: "例如:查詢結果",
},
migration: {
eyebrow: "升級後首次啟動",
title: "資料安全升級",
stepsLabel: "遷移步驟",
language: "介面語言",
step1: "檢查與確認",
step2: "執行遷移",
step3: "完成與備份",
successTitle: "遷移完成",
connectionCountLabel: "連線:",
secretCountLabel: "受保護項目:",
backupRequiredLabel: "需要備份:",
keyProviderLabel: "金鑰狀態:",
ready: "已就緒",
pending: "待設定",
intro: "你剛完成 DBX 升級並重新啟動應用程式。本次升級會提升本機資料安全,完成一次處理後即可繼續使用連線。",
whyTitle: "本次升級會帶來什麼?",
whyBody: "新版本會使用更安全的加密方式保護連線、外掛程式及其他敏感設定。這項處理只會執行一次。",
whyItem1: "開始前會自動建立備份,確保資料可以復原。",
whyItem2: "完成前會驗證資料可以正常讀取。",
connectionCount: "連線:{count} 個",
secretCount: "受保護項目:{count} 個",
backupPath: "備份位置:",
dataDir: "資料目錄:",
backupDir: "備份目錄:",
yes: "是",
no: "否",
keyFileStatus: "金鑰檔案已設定:{configured};可讀取:{readable}",
persistentKeyStatus: "持久化金鑰提供者:{available}",
keyUnavailable: "目前無法使用裝置金鑰,請允許存取鑰匙圈或設定持久化金鑰。",
keyRecoveryHint: "請還原此資料目錄原本使用的金鑰,或設定用於加密現有資料的相同外部金鑰。請勿使用新金鑰取代原金鑰。",
progress: "正在建立備份、執行遷移並驗證…",
failed: "遷移未完成,原始資料已保留。",
cleanupFailed: "遷移已完成,但無法刪除備份。",
retryCleanup: "重試刪除備份",
statusFailed: "無法檢查遷移狀態。",
errorCode: "錯誤代碼:{code}",
retryStatus: "重試狀態檢查",
start: "開始遷移",
running: "遷移中…",
retryMigration: "重試遷移",
enter: "進入 DBX",
deleteBackup: "刪除備份",
exportDiagnostic: "匯出診斷資訊",
openDataDir: "開啟資料目錄",
openBackupDir: "開啟備份位置",
exit: "退出 DBX",
confirmDelete: "確定要刪除這份升級備份嗎?連線與密碼已保存在本機,刪除備份不會刪除你的連線清單或密碼,但之後將無法再用它回復本次升級。",
confirm: "刪除",
cancel: "取消",
checking: "正在檢查本機資料安全狀態…",
authFailed: "無法檢查 DBX 驗證狀態。",
retry: "重試",
successIntro: "遷移與驗證已完成。刪除備份前請先確認連線正常。",
errors: {
persistentKeyAdvice: "請設定持久化金鑰後重新檢查狀態。",
cleanupAdvice: "請檢查備份目錄權限後重試。",
genericAdvice: "請修正問題後重試。",
keyProviderAdvice: "請允許存取鑰匙圈或設定持久化金鑰檔案。",
backupAdvice: "請確認資料目錄可寫入且有足夠空間。",
jsonAdvice: "請修復提示的檔案後重試。",
verifyAdvice: "驗證成功前請保留備份。",
},
launching: "正在啟動 DBX…",
},
solrAdmin: {
title: "Solr 管理",
refresh: "重新整理",
@@ -61,18 +61,27 @@ describe("WebDAV sync HTTP API", () => {
expect(lastCall(fetchMock)).toEqual({ url: "/api/cloud-sync/webdav/forget-sync-secrets-passphrase", body: {} });
const editorSettings = { theme: "dark" };
await webdavSyncUpload(config, editorSettings, "sync-passphrase");
await webdavSyncUpload(config, editorSettings, "sync-passphrase", true);
expect(lastCall(fetchMock)).toEqual({
url: "/api/cloud-sync/webdav/upload",
body: { config, editorSettings, secretsPassphrase: "sync-passphrase" },
body: { config, editorSettings, secretsPassphrase: "sync-passphrase", includeSecrets: true },
});
await webdavSyncDownload(config, "sync-passphrase");
await webdavSyncDownload(config, "sync-passphrase", true);
expect(lastCall(fetchMock)).toEqual({
url: "/api/cloud-sync/webdav/download",
body: { config, secretsPassphrase: "sync-passphrase" },
body: { config, secretsPassphrase: "sync-passphrase", restoreSecrets: true },
});
});
it("accepts the migration cleanup endpoint's empty 204 response", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const { migrationCleanupBackups } = await import("@/lib/backend/http");
await expect(migrationCleanupBackups()).resolves.toBeUndefined();
expect(lastCall(fetchMock)).toEqual({ url: "/api/migration/cleanup-backups", body: {} });
});
});
describe("GitLab snippet sync HTTP API", () => {
+4
View File
@@ -385,6 +385,10 @@ export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions"
export const loadTransferTaskLibrary = forward("loadTransferTaskLibrary");
export const saveTransferTaskLibrary = forward("saveTransferTaskLibrary");
export const webdavSyncTest = forward("webdavSyncTest");
export const migrationStatus = forward("migrationStatus");
export const migrationStart = forward("migrationStart");
export const migrationRetry = forward("migrationRetry");
export const migrationCleanupBackups = forward("migrationCleanupBackups");
export const webdavPasswordStatus = forward("webdavPasswordStatus");
export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword");
export const forgetWebdavSavedPassword = forward("forgetWebdavSavedPassword");
+11 -3
View File
@@ -175,6 +175,12 @@ import type {
} from "@/lib/backend/tauri";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
import type { MigrationPreflight, MigrationReport } from "./migration";
export type { MigrationPreflight, MigrationReport } from "./migration";
export const migrationStatus = (): Promise<MigrationPreflight> => get("/api/migration/status");
export const migrationStart = (): Promise<MigrationReport> => post("/api/migration/start", {});
export const migrationRetry = (): Promise<MigrationReport> => post("/api/migration/retry", {});
export const migrationCleanupBackups = (): Promise<void> => post("/api/migration/cleanup-backups", {});
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import type {
DataGridColumnDistinctValuesSqlOptions,
@@ -316,6 +322,7 @@ async function post<T>(url: string, body: unknown): Promise<T> {
body: JSON.stringify(body),
});
if (!res.ok) throw await backendResponseError(res);
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -2380,16 +2387,17 @@ export async function forgetWebdavSyncSecretsPassphrase(): Promise<void> {
return post("/api/cloud-sync/webdav/forget-sync-secrets-passphrase", {});
}
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<WebDavSyncSummary> {
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string, includeSecrets = false): Promise<WebDavSyncSummary> {
return post("/api/cloud-sync/webdav/upload", {
config,
editorSettings,
secretsPassphrase,
includeSecrets,
});
}
export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string): Promise<WebDavDownloadResult> {
return post("/api/cloud-sync/webdav/download", { config, secretsPassphrase });
export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string, restoreSecrets = true): Promise<WebDavDownloadResult> {
return post("/api/cloud-sync/webdav/download", { config, secretsPassphrase, restoreSecrets });
}
export async function snippetSyncTest(config: SnippetSyncConfig): Promise<void> {
+35
View File
@@ -0,0 +1,35 @@
export type MigrationState = "pending" | "running" | "succeeded" | "failed" | "not_required";
export interface MigrationPreflight {
migrationId: string;
state: MigrationState;
needsMigration: boolean;
keyProviderAvailable: boolean;
keyCreationAllowed?: boolean;
databasePlaintextCount: number;
connectionCount: number;
pluginSecretCount: number;
aiSecretCount: number;
tunnelSecretCount: number;
syncCredentialCount: number;
legacyJsonFiles: { name: string; exists: boolean; bytes: number }[];
backupRequired: boolean;
backupPath?: string | null;
errorMessage?: string | null;
errorCode?: string | null;
dataDir?: string;
backupDir?: string;
keyFileConfigured?: boolean;
keyFileReadable?: boolean;
persistentKeyConfigured?: boolean;
keySource?: "explicit_file" | "explicit_env" | "managed_data_dir" | "platform_store" | "unavailable" | string;
}
export interface MigrationReport {
migrationId: string;
state: MigrationState;
backupPath?: string | null;
databasePlaintextCount: number;
legacyJsonFiles: string[];
verifiedSecretCount: number;
errorCode?: string | null;
errorMessage?: string | null;
}
+11 -3
View File
@@ -12,6 +12,13 @@ function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>
assertUpdateAllowsCommand(command);
return tauriInvoke<T>(command, args);
}
import type { MigrationPreflight, MigrationReport } from "./migration";
export type { MigrationPreflight, MigrationReport } from "./migration";
export const migrationStatus = (): Promise<MigrationPreflight> => invoke("migration_status");
export const migrationStart = (): Promise<MigrationReport> => invoke("migration_start");
export const migrationRetry = (): Promise<MigrationReport> => invoke("migration_retry");
export const migrationCleanupBackups = (): Promise<void> => invoke("migration_cleanup_backups");
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@@ -967,16 +974,17 @@ export async function forgetWebdavSyncSecretsPassphrase(): Promise<void> {
return invoke("forget_webdav_sync_secrets_passphrase");
}
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<WebDavSyncSummary> {
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string, includeSecrets = false): Promise<WebDavSyncSummary> {
return invoke("webdav_sync_upload", {
config,
editorSettings,
secretsPassphrase,
includeSecrets,
});
}
export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string): Promise<WebDavDownloadResult> {
return invoke("webdav_sync_download", { config, secretsPassphrase });
export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string, restoreSecrets = true): Promise<WebDavDownloadResult> {
return invoke("webdav_sync_download", { config, secretsPassphrase, restoreSecrets });
}
export async function snippetSyncTest(config: SnippetSyncConfig): Promise<void> {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database";
import { buildConnectionConfigBundle, parseConnectionConfigObject, selectConnectionConfigBundle, snapshotConnectionsForExport } from "./connectionConfigTransfer";
import { buildConnectionConfigBundle, parseConnectionConfigObject, prepareConnectionConfigImport, scrubConnectionForPlaintextExport, scrubTunnelProfileForPlaintextExport, selectConnectionConfigBundle, snapshotConnectionsForExport } from "./connectionConfigTransfer";
function conn(id: string, name: string, extras: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
@@ -83,11 +83,76 @@ describe("connectionConfigTransfer", () => {
});
});
it("keeps stable ids and removes secrets from plaintext exports", () => {
const exported = scrubConnectionForPlaintextExport(
conn("stable", "A", {
password: "db-password",
init_script: "CREATE USER secret",
connection_string: "postgres://user:password@host/db",
url_params: "applicationName=dbx&PASSWORD=url-password&sslmode=require",
connection_secrets: { api_token: "plugin-token" },
transport_layers: [{ type: "ssh", id: "layer", host: "bastion", port: 22, user: "root", password: "ssh-password", key_passphrase: "key-password" }],
external_config: { auth: { password: "nested-password" }, safe: "value" },
}),
);
expect(exported.id).toBe("stable");
expect(exported.password).toBe("");
expect(exported.connection_secrets).toEqual({});
expect(JSON.stringify(exported)).not.toContain("db-password");
expect(JSON.stringify(exported)).not.toContain("nested-password");
expect(JSON.stringify(exported)).not.toContain("plugin-token");
expect(exported.url_params).toBe("applicationName=dbx&PASSWORD=&sslmode=require");
expect(JSON.stringify(exported)).not.toContain("url-password");
expect(scrubTunnelProfileForPlaintextExport({ type: "http_tunnel", id: "t", url: "https://example", token: "token" })).toMatchObject({ id: "t", token: "" });
});
it("redacts structured API key auth without removing unrelated values or mutating the source", () => {
const source = conn("mq", "MQ", {
external_config: {
auth: { kind: "apiKey", header: "X-Api-Key", value: "api-key-secret" },
value: "public-root",
nested: [{ auth: { kind: "apiKey", header: "Authorization", value: "nested-api-key" } }, { auth: { kind: "basic", username: "user", password: "basic-password", value: "public-auth" } }, { kind: "apiKey", value: "public-non-auth" }],
},
});
const exported = scrubConnectionForPlaintextExport(source);
expect(exported.external_config).toEqual({
auth: { kind: "apiKey", header: "X-Api-Key", value: "" },
value: "public-root",
nested: [{ auth: { kind: "apiKey", header: "Authorization", value: "" } }, { auth: { kind: "basic", username: "user", password: "", value: "public-auth" } }, { kind: "apiKey", value: "public-non-auth" }],
});
expect(source.external_config).toMatchObject({ auth: { value: "api-key-secret" } });
expect(JSON.stringify(exported)).not.toContain("api-key-secret");
expect(JSON.stringify(exported)).not.toContain("nested-api-key");
expect(JSON.stringify(exported)).not.toContain("basic-password");
});
it("parses legacy arrays and dbx-config payloads without inventing a layout", () => {
expect(parseConnectionConfigObject([conn("a", "A")])).toEqual({ connections: [conn("a", "A")] });
expect(parseConnectionConfigObject({ format: "dbx-config", connections: [conn("a", "A")] })).toEqual({ connections: [conn("a", "A")] });
});
it("remaps ordinary import IDs on collision and updates tunnel/layout references", () => {
const imported = prepareConnectionConfigImport(
{
connections: [conn("same", "Imported", { transport_layers: [{ type: "ssh", id: "layer", host: "", port: 22, user: "", profile_id: "tp" }] })],
tunnelProfiles: [{ ...tunnel1, id: "tp" }],
layout: { groups: [], order: [{ type: "connection", id: "same" }] },
},
["same"],
["tp"],
(() => {
let n = 0;
return () => `new-${++n}`;
})(),
);
expect(imported.connections[0].id).toBe("new-1");
expect(imported.connections[0].transport_layers?.[0].profile_id).toBe("new-2");
expect(imported.tunnelProfiles?.[0].id).toBe("new-2");
expect(imported.layout?.order[0]).toEqual({ type: "connection", id: "new-1" });
});
it("selects a preview subset without mutating the original preview", () => {
const preview = {
connections: [conn("a", "A"), conn("b", "B"), conn("c", "C")],
@@ -1,5 +1,5 @@
import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database";
import { filterSidebarLayoutByConnectionIds as filterLayoutByConnectionIds } from "@/lib/sidebar/sidebarLayout";
import { filterSidebarLayoutByConnectionIds as filterLayoutByConnectionIds, remapSidebarLayoutConnectionIds } from "@/lib/sidebar/sidebarLayout";
export type ConnectionExportProtection = { mode: "encrypted"; passphrase: string } | { mode: "plaintext" };
@@ -60,6 +60,74 @@ export function snapshotConnectionsForExport(connections: ConnectionConfig[], op
}));
}
/** Remove every credential-bearing field before creating a plaintext bundle. */
export function scrubConnectionForPlaintextExport(connection: ConnectionConfig): ConnectionConfig {
const scrubbed: ConnectionConfig = JSON.parse(JSON.stringify(connection)) as ConnectionConfig;
scrubbed.password = "";
scrubbed.url_params = scrubUrlParams(scrubbed.url_params);
scrubbed.init_script = undefined;
scrubbed.connection_string = undefined;
scrubbed.redis_sentinel_password = "";
scrubbed.connection_secrets = {};
scrubbed.transport_layers = (scrubbed.transport_layers ?? []).map((layer) => {
const copy = { ...layer } as typeof layer;
if (copy.type === "ssh") {
copy.password = "";
copy.key_passphrase = "";
} else if (copy.type === "proxy") {
copy.password = "";
} else {
copy.token = "";
}
return copy;
});
scrubbed.external_config = scrubExternalConfig(scrubbed.external_config);
return scrubbed;
}
export function scrubTunnelProfileForPlaintextExport(profile: TunnelProfile): TunnelProfile {
const scrubbed = { ...profile };
if (scrubbed.type === "ssh") {
scrubbed.password = "";
scrubbed.key_passphrase = "";
} else if (scrubbed.type === "proxy") {
scrubbed.password = "";
} else {
scrubbed.token = "";
}
return scrubbed;
}
function scrubExternalConfig(value: unknown): unknown {
if (Array.isArray(value)) return value.map(scrubExternalConfig);
if (!value || typeof value !== "object") return value;
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (/password|passphrase|secret|token|api[_-]?key|clientsecret|accesstoken|privatekey/i.test(key)) {
output[key] = typeof child === "string" ? "" : null;
} else if (key === "auth" && child && typeof child === "object" && !Array.isArray(child) && (child as Record<string, unknown>).kind === "apiKey") {
output[key] = { ...(scrubExternalConfig(child) as Record<string, unknown>), value: "" };
} else {
output[key] = scrubExternalConfig(child);
}
}
return output;
}
function scrubUrlParams(value: string | undefined): string | undefined {
if (typeof value !== "string") return value;
return value
.split("&")
.map((part) => {
const separator = part.indexOf("=");
if (separator < 0) return part;
const key = part.slice(0, separator);
const normalized = key.trim().toLowerCase().replace(/[_-]/g, "");
return /password|passphrase|secret|token|apikey|privatekey|clientsecret/.test(normalized) ? key + "=" : part;
})
.join("&");
}
export function buildConnectionConfigBundle(connections: ConnectionConfig[], layout: SidebarLayout | null | undefined, tunnelProfiles: TunnelProfile[], selectedIds?: Iterable<string>): ConnectionConfigBundle {
const selectedConnections = filterConnectionsByIds(connections, selectedIds);
const selectedConnectionIds = selectedConnections.map((connection) => connection.id);
@@ -110,3 +178,84 @@ export function selectConnectionConfigBundle(bundle: ConnectionConfigBundle, sel
tunnelProfiles: filterTunnelProfilesByIds(bundle.tunnelProfiles ?? [], referencedTunnelProfileIds(selectedConnections)),
};
}
/** Ordinary file imports deduplicate connections while keeping cloud sync's ID merge policy separate. */
export function prepareConnectionConfigImport(bundle: ConnectionConfigBundle, existingConnections: Iterable<Pick<ConnectionConfig, "id" | "name" | "host" | "port"> | string>, existingTunnelProfileIds: Iterable<string>, createId: () => string): ConnectionConfigBundle {
const existingConnectionList = Array.from(existingConnections);
const existingConnectionIds = new Set(existingConnectionList.map((connection) => (typeof connection === "string" ? connection : connection.id)));
const existingByIdentity = new Map<string, string>();
for (const connection of existingConnectionList) {
if (typeof connection === "string") continue;
existingByIdentity.set(connectionIdentity(connection), connection.id);
}
const sourceConnections = bundle.connections.map((connection, index) => ({
...connection,
// Anonymous legacy entries need a stable key for layout/reference remapping.
id: connection.id?.trim() || `__dbx_import_${index}`,
}));
const connectionIds = new Map<string, string>();
const importedConnections: ConnectionConfig[] = [];
const importedByIdentity = new Map<string, string>();
const seenSourceIds = new Set<string>();
const occupiedConnectionIds = new Set(existingConnectionIds);
for (const source of sourceConnections) {
if (seenSourceIds.has(source.id)) throw new Error("DUPLICATE_IMPORT_ID");
seenSourceIds.add(source.id);
const identity = connectionIdentity(source);
const duplicateId = existingByIdentity.get(identity) ?? importedByIdentity.get(identity);
if (duplicateId) {
connectionIds.set(source.id, duplicateId);
continue;
}
let targetId = createId();
while (!targetId || occupiedConnectionIds.has(targetId)) targetId = createId();
occupiedConnectionIds.add(targetId);
connectionIds.set(source.id, targetId);
importedByIdentity.set(identity, targetId);
importedConnections.push({ ...source, id: targetId });
}
const sourceProfiles = new Map<string, TunnelProfile>();
for (const profile of bundle.tunnelProfiles ?? []) {
const id = profile.id?.trim();
if (id && !sourceProfiles.has(id)) sourceProfiles.set(id, { ...profile, id });
}
const profileIds = new Map<string, string>();
const importedTunnelProfileIds = new Set<string>();
const occupiedProfileIds = new Set(existingTunnelProfileIds);
for (const connection of importedConnections) {
for (const layer of connection.transport_layers ?? []) {
const sourceProfileId = layer.profile_id;
if (!sourceProfileId || !sourceProfiles.has(sourceProfileId) || profileIds.has(sourceProfileId)) continue;
let targetId = sourceProfileId;
if (occupiedProfileIds.has(targetId)) {
targetId = createId();
while (!targetId || occupiedProfileIds.has(targetId)) targetId = createId();
}
occupiedProfileIds.add(targetId);
profileIds.set(sourceProfileId, targetId);
importedTunnelProfileIds.add(sourceProfileId);
}
}
return {
connections: importedConnections.map((connection) => ({
...connection,
transport_layers: connection.transport_layers?.map((layer) => ({
...layer,
// Never leave an unresolved source ID pointing at an unrelated local profile.
profile_id: layer.profile_id ? profileIds.get(layer.profile_id) : undefined,
})),
})),
tunnelProfiles: Array.from(importedTunnelProfileIds, (sourceId) => ({ ...sourceProfiles.get(sourceId)!, id: profileIds.get(sourceId)! })),
layout: bundle.layout ? filterSidebarLayoutByConnectionIds(remapSidebarLayoutConnectionIds(bundle.layout, connectionIds), connectionIds.values()) : undefined,
};
}
function connectionIdentity(connection: Pick<ConnectionConfig, "name" | "host" | "port">): string {
return JSON.stringify([connection.name, connection.host, connection.port]);
}
+1 -1
View File
@@ -77,7 +77,7 @@ function installGlobalInputAttrs() {
async function bootstrap() {
console.log("[STARTUP] frontend bootstrap begin");
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./App.vue")]);
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./StartupGate.vue")]);
console.log("[STARTUP] frontend modules loaded");
await loadSavedLocale();
console.log("[STARTUP] locale ready");
+21 -38
View File
@@ -56,7 +56,6 @@ import {
expandGroups as expandGroupsOp,
collapseAllGroups as collapseAllGroupsOp,
moveConnectionToGroup as moveConnectionToGroupOp,
remapSidebarLayoutConnectionIds,
mergeSidebarLayout,
reorderEntry as reorderEntryOp,
reorderEntries as reorderEntriesOp,
@@ -94,11 +93,11 @@ import {
} from "@/lib/table/tableVGroup";
import {
buildConnectionConfigBundle,
filterSidebarLayoutByConnectionIds,
filterTunnelProfilesByIds,
parseConnectionConfigObject,
referencedTunnelProfileIds,
prepareConnectionConfigImport,
selectConnectionConfigBundle,
scrubConnectionForPlaintextExport,
scrubTunnelProfileForPlaintextExport,
snapshotConnectionsForExport,
type ConnectionConfigBundle,
type ConnectionExportProtection,
@@ -9305,7 +9304,12 @@ export const useConnectionStore = defineStore("connection", () => {
const payload = await encryptConfig(json, protection.passphrase);
content = JSON.stringify(payload, null, 2);
} else {
content = JSON.stringify(exportData, null, 2);
const scrubbedData = {
...exportData,
connections: exportData.connections.map(scrubConnectionForPlaintextExport),
tunnelProfiles: exportData.tunnelProfiles?.map(scrubTunnelProfileForPlaintextExport),
};
content = JSON.stringify(scrubbedData, null, 2);
}
if (isTauriRuntime()) {
@@ -9545,40 +9549,19 @@ export const useConnectionStore = defineStore("connection", () => {
async function applyConnectionsImport(preview: ConnectionConfigBundle, selectedConnectionIds?: Iterable<string>): Promise<{ count: number; layout?: SidebarLayout }> {
const selected = selectConnectionConfigBundle(preview, selectedConnectionIds);
const imported = selected.connections.map((connection) => ({ ...connection }));
let importedLayout = selected.layout;
const importedConnections: ConnectionConfig[] = [];
const importedConnectionIdMap = new Map<string, string>();
for (const config of imported) {
const duplicate = [...connections.value, ...importedConnections].find((connection) => connection.name === config.name && connection.host === config.host && connection.port === config.port);
if (duplicate) {
if (typeof config.id === "string") importedConnectionIdMap.set(config.id, duplicate.id);
continue;
}
const importedId = config.id;
config.id = uuid();
if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id);
importedConnections.push(normalizeConnection(config));
const importedTunnelProfileStore = useTunnelProfileStore();
await importedTunnelProfileStore.init();
const imported = prepareConnectionConfigImport(
selected,
connections.value,
importedTunnelProfileStore.profiles.map((profile) => profile.id),
uuid,
);
if (imported.tunnelProfiles?.length) {
await importedTunnelProfileStore.saveProfiles([...importedTunnelProfileStore.profiles, ...imported.tunnelProfiles]);
}
const importedTunnelProfileIds = referencedTunnelProfileIds(importedConnections);
const importedTunnelProfiles = filterTunnelProfilesByIds(selected.tunnelProfiles ?? [], importedTunnelProfileIds);
if (importedTunnelProfiles.length) {
const importedTunnelProfileStore = useTunnelProfileStore();
await importedTunnelProfileStore.init();
const merged = [...importedTunnelProfileStore.profiles];
for (const profile of importedTunnelProfiles) {
if (!profile || typeof profile.id !== "string" || !profile.id) continue;
const index = merged.findIndex((existing) => existing.id === profile.id);
if (index >= 0) merged[index] = profile;
else merged.push(profile);
}
await importedTunnelProfileStore.saveProfiles(merged);
}
for (const connection of importedConnections) await addConnection(connection);
if (importedLayout) {
importedLayout = filterSidebarLayoutByConnectionIds(remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap), importedConnectionIdMap.values());
}
return { count: importedConnections.length, layout: importedLayout };
for (const connection of imported.connections) await addConnection(normalizeConnection(connection));
return { count: imported.connections.length, layout: imported.layout };
}
async function importConnectionsFromFile(content: string, passphrase: string | null, selectedConnectionIds?: Iterable<string>): Promise<{ count: number; layout?: SidebarLayout }> {
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import { useMigrationStore } from "./migrationStore";
import type { MigrationPreflight, MigrationReport } from "@/lib/backend/migration";
const pending = (): MigrationPreflight => ({
migrationId: "secret-store-v1",
state: "pending",
needsMigration: true,
keyProviderAvailable: true,
keyCreationAllowed: false,
databasePlaintextCount: 2,
connectionCount: 1,
pluginSecretCount: 0,
aiSecretCount: 0,
tunnelSecretCount: 0,
syncCredentialCount: 0,
legacyJsonFiles: [],
backupRequired: true,
});
const done = (): MigrationPreflight => ({ ...pending(), state: "succeeded", needsMigration: false, databasePlaintextCount: 0, backupPath: "/tmp/backup" });
const report: MigrationReport = { migrationId: "secret-store-v1", state: "succeeded", backupPath: "/tmp/backup", databasePlaintextCount: 2, legacyJsonFiles: [], verifiedSecretCount: 2 };
describe("migration store", () => {
it("keeps the wizard blocked until explicit entry after a successful migration", async () => {
const status = vi.fn().mockResolvedValueOnce(pending()).mockResolvedValue(done());
const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn().mockResolvedValue(report), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
expect(store.blocking.value).toBe(true);
await store.start();
expect(store.state.report).toEqual(report);
expect(store.blocking.value).toBe(true);
store.enter();
expect(store.blocking.value).toBe(false);
});
it("preserves the report and status when cleanup fails", async () => {
const cleanup = vi.fn().mockRejectedValue(new Error("failed"));
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn(), migrationRetry: vi.fn(), migrationCleanupBackups: cleanup });
await store.initialize();
store.state.report = report;
await store.cleanup();
expect(store.state.error).toBe("cleanupFailed");
expect(store.state.report).toEqual(report);
});
it("allows a completed migration with a retained backup to enter after restart", async () => {
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn(), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
expect(store.completed.value).toBe(true);
expect(store.blocking.value).toBe(false);
});
it("keeps status failures on the status retry path", async () => {
const status = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("unavailable"), { backendError: { code: "KEY_PROVIDER_UNAVAILABLE", detail: "safe detail" } }))
.mockResolvedValue(done());
const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn(), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
expect(store.state.error).toBe("statusFailed");
expect(store.state.errorCode).toBeNull();
expect(store.state.errorMessage).toBeNull();
await store.initialize();
expect(store.state.error).toBeNull();
expect(store.completed.value).toBe(true);
});
it("discards arbitrary transport exception values in state and diagnostics", async () => {
const secret = "password=DO_NOT_EXPOSE";
const failure = Object.assign(new Error(secret), { backendError: { code: secret, detail: secret } });
const status = vi.fn().mockRejectedValue(failure);
const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn().mockRejectedValue(failure), migrationRetry: vi.fn().mockRejectedValue(failure), migrationCleanupBackups: vi.fn().mockRejectedValue(failure) });
for (const action of [store.initialize, store.start, store.retry, store.cleanup]) {
await action();
expect(JSON.stringify(store.state)).not.toContain(secret);
expect(JSON.stringify(store.diagnostic())).not.toContain(secret);
}
});
it("shows safe preflight errors even when the migration is pending", async () => {
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue({ ...pending(), errorCode: "MISSING_PERSISTENT_KEY", errorMessage: "Configure a persistent key." }), migrationStart: vi.fn(), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
expect(store.state.error).toBe("migrationFailed");
expect(store.state.errorCode).toBe("MISSING_PERSISTENT_KEY");
expect(store.state.errorMessage).toBe("Configure a persistent key.");
});
it("does not show a failure for a managed key created by migration", async () => {
const store = useMigrationStore({
migrationStatus: vi.fn().mockResolvedValue({
...pending(),
keyProviderAvailable: false,
keyCreationAllowed: true,
errorCode: "MISSING_MANAGED_KEY",
errorMessage: "A managed data-directory secret key will be created when migration starts",
}),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
});
await store.initialize();
expect(store.state.error).toBeNull();
expect(store.state.errorCode).toBe("MISSING_MANAGED_KEY");
});
it("keeps completion through a failed cleanup and successful cleanup retry", async () => {
let finishCleanup!: () => void;
const cleanup = vi
.fn()
.mockRejectedValueOnce(new Error("password=secret"))
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve;
}),
);
const retry = vi.fn();
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn(), migrationRetry: retry, migrationCleanupBackups: cleanup });
await store.initialize();
await store.cleanup();
expect(store.state.error).toBe("cleanupFailed");
expect(store.state.step).toBe(3);
expect(store.completed.value).toBe(true);
expect(store.state.status?.backupPath).toBe("/tmp/backup");
const cleanupAttempt = store.cleanup();
expect(store.state.busy).toBe(true);
await store.cleanup();
expect(cleanup).toHaveBeenCalledTimes(2);
store.enter();
expect(store.state.entered).toBe(true);
finishCleanup();
await cleanupAttempt;
expect(store.state.busy).toBe(false);
expect(store.state.step).toBe(3);
expect(store.state.error).toBeNull();
expect(store.completed.value).toBe(true);
expect(store.state.status?.backupPath).toBeNull();
expect(retry).not.toHaveBeenCalled();
});
it("recovers when a lost action response is followed by verified completion", async () => {
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.start();
expect(store.state.step).toBe(3);
expect(store.state.error).toBeNull();
expect(store.state.busy).toBe(false);
});
it("retries a failed migration and completes without losing the error report", async () => {
const failed = { ...pending(), state: "failed" as const, errorCode: "BACKUP_FAILED", errorMessage: "Cannot create backup" };
const retry = vi.fn().mockResolvedValue(report);
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValueOnce(failed).mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("failure")), migrationRetry: retry, migrationCleanupBackups: vi.fn() });
await store.start();
expect(store.state.errorCode).toBe("BACKUP_FAILED");
expect(store.diagnostic().actionError).toBe("migrationFailed");
await store.retry();
expect(retry).toHaveBeenCalledOnce();
expect(store.state.step).toBe(3);
expect(store.state.error).toBeNull();
});
});
+111
View File
@@ -0,0 +1,111 @@
import { computed, reactive } from "vue";
import * as api from "@/lib/backend/api";
import type { MigrationPreflight, MigrationReport } from "@/lib/backend/migration";
type MigrationApi = Pick<typeof api, "migrationStatus" | "migrationStart" | "migrationRetry" | "migrationCleanupBackups">;
// Each startup boundary owns its state; an action failure never erases its report.
export function useMigrationStore(backend: MigrationApi = api) {
const state = reactive<{
status: MigrationPreflight | null;
report: MigrationReport | null;
loading: boolean;
busy: boolean;
error: "statusFailed" | "migrationFailed" | "cleanupFailed" | null;
errorCode: string | null;
errorMessage: string | null;
entered: boolean;
step: 1 | 2 | 3;
}>({ status: null, report: null, loading: true, busy: false, error: null, errorCode: null, errorMessage: null, entered: false, step: 1 });
const completed = computed(() => state.status !== null && !state.status.needsMigration && ["succeeded", "not_required"].includes(state.status.state));
// A managed data-directory key is intentionally created when migration
// starts. Its absence during the read-only preflight is actionable, not a
// migration failure, as long as the backend explicitly allows creation.
const hasBlockingError = () => Boolean(state.errorCode && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true));
const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && Boolean(state.report || state.status?.backupPath)));
function clearError() {
state.error = null;
state.errorCode = null;
state.errorMessage = null;
}
async function refreshStatus() {
// Only this endpoint returns classified, redacted migration errors. Transport
// exceptions may contain configuration values and must never enter UI state.
state.status = await backend.migrationStatus();
state.errorCode = state.status.errorCode ?? null;
state.errorMessage = state.status.errorMessage ?? null;
}
async function initialize() {
state.loading = true;
clearError();
try {
await refreshStatus();
state.step = completed.value ? 3 : 1;
// A completed migration only needs the success page in the session that
// performed it. On later launches, retained backups are recovery assets,
// not a reason to block the normal application startup.
state.entered = completed.value;
if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
state.loading = false;
}
}
async function run(retry: boolean) {
if (state.busy) return;
state.busy = true;
state.step = 2;
clearError();
try {
state.report = await (retry ? backend.migrationRetry() : backend.migrationStart());
} catch {
state.error = "migrationFailed";
}
try {
await refreshStatus();
if (completed.value) {
state.step = 3;
state.error = null;
}
if (!completed.value || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
state.busy = false;
}
}
async function cleanup() {
if (state.busy) return;
state.busy = true;
clearError();
try {
await backend.migrationCleanupBackups();
if (state.status) state.status.backupPath = null;
if (state.report) state.report.backupPath = null;
} catch {
state.error = "cleanupFailed";
} finally {
state.busy = false;
}
}
function diagnostic() {
const s = state.status;
return {
migrationId: s?.migrationId,
actionError: state.error,
step: state.step,
needsMigration: s?.needsMigration,
legacyJsonFiles: s?.legacyJsonFiles,
state: s?.state,
counts: s ? { connections: s.connectionCount, databasePlaintext: s.databasePlaintextCount, plugin: s.pluginSecretCount, ai: s.aiSecretCount, tunnel: s.tunnelSecretCount, sync: s.syncCredentialCount } : {},
keyProviderAvailable: s?.keyProviderAvailable,
backupPath: s?.backupPath,
errorCode: state.errorCode,
};
}
function enter() {
if (completed.value && !state.busy) state.entered = true;
}
return { state, blocking, completed, initialize, start: () => run(false), retry: () => run(true), cleanup, diagnostic, enter };
}