feat(auth): keep unsaved passwords in session memory
This commit is contained in:
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -444,7 +444,9 @@ export default withEnglishFallback({
|
||||
user: "ユーザー",
|
||||
password: "パスワード",
|
||||
savePassword: "パスワードを保存",
|
||||
savePasswordHint: "チェックを外すと、接続のたびに入力が必要です",
|
||||
savePasswordHint: "ローカルには保存しません。初回接続後、パスワードはこの実行中のみ保持され、アプリ終了時に消去されます。",
|
||||
disconnectAndForgetPassword: "切断してこのパスワードを忘れる",
|
||||
passwordForgotten: "パスワードを忘れました。次回の接続で再入力が必要です。",
|
||||
promptPasswordTitle: "パスワードを入力",
|
||||
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。",
|
||||
promptPasswordPlaceholder: "パスワード",
|
||||
|
||||
@@ -438,7 +438,9 @@ export default withEnglishFallback({
|
||||
user: "사용자",
|
||||
password: "비밀번호",
|
||||
savePassword: "비밀번호 저장",
|
||||
savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다",
|
||||
savePasswordHint: "로컬에 저장하지 않습니다. 첫 연결 후 비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.",
|
||||
disconnectAndForgetPassword: "연결 끊고 비밀번호 잊기",
|
||||
passwordForgotten: "비밀번호를 잊었습니다. 다음 연결 시 다시 입력해야 합니다.",
|
||||
promptPasswordTitle: "비밀번호 입력",
|
||||
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.",
|
||||
promptPasswordPlaceholder: "비밀번호",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -369,7 +369,9 @@ export default withEnglishFallback({
|
||||
user: "用户名",
|
||||
password: "密码",
|
||||
savePassword: "本地保存密码",
|
||||
savePasswordHint: "取消勾选后,每次连接都需要输入密码",
|
||||
savePasswordHint: "不保存到本地。首次连接后,密码仅在本次运行期间保留;退出应用后将清除。",
|
||||
disconnectAndForgetPassword: "断开并忘记本次密码",
|
||||
passwordForgotten: "已忘记密码,下次连接需重新输入。",
|
||||
promptPasswordTitle: "输入密码",
|
||||
promptPasswordMessage: "请输入连接“{connection}”的密码。",
|
||||
promptPasswordPlaceholder: "密码",
|
||||
|
||||
@@ -445,7 +445,9 @@ export default withEnglishFallback({
|
||||
user: "使用者名稱",
|
||||
password: "密碼",
|
||||
savePassword: "本機儲存密碼",
|
||||
savePasswordHint: "取消勾選後,每次連線都需要輸入密碼",
|
||||
savePasswordHint: "不儲存到本機。首次連線後,密碼僅在本次執行期間保留;退出應用程式後將清除。",
|
||||
disconnectAndForgetPassword: "中斷連線並忘記本次密碼",
|
||||
passwordForgotten: "已忘記密碼,下次連線需重新輸入。",
|
||||
promptPasswordTitle: "輸入密碼",
|
||||
promptPasswordMessage: "請輸入連線「{connection}」的密碼。",
|
||||
promptPasswordPlaceholder: "密碼",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user