登录页UI及长时间未操作锁定逻辑优化

This commit is contained in:
Cheng Zhou
2026-03-10 10:10:58 +08:00
parent 7936c55f3f
commit 4c98147f3e
12 changed files with 2225 additions and 470 deletions
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
vi.mock("../router", () => ({
default: {
replace: vi.fn(),
},
}));
vi.mock("../api/authClient", () => ({
extendToken: vi.fn(),
unlockSession: vi.fn(),
}));
describe("session manager idle recovery", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
setActivePinia(createPinia());
const storage = (() => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, String(value));
},
removeItem: (key: string) => {
data.delete(key);
},
clear: () => {
data.clear();
},
};
})();
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "sessionStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "BroadcastChannel", {
value: class {
onmessage: ((event: MessageEvent) => void) | null = null;
postMessage() {}
close() {}
},
configurable: true,
});
});
it("locks instead of refreshing activity after long inactivity when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markUserActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
session.recordNetworkActivity(oldTs);
markUserActive(Date.now());
expect(session.locked).toBe(true);
expect(session.lockReason).toBe("idle");
expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
});
it("locks instead of refreshing activity after long inactivity when network activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markNetworkActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
session.recordNetworkActivity(oldTs);
markNetworkActive(Date.now());
expect(session.locked).toBe(true);
expect(session.lockReason).toBe("idle");
expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
});
});
+36 -18
View File
@@ -25,6 +25,26 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const getLastActiveAt = (session: ReturnType<typeof useSessionStore>) =>
Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt);
const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore();
if (session.locked) return false;
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const lastActiveAt = getLastActiveAt(session);
if (now - lastActiveAt > autoLogoutMs) {
forceLogout(LOGOUT_REASON_TIMEOUT);
return false;
}
if (now - lastActiveAt > idleTimeoutMs) {
lockSession("idle");
return false;
}
return true;
};
const broadcast = (message: BroadcastMessage) => {
if (channel) {
channel.postMessage(message);
@@ -81,9 +101,9 @@ const scheduleIdleCheck = () => {
}
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
const idleRemain = baseActiveAt + idleTimeoutMs - now;
const logoutRemain = baseActiveAt + autoLogoutMs - now;
const lastActiveAt = getLastActiveAt(session);
const idleRemain = lastActiveAt + idleTimeoutMs - now;
const logoutRemain = lastActiveAt + autoLogoutMs - now;
const nextCheck = Math.min(idleRemain, logoutRemain);
if (nextCheck <= 0) {
checkIdle();
@@ -103,19 +123,7 @@ const checkIdle = () => {
scheduleIdleCheck();
return;
}
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const now = Date.now();
const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs;
const autoLogoutElapsed =
now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs;
if (autoLogoutElapsed) {
forceLogout(LOGOUT_REASON_TIMEOUT);
return;
}
if (idleElapsed) {
lockSession("idle");
}
if (!reconcileSessionState(Date.now())) return;
scheduleIdleCheck();
};
@@ -131,6 +139,14 @@ export const initSessionManager = () => {
markUserActive(now);
};
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
window.addEventListener("focus", () => {
checkIdle();
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
checkIdle();
}
});
if (channel) {
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
}
@@ -150,6 +166,7 @@ export const initSessionManager = () => {
export const markUserActive = (at: number = Date.now()) => {
const session = useSessionStore();
if (session.locked) return;
if (!reconcileSessionState(at)) return;
session.recordUserActivity(at);
broadcast({ type: "ACTIVE", at });
scheduleIdleCheck();
@@ -158,6 +175,7 @@ export const markUserActive = (at: number = Date.now()) => {
export const markNetworkActive = (at: number = Date.now()) => {
const session = useSessionStore();
if (session.locked) return;
if (!reconcileSessionState(at)) return;
session.recordNetworkActivity(at);
broadcast({ type: "NETWORK", at });
scheduleIdleCheck();
@@ -169,7 +187,7 @@ export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid")
if (session.locked) return;
const email = auth.user?.email || "";
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs;
const deadlineAt = getLastActiveAt(session) + autoLogoutMs;
session.lock(reason, email, deadlineAt);
broadcast({ type: "LOCK", reason, email, deadlineAt });
scheduleIdleCheck();
@@ -264,7 +282,7 @@ export const startTokenKeepAlive = () => {
if (!expAt) return;
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs;
const active = Date.now() - getLastActiveAt(session) < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early");
}