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

@@ -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,