feat(web): integrate session management and improve API error handling
This commit is contained in:
@@ -1,12 +1,10 @@
|
|||||||
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 */}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
|
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
|
||||||
import { Thunderbolt } from "@gravity-ui/icons";
|
import { Thunderbolt } from "@gravity-ui/icons";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
|
||||||
|
|
||||||
export default function SetupPage() {
|
export default function SetupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -38,28 +37,15 @@ export default function SetupPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${CSMS_URL}/api/setup`, {
|
await api.setup.create({
|
||||||
method: "POST",
|
name: form.name,
|
||||||
headers: { "Content-Type": "application/json" },
|
email: form.email,
|
||||||
credentials: "include",
|
username: form.username,
|
||||||
body: JSON.stringify({
|
password: form.password,
|
||||||
name: form.name,
|
|
||||||
email: form.email,
|
|
||||||
username: form.username,
|
|
||||||
password: form.password,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
setError((data as { error?: string }).error ?? "初始化失败,请重试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化成功,跳转登录页
|
|
||||||
router.push("/login?setup=1");
|
router.push("/login?setup=1");
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("网络错误,请稍后重试");
|
setError(err instanceof Error ? err.message : "初始化失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -74,7 +60,7 @@ export default function SetupPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
|
||||||
<p className="mt-1 text-sm text-muted">首次启动 · 创建根管理员账号</p>
|
<p className="mt-1 text-sm text-muted">首次启动 · 创建管理员账号</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "@heroui/react";
|
||||||
|
import { signOut, useSession } from "@/lib/auth-client";
|
||||||
|
import { APIError } from "@/lib/api";
|
||||||
|
|
||||||
|
/** 监听 better-auth 会话变为 null(服务端过期/异地登出等场景)*/
|
||||||
|
function SessionMonitor({ onExpired }: { onExpired: () => void }) {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const wasLoggedIn = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPending) return;
|
||||||
|
if (session) {
|
||||||
|
wasLoggedIn.current = true;
|
||||||
|
} else if (wasLoggedIn.current) {
|
||||||
|
wasLoggedIn.current = false;
|
||||||
|
onExpired();
|
||||||
|
}
|
||||||
|
}, [session, isPending, onExpired]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
|
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Stable ref so the QueryClient (created once) always calls the latest handler
|
||||||
|
const handleExpiredRef = useRef(handleExpired);
|
||||||
|
handleExpiredRef.current = handleExpired;
|
||||||
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof APIError && error.status === 401) {
|
||||||
|
handleExpiredRef.current();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof APIError && error.status === 401) {
|
||||||
|
handleExpiredRef.current();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
@@ -14,5 +69,11 @@ export function ReactQueryProvider({ children }: { children: React.ReactNode })
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionMonitor onExpired={handleExpired} />
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
export class APIError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "APIError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -10,11 +20,8 @@ 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 APIError(res.status, `API ${path} failed (${res.status}): ${text}`);
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -253,4 +260,8 @@ export const api = {
|
|||||||
},
|
},
|
||||||
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
},
|
},
|
||||||
|
setup: {
|
||||||
|
create: (data: { name: string; email: string; username: string; password: string }) =>
|
||||||
|
apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user