feat(web): add SessionWatcher component for session management and handle session expiration

This commit is contained in:
2026-03-11 17:08:52 +08:00
parent 7bd4e379de
commit f1932676be
4 changed files with 51 additions and 0 deletions

View File

@@ -1,10 +1,12 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import { ReactQueryProvider } from "@/components/query-provider"; import { ReactQueryProvider } from "@/components/query-provider";
import { SessionWatcher } from "@/components/session-watcher";
export default function DashboardLayout({ children }: { children: ReactNode }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="flex h-dvh bg-background"> <div className="flex h-dvh bg-background">
<SessionWatcher />
<Sidebar /> <Sidebar />
{/* Main content */} {/* Main content */}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist_Mono, Noto_Sans, Saira } from "next/font/google"; import { Geist_Mono, Noto_Sans, Saira } from "next/font/google";
import { Toast } from "@heroui/react";
import "./globals.css"; import "./globals.css";
const fontSaira = Saira({ const fontSaira = Saira({
@@ -33,6 +34,7 @@ export default function RootLayout({
<body <body
className={`${fontSaira.variable} ${fontNotoSans.variable} ${fontMono.variable} antialiased`} className={`${fontSaira.variable} ${fontNotoSans.variable} ${fontMono.variable} antialiased`}
> >
<Toast.Provider />
{children} {children}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,44 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@heroui/react";
import { useSession, signOut } from "@/lib/auth-client";
export function SessionWatcher() {
const router = useRouter();
const { data: session, isPending } = useSession();
const wasLoggedIn = useRef(false);
const isHandling = useRef(false);
const handleExpired = useCallback(async () => {
if (isHandling.current) return;
isHandling.current = true;
try {
await signOut({ fetchOptions: { credentials: "include" } });
} catch {
// ignore sign-out errors
}
toast.warning("登录已过期,请重新登录");
router.push("/login");
}, [router]);
// Detect session becoming null after being valid (e.g. server-side expiry)
useEffect(() => {
if (isPending) return;
if (session) {
wasLoggedIn.current = true;
} else if (wasLoggedIn.current) {
handleExpired();
}
}, [session, isPending, handleExpired]);
// Detect 401 responses from the API (dispatched by apiFetch)
useEffect(() => {
const handler = () => handleExpired();
window.addEventListener("session:expired", handler);
return () => window.removeEventListener("session:expired", handler);
}, [handleExpired]);
return null;
}

View File

@@ -10,6 +10,9 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("session:expired"));
}
const text = await res.text().catch(() => res.statusText); const text = await res.text().catch(() => res.statusText);
throw new Error(`API ${path} failed (${res.status}): ${text}`); throw new Error(`API ${path} failed (${res.status}): ${text}`);
} }