修复退出后历史缓存恢复旧控制台并阻止过期会话响应

This commit is contained in:
xuans 2026-09-27 01:47:45 +08:00
parent eb5951accb
commit 41d6137716
3 changed files with 120 additions and 14 deletions

View File

@ -1,4 +1,4 @@
import { onMounted, ref } from "vue";
import { onBeforeUnmount, onMounted, ref } from "vue";
export function useAppShell() {
const setupState = ref(null);
@ -8,9 +8,13 @@ export function useAppShell() {
const error = ref("");
const routePath = window.location.pathname.replace(/\/+$/, "") || "/";
const standalonePaths = ["/login", "/password-reset", "/reset"];
let bootstrapVersion = 0;
async function bootstrap() {
const version = ++bootstrapVersion;
bootstrapping.value = true;
authenticated.value = null;
isAdmin.value = null;
error.value = "";
try {
let response = await fetch("/api/v1/bootstrap/status", { cache: "no-store" });
@ -37,36 +41,39 @@ export function useAppShell() {
if (!settings.ok) throw new Error("请使用服务器 bootstrap-url 文件中的完整一次性地址访问,或在原浏览器中继续初始化。");
result = await settings.json();
}
if (version !== bootstrapVersion) return;
const session = result.initialized
? await checkAuth()
: { authenticated: false, isAdmin: false };
if (version !== bootstrapVersion) return;
setupState.value = result;
if (result.initialized) {
await checkAuth();
} else {
authenticated.value = false;
isAdmin.value = false;
}
authenticated.value = session.authenticated;
isAdmin.value = session.isAdmin;
} catch (err) {
if (version !== bootstrapVersion) return;
setupState.value = null;
authenticated.value = null;
isAdmin.value = null;
error.value = err.message || "无法连接核心服务,请先启动 scert serve";
} finally {
bootstrapping.value = false;
if (version === bootstrapVersion) bootstrapping.value = false;
}
}
async function checkAuth() {
const response = await fetch("/api/v1/auth/session", {
credentials: "include",
cache: "no-store",
});
if (response.status === 401) {
authenticated.value = false;
isAdmin.value = false;
return;
return { authenticated: false, isAdmin: false };
}
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "读取登录状态失败");
authenticated.value = result.authenticated === true;
isAdmin.value = result.is_admin === true || ["super_admin", "admin"].includes(result.role);
return {
authenticated: result.authenticated === true,
isAdmin: result.is_admin === true || ["super_admin", "admin"].includes(result.role),
};
}
function handleInitialized(result) {
@ -91,14 +98,34 @@ export function useAppShell() {
isAdmin.value = false;
}
function handlePageHide() {
// 历史缓存不保留已验证权限,旧请求也不能恢复离开前的登录状态。
bootstrapVersion += 1;
authenticated.value = null;
isAdmin.value = null;
bootstrapping.value = true;
}
function handlePageShow(event) {
if (event.persisted) bootstrap();
}
onMounted(() => {
if (standalonePaths.includes(routePath)) {
bootstrapping.value = false;
return;
}
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("pageshow", handlePageShow);
bootstrap();
});
onBeforeUnmount(() => {
bootstrapVersion += 1;
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("pageshow", handlePageShow);
});
return {
setupState,
authenticated,

View File

@ -1,7 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import { useAppShell } from "./App.js";
import { mountComposable } from "./test-support/mount.js";
import { deferred, mountComposable } from "./test-support/mount.js";
function dispatchPageEvent(type, persisted = true) {
const event = new Event(type);
Object.defineProperty(event, "persisted", { value: persisted });
window.dispatchEvent(event);
}
test("历史缓存恢复先隐藏旧控制台,再按真实会话显示登录页", async (t) => {
let active = true;
const sessionOptions = [];
const { state } = await mountComposable(t, useAppShell, async (path, options) => {
if (path.endsWith("/status")) return Response.json({ initialized: true });
sessionOptions.push(options);
return active ? Response.json({ authenticated: true, is_admin: true }) : new Response(null, { status: 401 });
});
assert.equal(state.authenticated.value, true);
dispatchPageEvent("pagehide");
assert.equal(state.authenticated.value, null);
assert.equal(state.bootstrapping.value, true);
active = false;
dispatchPageEvent("pageshow");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(state.authenticated.value, false);
assert.equal(state.isAdmin.value, false);
assert.equal(state.bootstrapping.value, false);
assert.equal(sessionOptions.length, 2);
assert.ok(sessionOptions.every((options) => options.cache === "no-store" && options.credentials === "include"));
});
test("非缓存页面显示不重复检查,卸载后移除恢复监听", async (t) => {
let calls = 0;
const { unmount } = await mountComposable(t, useAppShell, async (path) => {
calls += 1;
return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true });
});
assert.equal(calls, 2);
dispatchPageEvent("pageshow", false);
assert.equal(calls, 2);
unmount();
dispatchPageEvent("pageshow");
assert.equal(calls, 2);
});
test("恢复期间网络失败不恢复旧权限或业务界面", async (t) => {
let failed = false;
const { state } = await mountComposable(t, useAppShell, async (path) => {
if (failed) throw new Error("连接失败");
return Response.json(path.endsWith("/status") ? { initialized: true } : { authenticated: true, is_admin: true });
});
failed = true;
dispatchPageEvent("pageshow");
assert.equal(state.authenticated.value, null);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(state.setupState.value, null);
assert.equal(state.isAdmin.value, null);
assert.equal(state.error.value, "连接失败");
});
test("离开页面前的在途会话响应不能覆盖恢复后的未登录状态", async (t) => {
const pending = deferred();
let sessionCalls = 0;
const { state } = await mountComposable(t, useAppShell, async (path) => {
if (path.endsWith("/status")) return Response.json({ initialized: true });
sessionCalls += 1;
return sessionCalls === 1 ? pending.promise : new Response(null, { status: 401 });
});
dispatchPageEvent("pagehide");
dispatchPageEvent("pageshow");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(state.authenticated.value, false);
pending.resolve(Response.json({ authenticated: true, is_admin: true }));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(state.authenticated.value, false);
assert.equal(state.isAdmin.value, false);
});
test("初始化令牌兑换前清除地址片段,后续请求只用同源会话", async (t) => {
const calls = [];

View File

@ -28,12 +28,16 @@ export function verifiedResponse(token = "test-token") {
// 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。
export async function mountComposable(t, factory, fetcher) {
const windowEvents = new EventTarget();
const intervals = new Map();
let nextIntervalID = 1;
let currentTime = Date.now();
const originals = new Map();
for (const [key, value] of Object.entries({
window: {
addEventListener: windowEvents.addEventListener.bind(windowEvents),
removeEventListener: windowEvents.removeEventListener.bind(windowEvents),
dispatchEvent: windowEvents.dispatchEvent.bind(windowEvents),
setInterval(callback) {
const intervalID = nextIntervalID;
nextIntervalID += 1;