131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import type { AuthMode, AuthUser } from "@/lib/auth/session";
|
|
import { fetchBrowserAuthState, safeBrowserLocationNext, type BrowserCurrentSession } from "@/lib/client/browser-auth";
|
|
|
|
type BrowserAuthStatus = "loading" | "ready" | "error";
|
|
|
|
type BrowserAuthState = {
|
|
status: BrowserAuthStatus;
|
|
user: AuthUser | null;
|
|
authMode: AuthMode | null;
|
|
authRequired: boolean;
|
|
authConfigured: boolean;
|
|
isAdmin: boolean;
|
|
isSuperAdmin: boolean;
|
|
error: string | null;
|
|
refresh: () => Promise<void>;
|
|
};
|
|
|
|
const BrowserAuthContext = createContext<BrowserAuthState | null>(null);
|
|
|
|
export function BrowserAuthProvider({ children }: { children: React.ReactNode }) {
|
|
const [session, setSession] = useState<BrowserCurrentSession | null>(null);
|
|
const [status, setStatus] = useState<BrowserAuthStatus>("loading");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadSession = useCallback(async (signal?: AbortSignal) => {
|
|
try {
|
|
const current = await fetchBrowserAuthState(signal);
|
|
setSession(current);
|
|
setStatus("ready");
|
|
setError(null);
|
|
} catch (cause) {
|
|
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
|
setStatus("error");
|
|
setError(cause instanceof Error ? cause.message : "无法读取认证状态");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
void loadSession(controller.signal);
|
|
return () => controller.abort();
|
|
}, [loadSession]);
|
|
|
|
const value = useMemo<BrowserAuthState>(() => {
|
|
const user = session?.user || null;
|
|
const authRequired = session?.authRequired ?? true;
|
|
const isSuperAdmin = !authRequired || user?.role === "super_admin";
|
|
const isAdmin = isSuperAdmin || user?.role === "organization_admin";
|
|
return {
|
|
status,
|
|
user,
|
|
authMode: session?.authMode || null,
|
|
authRequired,
|
|
authConfigured: session?.authConfigured ?? false,
|
|
isAdmin,
|
|
isSuperAdmin,
|
|
error,
|
|
refresh: () => loadSession()
|
|
};
|
|
}, [error, loadSession, session, status]);
|
|
|
|
return <BrowserAuthContext.Provider value={value}>{children}</BrowserAuthContext.Provider>;
|
|
}
|
|
|
|
export function useBrowserAuth(): BrowserAuthState {
|
|
const state = useContext(BrowserAuthContext);
|
|
if (!state) throw new Error("useBrowserAuth must be used inside BrowserAuthProvider");
|
|
return state;
|
|
}
|
|
|
|
export function BrowserAuthGuard({
|
|
children,
|
|
role = "user"
|
|
}: {
|
|
children: React.ReactNode;
|
|
role?: "user" | "admin" | "super";
|
|
}) {
|
|
const auth = useBrowserAuth();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const needsLogin = auth.status === "ready" && auth.authRequired && !auth.user;
|
|
|
|
useEffect(() => {
|
|
if (needsLogin) {
|
|
const next = safeBrowserLocationNext(window.location, pathname || "/create");
|
|
router.replace(`/auth/login?next=${encodeURIComponent(next)}`);
|
|
}
|
|
}, [needsLogin, pathname, router]);
|
|
|
|
if (auth.status === "loading" || needsLogin) {
|
|
return <AuthStatePanel message="正在确认登录状态…" />;
|
|
}
|
|
if (auth.status === "error") {
|
|
return <AuthStatePanel message={auth.error || "无法读取认证状态"} actionLabel="重试" onAction={() => void auth.refresh()} />;
|
|
}
|
|
if ((role === "admin" && !auth.isAdmin) || (role === "super" && !auth.isSuperAdmin)) {
|
|
return <AuthStatePanel message="当前账号无权访问此页面。" />;
|
|
}
|
|
return <>{children}</>;
|
|
}
|
|
|
|
export function ClientRedirect({ to, forwardNext = false }: { to: string; forwardNext?: boolean }) {
|
|
useEffect(() => {
|
|
const next = forwardNext ? new URLSearchParams(window.location.search).get("next") : null;
|
|
const destination = next ? `${to}?next=${encodeURIComponent(next)}` : to;
|
|
window.location.replace(destination);
|
|
}, [forwardNext, to]);
|
|
return <AuthStatePanel message="正在跳转…" />;
|
|
}
|
|
|
|
function AuthStatePanel({
|
|
message,
|
|
actionLabel,
|
|
onAction
|
|
}: {
|
|
message: string;
|
|
actionLabel?: string;
|
|
onAction?: () => void;
|
|
}) {
|
|
return (
|
|
<section className="panel">
|
|
<p className="muted">{message}</p>
|
|
{actionLabel && onAction ? <button className="button" type="button" onClick={onAction}>{actionLabel}</button> : null}
|
|
</section>
|
|
);
|
|
}
|