feat(connection): allow opting out of password storage
This commit is contained in:
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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -430,6 +430,13 @@ export default withEnglishFallback({
|
||||
sqliteExtensionBrowse: "SQLite拡張を選択",
|
||||
user: "ユーザー",
|
||||
password: "パスワード",
|
||||
savePassword: "パスワードを保存",
|
||||
savePasswordHint: "チェックを外すと、接続のたびに入力が必要です",
|
||||
promptPasswordTitle: "パスワードを入力",
|
||||
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。ローカルには保存されません。",
|
||||
promptPasswordPlaceholder: "パスワード",
|
||||
promptPasswordSubmit: "接続",
|
||||
promptPasswordCancel: "キャンセル",
|
||||
database: "データベース",
|
||||
databasePlaceholder: "任意",
|
||||
databasePlaceholderRequired: "必須、既存のデータベースを入力",
|
||||
|
||||
@@ -424,6 +424,13 @@ export default withEnglishFallback({
|
||||
sqliteExtensionBrowse: "SQLite 확장 선택",
|
||||
user: "사용자",
|
||||
password: "비밀번호",
|
||||
savePassword: "비밀번호 저장",
|
||||
savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다",
|
||||
promptPasswordTitle: "비밀번호 입력",
|
||||
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요. 로컬에 저장되지 않습니다.",
|
||||
promptPasswordPlaceholder: "비밀번호",
|
||||
promptPasswordSubmit: "연결",
|
||||
promptPasswordCancel: "취소",
|
||||
database: "데이터베이스",
|
||||
databasePlaceholder: "선택",
|
||||
databasePlaceholderRequired: "필수, 기존 데이터베이스 입력",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -356,6 +356,13 @@ export default withEnglishFallback({
|
||||
sqliteExtensionBrowse: "选择 SQLite 扩展库",
|
||||
user: "用户名",
|
||||
password: "密码",
|
||||
savePassword: "本地保存密码",
|
||||
savePasswordHint: "取消勾选后,每次连接都需要输入密码",
|
||||
promptPasswordTitle: "输入密码",
|
||||
promptPasswordMessage: "请输入连接“{connection}”的密码,该密码不会保存在本地。",
|
||||
promptPasswordPlaceholder: "密码",
|
||||
promptPasswordSubmit: "连接",
|
||||
promptPasswordCancel: "取消",
|
||||
database: "数据库",
|
||||
databasePlaceholder: "可选",
|
||||
databasePlaceholderRequired: "必填,请填写已存在的数据库",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user