| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- // ---------------------------------------------------------------------------
- // Folder: components/auth
- // File: AuthProvider.jsx
- // Relative Path: components/auth/AuthProvider.jsx
- // ---------------------------------------------------------------------------
- "use client";
- import React from "react";
- import { usePathname, useRouter, useSearchParams } from "next/navigation";
- import { Loader2 } from "lucide-react";
- import { getMe } from "@/lib/frontend/apiClient";
- import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
- import { AuthProvider as AuthContextProvider } from "@/components/auth/authContext";
- import { Button } from "@/components/ui/button";
- import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
- /**
- * AuthProvider (RHL-020)
- *
- * Best-practice approach (per Next.js docs):
- * - We use Next.js navigation hooks (usePathname/useSearchParams/useRouter)
- * to build a correct `next` parameter and redirect smoothly.
- * - IMPORTANT: When `useSearchParams()` is used, this component MUST be rendered
- * under a <Suspense> boundary for production builds where the route can be
- * statically prerendered. The Suspense boundary is added in the protected layout.
- *
- * Responsibilities:
- * - Run session check via GET /api/auth/me.
- * - If authenticated: provide { status: "authenticated", user } and render children.
- * - If unauthenticated: redirect to /login?reason=expired&next=<current-url>.
- * - Show a loading UI while checking.
- * - Show an error UI with a retry button if the check fails.
- *
- * @param {{ children: React.ReactNode }} props
- */
- export default function AuthProvider({ children }) {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- // Create a stable string representation of search params for effect deps.
- const search = searchParams ? searchParams.toString() : "";
- // Prevent double redirects in quick re-renders.
- const didRedirectRef = React.useRef(false);
- // Auth state exposed via context.
- const [auth, setAuth] = React.useState({
- status: "loading",
- user: null,
- error: null,
- });
- // Retry tick for the error UI.
- const [retryTick, setRetryTick] = React.useState(0);
- React.useEffect(() => {
- let cancelled = false;
- async function runSessionCheck() {
- setAuth({ status: "loading", user: null, error: null });
- try {
- const res = await getMe();
- if (cancelled) return;
- // /api/auth/me:
- // - 200 { user: null } when unauthenticated
- // - 200 { user: {...} } when authenticated
- if (res?.user) {
- setAuth({ status: "authenticated", user: res.user, error: null });
- return;
- }
- setAuth({ status: "unauthenticated", user: null, error: null });
- if (!didRedirectRef.current) {
- didRedirectRef.current = true;
- // Build `next` from current URL (path + query).
- const next = search ? `${pathname}?${search}` : pathname;
- const loginUrl = buildLoginUrl({
- reason: LOGIN_REASONS.EXPIRED,
- next,
- });
- // Replace history so back button won't bounce into a protected page.
- router.replace(loginUrl);
- }
- } catch (err) {
- if (cancelled) return;
- // Do not redirect blindly on failures (backend might be down).
- setAuth({
- status: "error",
- user: null,
- error: "Unable to verify your session. Please try again.",
- });
- }
- }
- runSessionCheck();
- return () => {
- cancelled = true;
- };
- }, [router, pathname, search, retryTick]);
- // -----------------------------------------------------------------------
- // Render gating
- // -----------------------------------------------------------------------
- if (auth.status === "loading") {
- return (
- <AuthContextProvider value={auth}>
- <div className="min-h-screen w-full px-4">
- <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
- <div className="flex items-center gap-3 text-sm text-muted-foreground">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span>Checking session...</span>
- </div>
- </div>
- </div>
- </AuthContextProvider>
- );
- }
- if (auth.status === "error") {
- return (
- <AuthContextProvider value={auth}>
- <div className="min-h-screen w-full px-4">
- <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
- <div className="w-full space-y-4">
- <Alert variant="destructive">
- <AlertTitle>Session check failed</AlertTitle>
- <AlertDescription>{auth.error}</AlertDescription>
- </Alert>
- <Button
- type="button"
- className="w-full"
- onClick={() => setRetryTick((n) => n + 1)}
- >
- Retry
- </Button>
- </div>
- </div>
- </div>
- </AuthContextProvider>
- );
- }
- if (auth.status === "unauthenticated") {
- // Redirect happens in useEffect. Show a tiny placeholder meanwhile.
- return (
- <AuthContextProvider value={auth}>
- <div className="min-h-screen w-full px-4">
- <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
- <div className="flex items-center gap-3 text-sm text-muted-foreground">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span>Redirecting to login...</span>
- </div>
- </div>
- </div>
- </AuthContextProvider>
- );
- }
- // Authenticated
- return <AuthContextProvider value={auth}>{children}</AuthContextProvider>;
- }
|