From f1932676be18d4e9ea79e7bf9e79a41d4724164b Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Wed, 11 Mar 2026 17:08:52 +0800 Subject: [PATCH] feat(web): add SessionWatcher component for session management and handle session expiration --- apps/web/app/dashboard/layout.tsx | 2 ++ apps/web/app/layout.tsx | 2 ++ apps/web/components/session-watcher.tsx | 44 +++++++++++++++++++++++++ apps/web/lib/api.ts | 3 ++ 4 files changed, 51 insertions(+) create mode 100644 apps/web/components/session-watcher.tsx diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 1befeff..770714c 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,10 +1,12 @@ import { ReactNode } from "react"; import Sidebar from "@/components/sidebar"; import { ReactQueryProvider } from "@/components/query-provider"; +import { SessionWatcher } from "@/components/session-watcher"; export default function DashboardLayout({ children }: { children: ReactNode }) { return (
+ {/* Main content */} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8b484cc..2c1aeb1 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist_Mono, Noto_Sans, Saira } from "next/font/google"; +import { Toast } from "@heroui/react"; import "./globals.css"; const fontSaira = Saira({ @@ -33,6 +34,7 @@ export default function RootLayout({ + {children} diff --git a/apps/web/components/session-watcher.tsx b/apps/web/components/session-watcher.tsx new file mode 100644 index 0000000..91b10e0 --- /dev/null +++ b/apps/web/components/session-watcher.tsx @@ -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; +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 3941d60..044a192 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -10,6 +10,9 @@ async function apiFetch(path: string, init?: RequestInit): Promise { credentials: "include", }); if (!res.ok) { + if (res.status === 401 && typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("session:expired")); + } const text = await res.text().catch(() => res.statusText); throw new Error(`API ${path} failed (${res.status}): ${text}`); }