AuthProvider.jsx 5.1 KB

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