feat(connection): allow opting out of password storage

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-12 19:34:00 +08:00
1 parent b8d37774fd
commit a029888725
47 files changed
+585 -12

No files matched your search

@@ -320,6 +320,7 @@ const defaultForm = (): ConnectionForm => ({
is_production: false,
production_databases: [],
visible_databases: undefined,
save_password: true,
});
const elasticsearchConnectionMode = ref<ElasticsearchConnectionMode>("direct");
@@ -2470,6 +2471,7 @@ watch(
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
visible_schemas: config.visible_schemas,
save_password: config.save_password !== false,
};
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
@@ -3755,6 +3757,9 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
}
if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined;
// Save-password is a positive default: only an explicit unchecked state (false)
// is persisted; anything else keeps the current behavior.
config.save_password = config.save_password !== false;
if ((isSingleDatabase(config.db_type) || config.db_type === "mq" || config.db_type === "mqtt") && config.production_databases?.length) {
// Single-database / MQ drivers expose no independently selectable database list for PROD scope.
config.is_production = true;
@@ -5814,6 +5819,14 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input v-model="form.save_password" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :aria-label="t('connection.savePassword')" />
<span>{{ t("connection.savePassword") }}</span>
<span class="text-xs text-muted-foreground">{{ t("connection.savePasswordHint") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.jdbcDriverPaths") }}</Label>
<div class="col-span-3 space-y-2">
@@ -6953,6 +6966,15 @@ function openExternalUrl(url: string) {
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input v-model="form.save_password" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :aria-label="t('connection.savePassword')" />
<span>{{ t("connection.savePassword") }}</span>
<span class="text-xs text-muted-foreground">{{ t("connection.savePasswordHint") }}</span>
</label>
</div>
<div v-if="form.db_type !== 'hbase'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { KeyRound } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { useConnectionPasswordPromptStore } from "@/stores/connectionPasswordPromptStore";
const { t } = useI18n();
const promptStore = useConnectionPasswordPromptStore();
const password = ref("");
const resolving = ref(false);
const open = computed({
get: () => !!promptStore.pending,
set: (value: boolean) => {
if (!value && promptStore.pending) promptStore.cancel();
},
});
// Reset the input for each new prompt (including the next one in the queue).
watch(
() => promptStore.pending,
() => {
password.value = "";
resolving.value = false;
},
);
function submit() {
if (resolving.value || !password.value) return;
resolving.value = true;
promptStore.submit(password.value);
}
function cancel() {
if (resolving.value) return;
promptStore.cancel();
}
</script>
<template>
<Dialog v-model:open="open" @escape-key-down.prevent @interact-outside.prevent>
<DialogContent class="sm:max-w-[420px]" :show-close-button="false">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<KeyRound class="h-5 w-5" />
{{ t("connection.promptPasswordTitle") }}
</DialogTitle>
<DialogDescription class="text-muted-foreground">
{{ t("connection.promptPasswordMessage", { connection: promptStore.pending?.connectionName ?? "" }) }}
</DialogDescription>
</DialogHeader>
<div class="py-1">
<PasswordInput v-model="password" autofocus :placeholder="t('connection.promptPasswordPlaceholder')" :disabled="resolving" @keydown.enter.prevent="submit" />
</div>
<DialogFooter>
<Button variant="outline" :disabled="resolving" @click="cancel">
{{ t("connection.promptPasswordCancel") }}
</Button>
<Button :disabled="resolving || !password" @click="submit">
{{ t("connection.promptPasswordSubmit") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -17,6 +17,7 @@ const FieldLineageDialog = defineAsyncComponent(() => import("@/components/linea
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/ssh/SshHostKeyPromptDialog.vue"));
const ConnectionPasswordPromptDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionPasswordPromptDialog.vue"));
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
@@ -300,4 +301,5 @@ watch(
</DialogContent>
</Dialog>
<SshHostKeyPromptDialog />
<ConnectionPasswordPromptDialog />
</template>
+7
View File
@@ -430,6 +430,13 @@ export default {
sqliteExtensionBrowse: "Select SQLite extension",
user: "User",
password: "Password",
savePassword: "Save password",
savePasswordHint: "Uncheck to require entering it on every connect",
promptPasswordTitle: "Enter password",
promptPasswordMessage: "Enter the password for connection “{connection}”. It will not be saved locally.",
promptPasswordPlaceholder: "Password",
promptPasswordSubmit: "Connect",
promptPasswordCancel: "Cancel",
database: "Database",
databasePlaceholder: "Optional",
databasePlaceholderRequired: "Required, enter an existing database",
+7
View File
@@ -431,6 +431,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleccionar extensión de SQLite",
user: "Usuario",
password: "Contraseña",
savePassword: "Guardar contraseña",
savePasswordHint: "Desmarcar para solicitarla en cada conexión",
promptPasswordTitle: "Introducir contraseña",
promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”. No se guardará localmente.",
promptPasswordPlaceholder: "Contraseña",
promptPasswordSubmit: "Conectar",
promptPasswordCancel: "Cancelar",
database: "Base de datos",
databasePlaceholder: "Opcional",
databasePlaceholderRequired: "Obligatorio, ingresa una base de datos existente",
+7
View File
@@ -430,6 +430,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleziona estensione SQLite",
user: "Utente",
password: "Password",
savePassword: "Salva password",
savePasswordHint: "Deseleziona per richiederla a ogni connessione",
promptPasswordTitle: "Inserisci password",
promptPasswordMessage: "Inserisci la password per la connessione “{connection}”. Non verrà salvata localmente.",
promptPasswordPlaceholder: "Password",
promptPasswordSubmit: "Connetti",
promptPasswordCancel: "Annulla",
database: "Database",
databasePlaceholder: "Opzionale",
databasePlaceholderRequired: "Obbligatorio, inserisci un database esistente",
+7
View File
@@ -430,6 +430,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite拡張を選択",
user: "ユーザー",
password: "パスワード",
savePassword: "パスワードを保存",
savePasswordHint: "チェックを外すと、接続のたびに入力が必要です",
promptPasswordTitle: "パスワードを入力",
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。ローカルには保存されません。",
promptPasswordPlaceholder: "パスワード",
promptPasswordSubmit: "接続",
promptPasswordCancel: "キャンセル",
database: "データベース",
databasePlaceholder: "任意",
databasePlaceholderRequired: "必須、既存のデータベースを入力",
+7
View File
@@ -424,6 +424,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite 확장 선택",
user: "사용자",
password: "비밀번호",
savePassword: "비밀번호 저장",
savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다",
promptPasswordTitle: "비밀번호 입력",
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요. 로컬에 저장되지 않습니다.",
promptPasswordPlaceholder: "비밀번호",
promptPasswordSubmit: "연결",
promptPasswordCancel: "취소",
database: "데이터베이스",
databasePlaceholder: "선택",
databasePlaceholderRequired: "필수, 기존 데이터베이스 입력",
+7
View File
@@ -431,6 +431,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Selecionar extensão SQLite",
user: "Usuário",
password: "Senha",
savePassword: "Salvar senha",
savePasswordHint: "Desmarque para exigir o preenchimento em cada conexão",
promptPasswordTitle: "Digite a senha",
promptPasswordMessage: "Digite a senha da conexão “{connection}”. Ela não será salva localmente.",
promptPasswordPlaceholder: "Senha",
promptPasswordSubmit: "Conectar",
promptPasswordCancel: "Cancelar",
database: "Banco de dados",
databasePlaceholder: "Opcional",
databasePlaceholderRequired: "Obrigatório, informe um banco de dados existente",
+7
View File
@@ -356,6 +356,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "选择 SQLite 扩展库",
user: "用户名",
password: "密码",
savePassword: "本地保存密码",
savePasswordHint: "取消勾选后,每次连接都需要输入密码",
promptPasswordTitle: "输入密码",
promptPasswordMessage: "请输入连接“{connection}”的密码,该密码不会保存在本地。",
promptPasswordPlaceholder: "密码",
promptPasswordSubmit: "连接",
promptPasswordCancel: "取消",
database: "数据库",
databasePlaceholder: "可选",
databasePlaceholderRequired: "必填,请填写已存在的数据库",
+7
View File
@@ -431,6 +431,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "選擇 SQLite 擴充庫",
user: "使用者名稱",
password: "密碼",
savePassword: "本機儲存密碼",
savePasswordHint: "取消勾選後,每次連線都需要輸入密碼",
promptPasswordTitle: "輸入密碼",
promptPasswordMessage: "請輸入連線「{connection}」的密碼,該密碼不會儲存在本機。",
promptPasswordPlaceholder: "密碼",
promptPasswordSubmit: "連線",
promptPasswordCancel: "取消",
database: "資料庫",
databasePlaceholder: "可選",
databasePlaceholderRequired: "必填,請填寫已存在的資料庫",
@@ -0,0 +1,148 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { CONNECTION_PASSWORD_REQUIRED_MESSAGE } from "@/stores/connectionStore";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function postgresConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "pg-1",
name: "Postgres",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
database: "app",
read_only: false,
...overrides,
} as ConnectionConfig;
}
let requestPassword: ReturnType<typeof vi.fn>;
function installApiMocks(extra: Record<string, unknown> = {}) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
connectDb: vi.fn().mockResolvedValue("pg-1"),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
...extra,
}));
}
function installPasswordPromptMock() {
vi.doMock("@/stores/connectionPasswordPromptStore", () => ({
useConnectionPasswordPromptStore: () => ({
pending: null,
requestPassword,
}),
}));
}
describe("connectionStore save_password opt-out", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
requestPassword = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("addConnection blanks the in-memory password when save_password is false", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(postgresConnection({ id: "no-save", save_password: false, password: "hunter2" }));
expect(store.getConfig("no-save")?.password).toBe("");
const { saveConnections } = await import("@/lib/backend/api");
expect(saveConnections).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: "no-save", password: "", save_password: false })]));
});
it("addConnection keeps the password for legacy configs without save_password", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(postgresConnection({ id: "legacy", password: "s3cret" }));
expect(store.getConfig("legacy")?.password).toBe("s3cret");
});
it("updateConnection blanks the in-memory password when save_password is false", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(postgresConnection({ id: "pg-1", save_password: true, password: "old" }));
await store.updateConnection(postgresConnection({ id: "pg-1", save_password: false, password: "hunter2" }));
expect(store.getConfig("pg-1")?.password).toBe("");
});
it("connect prompts for the password and uses it only for connectDb", async () => {
installApiMocks();
installPasswordPromptMock();
requestPassword.mockResolvedValue("typed-pw");
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
store.connections = [connection];
await store.connect(connection);
expect(requestPassword).toHaveBeenCalledWith({ connectionId: "pg-1", connectionName: "Postgres" });
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "pg-1", password: "typed-pw" }), expect.any(Number));
// The typed password is never written back to the store.
expect(store.getConfig("pg-1")?.password).toBe("");
});
it("connect cancels when the prompt is dismissed", async () => {
installApiMocks();
installPasswordPromptMock();
requestPassword.mockResolvedValue(null);
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
store.connections = [connection];
await expect(store.connect(connection)).rejects.toThrow(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).not.toHaveBeenCalled();
});
it("connect does not prompt for a saved-password connection or one with a password present", async () => {
installApiMocks();
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const saved = postgresConnection({ id: "saved", save_password: true, password: "s3cret" });
const fresh = postgresConnection({ id: "fresh", save_password: false, password: "typed" });
store.connections = [saved, fresh];
await store.connect(saved);
await store.connect(fresh);
expect(requestPassword).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export interface ConnectionPasswordPromptRequest {
connectionId: string;
connectionName: string;
}
interface QueuedPasswordPrompt {
request: ConnectionPasswordPromptRequest;
resolve: (password: string | null) => void;
}
/**
* Coordinates the single "enter password for a connection that is set to not
* save it locally" dialog shared by every connect surface. Mirrors the
* production-safety confirmation store: a request is transient, never
* persisted, and concurrent connects are queued so each prompt is shown and
* answered exactly once. A `null` resolution means the user cancelled.
*/
export const useConnectionPasswordPromptStore = defineStore("connectionPasswordPrompt", () => {
const pending = ref<ConnectionPasswordPromptRequest>();
const queue: QueuedPasswordPrompt[] = [];
let resolvePending: ((password: string | null) => void) | undefined;
function requestPassword(request: ConnectionPasswordPromptRequest): Promise<string | null> {
return new Promise<string | null>((resolve) => {
if (pending.value) {
queue.push({ request, resolve });
return;
}
beginRequest(request, resolve);
});
}
function beginRequest(request: ConnectionPasswordPromptRequest, resolve: (password: string | null) => void) {
pending.value = request;
resolvePending = resolve;
}
function settle(password: string | null) {
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
resolve?.(password);
const next = queue.shift();
if (next) beginRequest(next.request, next.resolve);
}
function submit(password: string) {
settle(password);
}
function cancel() {
settle(null);
}
return { pending, requestPassword, submit, cancel };
});
@@ -302,6 +302,8 @@ type MetadataListPageResult = TableInfo[] | ObjectInfo[];
type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled";
/** Thrown when a no-save-password connection is connected without a typed password. */
export const CONNECTION_PASSWORD_REQUIRED_MESSAGE = "Password is required for this connection";
function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
return config?.driver_profile || config?.db_type;
@@ -2339,6 +2341,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {
const normalized = normalizeConnection(config);
if (normalized.save_password === false) normalized.password = "";
await persistTimeoutInheritance(normalized.id, normalized.connect_timeout_inherit === true, normalized.query_timeout_inherit === true);
const existing = connections.value.findIndex((c) => c.id === normalized.id);
const nextConnections = [...connections.value];
@@ -2502,6 +2505,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function updateConnection(config: ConnectionConfig) {
config = normalizeConnection(config);
if (config.save_password === false) config.password = "";
const idx = connections.value.findIndex((c) => c.id === config.id);
if (idx < 0) return;
const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config);
@@ -2827,11 +2831,32 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/**
* For a connection configured with `save_password === false` (password not
* persisted), prompt the user for the password and return a copy of the
* config carrying the typed value. The returned config is used only for the
* immediate `connectDb` call — it is never written back to the store, so the
* typed password is not persisted and the next connect prompts again.
*/
async function ensureConnectionPassword(config: ConnectionConfig): Promise<ConnectionConfig> {
if (config.save_password !== false || config.password) return config;
const { useConnectionPasswordPromptStore } = await import("@/stores/connectionPasswordPromptStore");
const password = await useConnectionPasswordPromptStore().requestPassword({
connectionId: config.id,
connectionName: config.name,
});
if (!password) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
return { ...config, password };
}
async function connect(config: ConnectionConfig) {
config = normalizeConnection(config);
if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id);
const localAttempt = beginLocalConnectionAttempt(config.id);
try {
if (config.save_password === false && !config.password) {
config = await ensureConnectionPassword(config);
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
@@ -3011,6 +3036,12 @@ export const useConnectionStore = defineStore("connection", () => {
}
const localAttempt = beginLocalConnectionAttempt(connectionId);
const connectPromise = (async () => {
// Fast-path the common case (password saved or no password needed) so the
// in-flight dedup above keeps its exact microtask cadence; only await the
// interactive prompt when the connection actually needs a typed password.
if (config.save_password === false && !config.password) {
config = await ensureConnectionPassword(config);
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
+6
View File
@@ -185,6 +185,12 @@ export interface ConnectionConfig {
informix_server?: string;
external_config?: unknown;
one_time?: boolean;
/**
* Whether the database password may be persisted locally. When false, the
* password is never written to local storage and the user is prompted on
* every connect. Absent/true keeps current behavior (password saved).
*/
save_password?: boolean;
read_only?: boolean;
/** Explicit production marker for every database reachable through this connection. */
is_production?: boolean;