feat(auth): keep unsaved passwords in session memory

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-14 18:37:53 +08:00
1 parent bcec60ebef
commit 1ff24aaeb4
23 files changed
+1265 -65

No files matched your search

@@ -385,6 +385,8 @@ const {
disconnectConnection,
connectionDisconnectMenuLabel,
canDisconnectConnection,
canForgetSessionCredential,
disconnectAndForgetConnectionPassword,
cancelConnectionAttempt,
closeDatabaseConnection,
isPinned,
@@ -4433,6 +4435,15 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X });
} else if (canDisconnectConnection()) {
items.push({ label: connectionDisconnectMenuLabel(), action: disconnectConnection, icon: Unplug });
// save_password=false 且本次运行期已输入密码:提供"断开并忘记本次密码",
// 清除会话凭据后下次连接需重新输入。
if (canForgetSessionCredential()) {
items.push({
label: t("connection.disconnectAndForgetPassword"),
action: disconnectAndForgetConnectionPassword,
icon: Unplug,
});
}
} else if (!isConnected.value) {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
}
@@ -246,6 +246,26 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
return connectionDisconnectTargets().some((target) => connectionStore.connectedIds.has(target.connectionId));
}
/** "断开并忘记本次密码"是否可用:save_password=false 且当前已连接(本次运行期必已输入密码)。 */
function canForgetSessionCredential(): boolean {
const node = activeNode.value;
if (!node?.connectionId) return false;
const config = connectionStore.getConfig(node.connectionId);
return config?.save_password === false && connectionStore.connectedIds.has(node.connectionId);
}
/** "断开并忘记本次密码":关闭连接池并清除该连接本次运行期的会话密码。 */
async function disconnectAndForgetConnectionPassword() {
const node = activeNode.value;
if (!node?.connectionId) return;
try {
await connectionStore.disconnectAndForgetConnectionPassword(node.connectionId);
toast(t("connection.passwordForgotten"), 2000);
} catch (error: any) {
toast(t("connection.saveFailed", { message: error?.message || String(error) }), 5000);
}
}
async function cancelConnectionAttempt() {
const connectionId = activeNode.value.connectionId;
if (!connectionId) return;
@@ -366,6 +386,8 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
disconnectConnection,
connectionDisconnectMenuLabel,
canDisconnectConnection,
canForgetSessionCredential,
disconnectAndForgetConnectionPassword,
cancelConnectionAttempt,
closeDatabaseConnection,
isPinned,
+3 -1
View File
@@ -444,7 +444,9 @@ export default {
user: "User",
password: "Password",
savePassword: "Save password",
savePasswordHint: "Uncheck to require entering it on every connect",
savePasswordHint: "Not saved locally. Kept only for this session and cleared when you quit the app",
disconnectAndForgetPassword: "Disconnect and Forget Password",
passwordForgotten: "Password forgotten. Reconnect will ask for it again.",
promptPasswordTitle: "Enter password",
promptPasswordMessage: "Enter the password for connection “{connection}”.",
promptPasswordPlaceholder: "Password",
+3 -1
View File
@@ -445,7 +445,9 @@ export default withEnglishFallback({
user: "Usuario",
password: "Contraseña",
savePassword: "Guardar contraseña",
savePasswordHint: "Desmarcar para solicitarla en cada conexión",
savePasswordHint: "No se guarda localmente. Solo se conserva en esta sesión y se borra al salir de la aplicación",
disconnectAndForgetPassword: "Desconectar y olvidar la contraseña",
passwordForgotten: "Contraseña olvidada. La próxima conexión la solicitará de nuevo.",
promptPasswordTitle: "Introducir contraseña",
promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”.",
promptPasswordPlaceholder: "Contraseña",
+3 -1
View File
@@ -444,7 +444,9 @@ export default withEnglishFallback({
user: "Utente",
password: "Password",
savePassword: "Salva password",
savePasswordHint: "Deseleziona per richiederla a ogni connessione",
savePasswordHint: "Non salvata localmente. Conservata solo per questa sessione e cancellata alla chiusura dell'app",
disconnectAndForgetPassword: "Disconnetti e dimentica la password",
passwordForgotten: "Password dimenticata. La prossima connessione la richiederà di nuovo.",
promptPasswordTitle: "Inserisci password",
promptPasswordMessage: "Inserisci la password per la connessione “{connection}”.",
promptPasswordPlaceholder: "Password",
+3 -1
View File
@@ -444,7 +444,9 @@ export default withEnglishFallback({
user: "ユーザー",
password: "パスワード",
savePassword: "パスワードを保存",
savePasswordHint: "チェックを外すと、接続のたびに入力が必要です",
savePasswordHint: "ローカルには保存しません。初回接続後、パスワードはこの実行中のみ保持され、アプリ終了時に消去されます。",
disconnectAndForgetPassword: "切断してこのパスワードを忘れる",
passwordForgotten: "パスワードを忘れました。次回の接続で再入力が必要です。",
promptPasswordTitle: "パスワードを入力",
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。",
promptPasswordPlaceholder: "パスワード",
+3 -1
View File
@@ -438,7 +438,9 @@ export default withEnglishFallback({
user: "사용자",
password: "비밀번호",
savePassword: "비밀번호 저장",
savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다",
savePasswordHint: "로컬에 저장하지 않습니다. 첫 연결 후 비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.",
disconnectAndForgetPassword: "연결 끊고 비밀번호 잊기",
passwordForgotten: "비밀번호를 잊었습니다. 다음 연결 시 다시 입력해야 합니다.",
promptPasswordTitle: "비밀번호 입력",
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.",
promptPasswordPlaceholder: "비밀번호",
+3 -1
View File
@@ -445,7 +445,9 @@ export default withEnglishFallback({
user: "Usuário",
password: "Senha",
savePassword: "Salvar senha",
savePasswordHint: "Desmarque para exigir o preenchimento em cada conexão",
savePasswordHint: "Não salva localmente. Mantida apenas nesta sessão e apagada ao sair do aplicativo",
disconnectAndForgetPassword: "Desconectar e Esquecer a Senha",
passwordForgotten: "Senha esquecida. A próxima conexão pedirá novamente.",
promptPasswordTitle: "Digite a senha",
promptPasswordMessage: "Digite a senha da conexão “{connection}”.",
promptPasswordPlaceholder: "Senha",
+3 -1
View File
@@ -369,7 +369,9 @@ export default withEnglishFallback({
user: "用户名",
password: "密码",
savePassword: "本地保存密码",
savePasswordHint: "取消勾选后,每次连接都需要输入密码",
savePasswordHint: "不保存到本地。首次连接后,密码仅在本次运行期间保留;退出应用后将清除。",
disconnectAndForgetPassword: "断开并忘记本次密码",
passwordForgotten: "已忘记密码,下次连接需重新输入。",
promptPasswordTitle: "输入密码",
promptPasswordMessage: "请输入连接“{connection}”的密码。",
promptPasswordPlaceholder: "密码",
+3 -1
View File
@@ -445,7 +445,9 @@ export default withEnglishFallback({
user: "使用者名稱",
password: "密碼",
savePassword: "本機儲存密碼",
savePasswordHint: "取消勾選後,每次連線都需要輸入密碼",
savePasswordHint: "不儲存到本機。首次連線後,密碼僅在本次執行期間保留;退出應用程式後將清除。",
disconnectAndForgetPassword: "中斷連線並忘記本次密碼",
passwordForgotten: "已忘記密碼,下次連線需重新輸入。",
promptPasswordTitle: "輸入密碼",
promptPasswordMessage: "請輸入連線「{connection}」的密碼。",
promptPasswordPlaceholder: "密碼",
+2
View File
@@ -58,6 +58,8 @@ export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
export const disconnectDb = forward("disconnectDb");
export const sessionCredentialStatus = forward("sessionCredentialStatus");
export const forgetSessionCredential = forward("forgetSessionCredential");
export const checkConnectionHealth = forward("checkConnectionHealth");
export const connectionIdentifierQuote = forward("connectionIdentifierQuote");
export const closeDatabaseConnection = forward("closeDatabaseConnection");
+8
View File
@@ -373,6 +373,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number)
return post("/api/connection/disconnect", { connectionId, clientAttempt });
}
export async function sessionCredentialStatus(connectionId: string): Promise<boolean> {
return post("/api/connection/session-credential-status", { connectionId });
}
export async function forgetSessionCredential(connectionId: string): Promise<void> {
return post("/api/connection/forget-session-credential", { connectionId });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return post("/api/connection/check-health", { connectionId });
}
+8
View File
@@ -924,6 +924,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number)
return invokeBackend("disconnect_db", { connectionId, clientAttempt });
}
export async function sessionCredentialStatus(connectionId: string): Promise<boolean> {
return invokeBackend("session_credential_status", { connectionId });
}
export async function forgetSessionCredential(connectionId: string): Promise<void> {
return invokeBackend("forget_session_credential", { connectionId });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return invokeBackend("check_connection_health", { connectionId });
}
@@ -44,12 +44,15 @@ function installApiMocks(extra: Record<string, unknown> = {}) {
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
connectDb: vi.fn().mockResolvedValue("pg-1"),
disconnectDb: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
forgetSessionCredential: vi.fn().mockResolvedValue(undefined),
...extra,
}));
}
@@ -157,6 +160,58 @@ describe("connectionStore save_password opt-out", () => {
expect(requestPassword).not.toHaveBeenCalled();
});
it("connect skips the prompt when the backend already has a session credential", async () => {
installApiMocks({ sessionCredentialStatus: vi.fn().mockResolvedValue(true) });
installPasswordPromptMock();
requestPassword.mockResolvedValue({ password: "never-used", rememberPassword: false });
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);
// 本次运行期已输入过密码:不再弹窗,直接以空密码 connectDb(后端补主密码)。
expect(requestPassword).not.toHaveBeenCalled();
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "pg-1", password: "" }), expect.any(Number));
});
it("disconnectAndForgetConnectionPassword clears the backend session credential", async () => {
const forgetSessionCredential = vi.fn().mockResolvedValue(undefined);
installApiMocks({
forgetSessionCredential,
sessionCredentialStatus: vi.fn().mockResolvedValue(true),
});
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
store.connections = [connection];
store.connectedIds.add("pg-1");
await store.disconnectAndForgetConnectionPassword("pg-1");
expect(forgetSessionCredential).toHaveBeenCalledWith("pg-1");
expect(store.connectedIds.has("pg-1")).toBe(false);
});
it("disconnectAndForgetConnectionPassword propagates forget failures", async () => {
const forgetSessionCredential = vi.fn().mockRejectedValue(new Error("no session credential"));
installApiMocks({
forgetSessionCredential,
sessionCredentialStatus: vi.fn().mockResolvedValue(true),
});
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
store.connections = [connection];
store.connectedIds.add("pg-1");
await expect(store.disconnectAndForgetConnectionPassword("pg-1")).rejects.toThrow("no session credential");
});
it("prompts and retries when MySQL rejects a synced connection that sent no password", async () => {
const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1");
installApiMocks({ connectDb });
+43 -6
View File
@@ -3054,6 +3054,23 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/**
* Query whether this connection already has a transient password stored in the
* backend session-credential store (a `save_password === false` connection that
* was connected once this run). Used to skip the interactive password prompt on
* later connects. Only returns a boolean — the password itself never leaves the
* backend process memory.
*/
async function hasSessionCredential(connectionId: string): Promise<boolean> {
try {
return await api.sessionCredentialStatus(connectionId);
} catch {
// Status query failure is treated as "no credential": falling back to the
// interactive prompt is the safe, well-known path.
return false;
}
}
/**
* Prompt for a transient password when saving is disabled, or when a server
* confirms that a metadata-only synced connection sent no password. The
@@ -3109,9 +3126,13 @@ export const useConnectionStore = defineStore("connection", () => {
try {
let rememberPassword = false;
if (config.save_password === false && !config.password) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
if (!(await hasSessionCredential(config.id))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
}
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
@@ -3243,6 +3264,16 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/**
* "断开并忘记本次密码":关闭连接池(保留其它已保存连接的凭据),并清除该
* `save_password === false` 连接本次运行期的会话密码,使下一次连接必须重新输入。
* 若该连接本无会话凭据,后端会返回错误并在此抛出(不静默吞掉),避免误报成功。
*/
async function disconnectAndForgetConnectionPassword(connectionId: string) {
await disconnect(connectionId);
await api.forgetSessionCredential(connectionId);
}
async function closeDatabaseConnection(connectionId: string, database: string) {
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
await api.closeDatabaseConnection(connectionId, database);
@@ -3310,9 +3341,13 @@ export const useConnectionStore = defineStore("connection", () => {
// 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) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
if (!(await hasSessionCredential(connectionId))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
}
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
@@ -7816,6 +7851,8 @@ export const useConnectionStore = defineStore("connection", () => {
connect,
cancelConnecting,
disconnect,
disconnectAndForgetConnectionPassword,
hasSessionCredential,
closeDatabaseConnection,
ensureConnected,
loadConnectedConnectionRootForSidebarSearch,