Explorar o código

RHL-022 feat(auth): implement AuthGate for improved session handling and user experience

Code_Uwe hai 1 mes
pai
achega
74a55e13ee

+ 11 - 29
app/(protected)/layout.jsx

@@ -1,40 +1,22 @@
-import React, { Suspense } from "react";
-import { Loader2 } from "lucide-react";
-
 import AppShell from "@/components/app-shell/AppShell";
 import AuthProvider from "@/components/auth/AuthProvider";
+import AuthGate from "@/components/auth/AuthGate";
 
 /**
- * Protected layout (RHL-020)
+ * Protected layout
  *
- * Best practice for `useSearchParams()`:
- * - AuthProvider uses `useSearchParams()` (client hook).
- * - During production builds, static prerendering requires a Suspense boundary
- *   above the component using `useSearchParams()`.
+ * UX goal:
+ * - Keep the AppShell visible at all times (TopNav + Sidebar).
+ * - Render auth/loading/error states inside the main content area via AuthGate.
  *
- * This Suspense fallback will be part of the initial HTML for static rendering.
- * After hydration, AuthProvider takes over and performs the session check.
+ * This avoids "blank spinner" screens on slow connections.
  */
-
-function AuthProviderFallback() {
-	return (
-		<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 vorbereitet…</span>
-				</div>
-			</div>
-		</div>
-	);
-}
-
 export default function ProtectedLayout({ children }) {
 	return (
-		<Suspense fallback={<AuthProviderFallback />}>
-			<AuthProvider>
-				<AppShell>{children}</AppShell>
-			</AuthProvider>
-		</Suspense>
+		<AuthProvider>
+			<AppShell>
+				<AuthGate>{children}</AuthGate>
+			</AppShell>
+		</AuthProvider>
 	);
 }

+ 116 - 0
components/auth/AuthGate.jsx

@@ -0,0 +1,116 @@
+"use client";
+
+import React from "react";
+import { Loader2, RefreshCw } from "lucide-react";
+
+import { useAuth } from "@/components/auth/authContext";
+import { Button } from "@/components/ui/button";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import {
+	Card,
+	CardHeader,
+	CardTitle,
+	CardDescription,
+	CardContent,
+	CardFooter,
+} from "@/components/ui/card";
+
+/**
+ * AuthGate
+ *
+ * Renders auth-related states *inside* the AppShell main area to avoid "blank spinner" screens.
+ *
+ * Why this exists:
+ * - AuthProvider owns the session check and provides AuthContext.
+ * - We want the AppShell frame (TopNav/sidebar) to remain stable while auth checks run.
+ * - This component decides what the user sees inside the main content area.
+ *
+ * UX rule:
+ * - All user-facing strings are German.
+ *
+ * @param {{ children: React.ReactNode }} props
+ */
+export default function AuthGate({ children }) {
+	const { status, error, retry } = useAuth();
+
+	const canRetry = typeof retry === "function";
+
+	if (status === "authenticated") {
+		return children;
+	}
+
+	if (status === "error") {
+		return (
+			<Card>
+				<CardHeader>
+					<CardTitle>Sitzungsprüfung fehlgeschlagen</CardTitle>
+					<CardDescription>
+						Die Sitzung konnte nicht geprüft werden.
+					</CardDescription>
+				</CardHeader>
+
+				<CardContent className="space-y-3">
+					<Alert variant="destructive">
+						<AlertTitle>Fehler</AlertTitle>
+						<AlertDescription>
+							{error ||
+								"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut."}
+						</AlertDescription>
+					</Alert>
+				</CardContent>
+
+				<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
+					<Button
+						type="button"
+						variant="outline"
+						onClick={() => {
+							if (canRetry) retry();
+							else window.location.reload();
+						}}
+					>
+						<RefreshCw className="h-4 w-4" />
+						Erneut versuchen
+					</Button>
+				</CardFooter>
+			</Card>
+		);
+	}
+
+	// "unauthenticated" -> redirect happens in AuthProvider.
+	if (status === "unauthenticated") {
+		return (
+			<Card>
+				<CardHeader>
+					<CardTitle>Weiterleitung</CardTitle>
+					<CardDescription>
+						Sie werden zum Login weitergeleitet.
+					</CardDescription>
+				</CardHeader>
+
+				<CardContent>
+					<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>
+				</CardContent>
+			</Card>
+		);
+	}
+
+	// Default: loading (or unknown)
+	return (
+		<Card>
+			<CardHeader>
+				<CardTitle>Sitzung wird geprüft</CardTitle>
+				<CardDescription>Bitte warten…</CardDescription>
+			</CardHeader>
+
+			<CardContent>
+				<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>
+			</CardContent>
+		</Card>
+	);
+}

+ 34 - 94
components/auth/AuthProvider.jsx

@@ -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>
+	);
 }

+ 13 - 18
components/auth/BranchGuard.jsx

@@ -1,7 +1,6 @@
 "use client";
 
 import React from "react";
-import { Loader2 } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { getBranches } from "@/lib/frontend/apiClient";
@@ -20,15 +19,18 @@ const BRANCH_LIST_STATE = Object.freeze({
 	ERROR: "error",
 });
 
-function BranchValidationLoading() {
-	return (
-		<div className="flex items-center gap-3 text-sm text-muted-foreground">
-			<Loader2 className="h-4 w-4 animate-spin" />
-			<span>Niederlassung wird geprüft…</span>
-		</div>
-	);
-}
-
+/**
+ * BranchGuard
+ *
+ * UX improvement:
+ * - We do NOT block rendering while admin/dev branch list is loading.
+ * - Existence validation is applied once the list is READY.
+ * - While LOADING/ERROR we fail open to avoid "solo spinner" screens.
+ *
+ * Security note:
+ * - Backend RBAC remains authoritative.
+ * - Branch users are enforced immediately (no existence check needed).
+ */
 export default function BranchGuard({ branch, children }) {
 	const { status, user } = useAuth();
 
@@ -67,18 +69,11 @@ export default function BranchGuard({ branch, children }) {
 		return () => {
 			cancelled = true;
 		};
-
-		// IMPORTANT:
-		// - depend only on user identity + needsExistenceCheck
-		// - do NOT depend on branchList.status (would cancel itself when setting LOADING)
 	}, [needsExistenceCheck, user?.userId]);
 
 	if (!isAuthenticated) return children;
 
-	if (needsExistenceCheck && branchList.status === BRANCH_LIST_STATE.LOADING) {
-		return <BranchValidationLoading />;
-	}
-
+	// Only apply existence validation when the list is READY.
 	const allowedBranches =
 		branchList.status === BRANCH_LIST_STATE.READY ? branchList.branches : null;
 

+ 3 - 17
components/auth/authContext.jsx

@@ -1,9 +1,3 @@
-// ---------------------------------------------------------------------------
-// Folder: components/auth
-// File: authContext.js
-// Relative Path: components/auth/authContext.js
-// ---------------------------------------------------------------------------
-
 "use client";
 
 import React from "react";
@@ -16,12 +10,11 @@ import React from "react";
  *   - status: "unknown" | "loading" | "authenticated" | "unauthenticated" | "error"
  *   - user: { userId, role, branchId } | null
  *   - error: string | null
+ *   - retry: () => void | null (optional callback to re-run the session check)
  *
  * Why this file exists:
  * - Keep auth state accessible without prop-drilling.
  * - Keep the context/hook independent from Next.js routing.
- * - Avoid importing next/navigation in components that only need auth state
- *   (helps SSR/unit tests and keeps dependencies clean).
  */
 
 /**
@@ -40,6 +33,7 @@ import React from "react";
  * @property {AuthStatus} status
  * @property {AuthUser|null} user
  * @property {string|null} error
+ * @property {(() => void)|null} retry
  */
 
 /** @type {AuthState} */
@@ -47,13 +41,9 @@ export const DEFAULT_AUTH_STATE = Object.freeze({
 	status: "unknown",
 	user: null,
 	error: null,
+	retry: null,
 });
 
-/**
- * The actual React context.
- * We provide a safe default so components can render even without a provider
- * (useful for SSR tests that render AppShell in isolation).
- */
 const AuthContext = React.createContext(DEFAULT_AUTH_STATE);
 
 /**
@@ -68,10 +58,6 @@ export function useAuth() {
 /**
  * Provider wrapper.
  *
- * We keep it very small and predictable:
- * - merge provided value with DEFAULT_AUTH_STATE to guarantee all fields exist
- * - avoid surprises when callers pass partial objects
- *
  * @param {{ value: Partial<AuthState>, children: React.ReactNode }} props
  */
 export function AuthProvider({ value, children }) {