|
|
@@ -0,0 +1,176 @@
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 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>;
|
|
|
+}
|