|
|
@@ -1,42 +1,35 @@
|
|
|
"use client";
|
|
|
|
|
|
import React from "react";
|
|
|
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
|
-import { Loader2 } from "lucide-react";
|
|
|
+import { useRouter } from "next/navigation";
|
|
|
|
|
|
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.
|
|
|
+ * - Run a session check via GET /api/auth/me.
|
|
|
+ * - Store the result in AuthContext.
|
|
|
+ * - Redirect to /login when unauthenticated (reason=expired, next=current URL).
|
|
|
+ *
|
|
|
+ * Important UX improvement:
|
|
|
+ * - We no longer render full-screen "solo spinners" here.
|
|
|
+ * - The AppShell stays visible and AuthGate renders the loading/error UI inside main content.
|
|
|
+ *
|
|
|
+ * Important performance/UX improvement:
|
|
|
+ * - We do NOT re-check the session on every route change.
|
|
|
+ * - The session check runs:
|
|
|
+ * - once on mount
|
|
|
+ * - and again only when the user hits "retry"
|
|
|
*
|
|
|
* @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);
|
|
|
@@ -48,9 +41,13 @@ export default function AuthProvider({ children }) {
|
|
|
error: null,
|
|
|
});
|
|
|
|
|
|
- // Retry tick for the error UI.
|
|
|
+ // Retry tick triggers a refetch without tying auth checks to route changes.
|
|
|
const [retryTick, setRetryTick] = React.useState(0);
|
|
|
|
|
|
+ const retry = React.useCallback(() => {
|
|
|
+ setRetryTick((n) => n + 1);
|
|
|
+ }, []);
|
|
|
+
|
|
|
React.useEffect(() => {
|
|
|
let cancelled = false;
|
|
|
|
|
|
@@ -59,37 +56,37 @@ export default function AuthProvider({ children }) {
|
|
|
|
|
|
try {
|
|
|
const res = await getMe();
|
|
|
-
|
|
|
if (cancelled) return;
|
|
|
|
|
|
- // /api/auth/me:
|
|
|
- // - 200 { user: null } when unauthenticated
|
|
|
- // - 200 { user: {...} } when authenticated
|
|
|
if (res?.user) {
|
|
|
+ // Authenticated session.
|
|
|
+ didRedirectRef.current = false;
|
|
|
setAuth({ status: "authenticated", user: res.user, error: null });
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ // Unauthenticated session (frontend-friendly endpoint returns 200 + user:null).
|
|
|
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;
|
|
|
+ // Preserve the current URL as "next" so the user returns to the same page after login.
|
|
|
+ const next =
|
|
|
+ typeof window !== "undefined"
|
|
|
+ ? `${window.location.pathname}${window.location.search}`
|
|
|
+ : "/";
|
|
|
|
|
|
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,
|
|
|
@@ -103,68 +100,11 @@ export default function AuthProvider({ children }) {
|
|
|
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>Sitzung wird geprüft…</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>Sitzungsprüfung fehlgeschlagen</AlertTitle>
|
|
|
- <AlertDescription>{auth.error}</AlertDescription>
|
|
|
- </Alert>
|
|
|
-
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- className="w-full"
|
|
|
- onClick={() => setRetryTick((n) => n + 1)}
|
|
|
- >
|
|
|
- Erneut versuchen
|
|
|
- </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>Weiterleitung zum Login…</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </AuthContextProvider>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- // Authenticated
|
|
|
- return <AuthContextProvider value={auth}>{children}</AuthContextProvider>;
|
|
|
+ }, [router, retryTick]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AuthContextProvider value={{ ...auth, retry }}>
|
|
|
+ {children}
|
|
|
+ </AuthContextProvider>
|
|
|
+ );
|
|
|
}
|