Files
jiang13-bbs/frontend/components/AccountGuard.tsx
freefire 3e0689fe98 fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
2026-09-28 03:33:52 +08:00

162 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Ban, LogOut } from "lucide-react";
import { onForceLogout } from "@/lib/userEvents";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { hasAuthCookieHint } from "@/lib/cookies";
import { apiMe } from "@/lib/api";
import { realtime, RT_SESSION_REPLACED } from "@/lib/realtime";
import Modal from "@/components/Modal";
/**
* 账号安全守卫:监听全局强制下线事件,弹出不可误关的告知模态。
*
* 两类告知:
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
* - 登录已失效(reason=session_replaced):安全设置剔除本设备、
* 会话数超上限被淘汰等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
*
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
* 2) WS 心跳复查失败 4401(realtime 内部转一次 /api/me 探测);
* 3) 回前台对账:后台标签页错过 WS 事件,切回时经 /api/me 校正。
*
* 登录态清理(cookie/Header 游客化/router.refresh)由 Header 复用统一
* 登出链路完成;本组件只负责"告知",故不处理任何凭据逻辑。
* 挂载于 root layout,覆盖任意页面(含后台)。
*/
export default function AccountGuard() {
const router = useRouter();
const [banned, setBanned] = useState(false);
const [sessionEnded, setSessionEnded] = useState(false);
useEffect(
() =>
onForceLogout((reason) => {
if (reason === "banned") setBanned(true);
else if (reason === "session_replaced") setSessionEnded(true);
}),
[]
);
// 登录态被顶:后端 WS 即时事件 → 探测确认 → 派发 session_replaced
useEffect(
() => realtime.on(RT_SESSION_REPLACED, () => void apiMe().catch(() => {})),
[]
);
// 回前台对账(10s 限频):覆盖后台标签页错过 WS 事件的场景。
// 仅当存在登录 cookie 痕迹时探测,游客不白发请求
useEffect(
() =>
onDocumentVisible(() => {
if (hasAuthCookieHint()) void apiMe().catch(() => {});
}),
[]
);
return (
<>
<Modal
open={banned}
title="账号已被封禁"
onClose={() => {}}
hideClose
closeOnEscape={false}
role="alertdialog"
ariaLabel="账号已被封禁"
maxWidthClass="max-w-[400px]"
footer={
<button
type="button"
onClick={() => {
setBanned(false);
router.push("/");
}}
className="btn btn-primary w-full"
autoFocus
>
我知道了
</button>
}
header={
<div className="w-full text-center">
<span
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
<Ban size={26} />
</span>
<h2
id="banned-dialog-title"
className="mt-4 text-[18px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
账号已被封禁
</h2>
</div>
}
>
<p
id="banned-dialog-desc"
className="meta text-[13px] leading-relaxed text-center"
>
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
如对处理结果有异议,请联系管理员核实。
</p>
</Modal>
<Modal
open={sessionEnded}
title="登录已失效"
onClose={() => {}}
hideClose
closeOnEscape={false}
role="alertdialog"
ariaLabel="登录已失效"
maxWidthClass="max-w-[400px]"
footer={
<button
type="button"
onClick={() => {
setSessionEnded(false);
router.push("/login");
}}
className="btn btn-primary w-full"
autoFocus
>
重新登录
</button>
}
header={
<div className="w-full text-center">
<span
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<LogOut size={26} />
</span>
<h2
id="session-ended-dialog-title"
className="mt-4 text-[18px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
登录已失效
</h2>
</div>
}
>
<p
id="session-ended-dialog-desc"
className="meta text-[13px] leading-relaxed text-center"
>
你的登录会话已失效(在安全设置中剔除、会话数超出上限被淘汰,或登录有效期已过)。
请重新登录后继续使用。
</p>
</Modal>
</>
);
}