AuthProvider.jsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. // ---------------------------------------------------------------------------
  2. // Folder: components/auth
  3. // File: AuthProvider.jsx
  4. // Relative Path: components/auth/AuthProvider.jsx
  5. // ---------------------------------------------------------------------------
  6. "use client";
  7. import React from "react";
  8. import { usePathname, useRouter, useSearchParams } from "next/navigation";
  9. import { Loader2 } from "lucide-react";
  10. import { getMe } from "@/lib/frontend/apiClient";
  11. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  12. import { AuthProvider as AuthContextProvider } from "@/components/auth/authContext";
  13. import { Button } from "@/components/ui/button";
  14. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  15. /**
  16. * AuthProvider (RHL-020)
  17. *
  18. * Best-practice approach (per Next.js docs):
  19. * - We use Next.js navigation hooks (usePathname/useSearchParams/useRouter)
  20. * to build a correct `next` parameter and redirect smoothly.
  21. * - IMPORTANT: When `useSearchParams()` is used, this component MUST be rendered
  22. * under a <Suspense> boundary for production builds where the route can be
  23. * statically prerendered. The Suspense boundary is added in the protected layout.
  24. *
  25. * Responsibilities:
  26. * - Run session check via GET /api/auth/me.
  27. * - If authenticated: provide { status: "authenticated", user } and render children.
  28. * - If unauthenticated: redirect to /login?reason=expired&next=<current-url>.
  29. * - Show a loading UI while checking.
  30. * - Show an error UI with a retry button if the check fails.
  31. *
  32. * @param {{ children: React.ReactNode }} props
  33. */
  34. export default function AuthProvider({ children }) {
  35. const router = useRouter();
  36. const pathname = usePathname();
  37. const searchParams = useSearchParams();
  38. // Create a stable string representation of search params for effect deps.
  39. const search = searchParams ? searchParams.toString() : "";
  40. // Prevent double redirects in quick re-renders.
  41. const didRedirectRef = React.useRef(false);
  42. // Auth state exposed via context.
  43. const [auth, setAuth] = React.useState({
  44. status: "loading",
  45. user: null,
  46. error: null,
  47. });
  48. // Retry tick for the error UI.
  49. const [retryTick, setRetryTick] = React.useState(0);
  50. React.useEffect(() => {
  51. let cancelled = false;
  52. async function runSessionCheck() {
  53. setAuth({ status: "loading", user: null, error: null });
  54. try {
  55. const res = await getMe();
  56. if (cancelled) return;
  57. // /api/auth/me:
  58. // - 200 { user: null } when unauthenticated
  59. // - 200 { user: {...} } when authenticated
  60. if (res?.user) {
  61. setAuth({ status: "authenticated", user: res.user, error: null });
  62. return;
  63. }
  64. setAuth({ status: "unauthenticated", user: null, error: null });
  65. if (!didRedirectRef.current) {
  66. didRedirectRef.current = true;
  67. // Build `next` from current URL (path + query).
  68. const next = search ? `${pathname}?${search}` : pathname;
  69. const loginUrl = buildLoginUrl({
  70. reason: LOGIN_REASONS.EXPIRED,
  71. next,
  72. });
  73. // Replace history so back button won't bounce into a protected page.
  74. router.replace(loginUrl);
  75. }
  76. } catch (err) {
  77. if (cancelled) return;
  78. // Do not redirect blindly on failures (backend might be down).
  79. setAuth({
  80. status: "error",
  81. user: null,
  82. error: "Unable to verify your session. Please try again.",
  83. });
  84. }
  85. }
  86. runSessionCheck();
  87. return () => {
  88. cancelled = true;
  89. };
  90. }, [router, pathname, search, retryTick]);
  91. // -----------------------------------------------------------------------
  92. // Render gating
  93. // -----------------------------------------------------------------------
  94. if (auth.status === "loading") {
  95. return (
  96. <AuthContextProvider value={auth}>
  97. <div className="min-h-screen w-full px-4">
  98. <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
  99. <div className="flex items-center gap-3 text-sm text-muted-foreground">
  100. <Loader2 className="h-4 w-4 animate-spin" />
  101. <span>Checking session...</span>
  102. </div>
  103. </div>
  104. </div>
  105. </AuthContextProvider>
  106. );
  107. }
  108. if (auth.status === "error") {
  109. return (
  110. <AuthContextProvider value={auth}>
  111. <div className="min-h-screen w-full px-4">
  112. <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
  113. <div className="w-full space-y-4">
  114. <Alert variant="destructive">
  115. <AlertTitle>Session check failed</AlertTitle>
  116. <AlertDescription>{auth.error}</AlertDescription>
  117. </Alert>
  118. <Button
  119. type="button"
  120. className="w-full"
  121. onClick={() => setRetryTick((n) => n + 1)}
  122. >
  123. Retry
  124. </Button>
  125. </div>
  126. </div>
  127. </div>
  128. </AuthContextProvider>
  129. );
  130. }
  131. if (auth.status === "unauthenticated") {
  132. // Redirect happens in useEffect. Show a tiny placeholder meanwhile.
  133. return (
  134. <AuthContextProvider value={auth}>
  135. <div className="min-h-screen w-full px-4">
  136. <div className="mx-auto flex min-h-screen max-w-md items-center justify-center">
  137. <div className="flex items-center gap-3 text-sm text-muted-foreground">
  138. <Loader2 className="h-4 w-4 animate-spin" />
  139. <span>Redirecting to login...</span>
  140. </div>
  141. </div>
  142. </div>
  143. </AuthContextProvider>
  144. );
  145. }
  146. // Authenticated
  147. return <AuthContextProvider value={auth}>{children}</AuthContextProvider>;
  148. }