소스 검색

RHL-032 feat(session): add SessionIndicator for inline session validation and improve AuthProvider for background revalidation

Code_Uwe 1 주 전
부모
커밋
2503231420

+ 7 - 12
components/app-shell/QuickNav.jsx

@@ -44,13 +44,15 @@ export default function QuickNav() {
 	const router = useRouter();
 	const pathname = usePathname() || "/";
 
-	const { status, user } = useAuth();
+	const { status, user, retry } = useAuth();
 
 	const isAuthenticated = status === "authenticated" && user;
 	const isAdminDev =
 		isAuthenticated && (user.role === "admin" || user.role === "dev");
 	const isBranchUser = isAuthenticated && user.role === "branch";
 
+	const canRevalidate = typeof retry === "function";
+
 	const [selectedBranch, setSelectedBranch] = React.useState(null);
 
 	const [branchList, setBranchList] = React.useState({
@@ -68,20 +70,17 @@ export default function QuickNav() {
 	React.useEffect(() => {
 		if (!isAuthenticated) return;
 
-		// Branch users: selection is fixed to their own branch.
 		if (isBranchUser) {
 			const own = user.branchId;
 			setSelectedBranch(own && isValidBranchParam(own) ? own : null);
 			return;
 		}
 
-		// Admin/dev: prefer current route branch, fallback to last-used localStorage.
 		const fromRoute = readRouteBranchFromPathname(pathname);
 		const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
 
 		const initial = fromRoute || fromStorage || null;
 
-		// Avoid unnecessary state updates.
 		if (initial && initial !== selectedBranch) {
 			setSelectedBranch(initial);
 			safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, initial);
@@ -89,8 +88,6 @@ export default function QuickNav() {
 	}, [isAuthenticated, isBranchUser, user?.branchId, pathname, selectedBranch]);
 
 	React.useEffect(() => {
-		// Fetch the branch list once for admin/dev users (or when the user changes),
-		// not on every selectedBranch change.
 		if (!isAdminDev) return;
 
 		let cancelled = false;
@@ -118,8 +115,6 @@ export default function QuickNav() {
 	}, [isAdminDev, user?.userId]);
 
 	React.useEffect(() => {
-		// After we have the branch list, ensure selectedBranch is valid and known.
-		// This effect does NOT trigger any refetches (only local state).
 		if (!isAdminDev) return;
 		if (branchList.status !== BRANCH_LIST_STATE.READY) return;
 
@@ -152,9 +147,6 @@ export default function QuickNav() {
 	function navigateToBranchKeepingContext(nextBranch) {
 		if (!isValidBranchParam(nextBranch)) return;
 
-		// IMPORTANT:
-		// Avoid useSearchParams() here to prevent build-time prerender failures on static routes.
-		// We only need the current query string at click-time (client-only), so window is fine.
 		const currentPathname =
 			typeof window !== "undefined"
 				? window.location.pathname || pathname
@@ -171,7 +163,10 @@ export default function QuickNav() {
 
 		if (!nextUrl) return;
 
-		// Client navigation: keeps providers mounted and avoids hard reload flicker.
+		// Optional but desired (RHL-032):
+		// Trigger a session revalidation without causing content flicker.
+		if (canRevalidate) retry();
+
 		router.push(nextUrl);
 	}
 

+ 40 - 0
components/app-shell/SessionIndicator.jsx

@@ -0,0 +1,40 @@
+"use client";
+
+import React from "react";
+import { Loader2 } from "lucide-react";
+
+import { useAuth } from "@/components/auth/authContext";
+
+/**
+ * SessionIndicator (RHL-032)
+ *
+ * Shows a small inline indicator when:
+ * - the initial session check is running (status === "loading")
+ * - a background revalidation is running (isValidating === true)
+ *
+ * UX:
+ * - Keep it subtle and non-blocking.
+ * - Text is German.
+ */
+export default function SessionIndicator() {
+	const { status, isValidating } = useAuth();
+
+	const show = status === "loading" || Boolean(isValidating);
+	if (!show) return null;
+
+	return (
+		<div
+			className="flex items-center gap-2"
+			aria-live="polite"
+			title="Sitzung wird geprüft"
+		>
+			<Loader2
+				className="h-4 w-4 animate-spin text-muted-foreground"
+				aria-hidden="true"
+			/>
+			<span className="hidden text-xs text-muted-foreground md:inline">
+				Sitzung wird geprüft…
+			</span>
+		</div>
+	);
+}

+ 4 - 7
components/app-shell/TopNav.jsx

@@ -5,6 +5,7 @@ import Image from "next/image";
 import QuickNav from "@/components/app-shell/QuickNav";
 import UserStatus from "@/components/app-shell/UserStatus";
 import ThemeToggleButton from "@/components/app-shell/ThemeToggleButton";
+import SessionIndicator from "@/components/app-shell/SessionIndicator";
 
 export default function TopNav() {
 	return (
@@ -18,11 +19,6 @@ export default function TopNav() {
 								title="Startseite"
 								className="hover:cursor-pointer"
 							>
-								{/* 
-									Logo rendering:
-									- Use `fill` so we don't need to hardcode width/height for string src.
-									- This avoids Next/Image runtime errors and keeps aspect ratio via object-contain. :contentReference[oaicite:2]{index=2}
-								*/}
 								<div className="relative h-10 w-16">
 									<Image
 										src="/brand/logo-blackNav.png"
@@ -49,10 +45,11 @@ export default function TopNav() {
 						</div>
 
 						<div className="flex items-center gap-3">
-							{/* Icon-only theme toggle */}
+							{/* New: inline session validation indicator (no content flicker) */}
+							<SessionIndicator />
+
 							<ThemeToggleButton />
 
-							{/* User is now the dropdown trigger (settings/logout) */}
 							<UserStatus />
 						</div>
 					</div>

+ 7 - 35
components/auth/AuthGate.jsx

@@ -1,7 +1,7 @@
 "use client";
 
 import React from "react";
-import { Loader2, RefreshCw } from "lucide-react";
+import { RefreshCw } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { Button } from "@/components/ui/button";
@@ -15,21 +15,6 @@ import {
 	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();
 
@@ -77,6 +62,7 @@ export default function AuthGate({ children }) {
 	}
 
 	// "unauthenticated" -> redirect happens in AuthProvider.
+	// Keeping this message is fine because TopNav indicator is not shown in this state.
 	if (status === "unauthenticated") {
 		return (
 			<Card>
@@ -88,29 +74,15 @@ export default function AuthGate({ children }) {
 				</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>
+					<p className="text-sm text-muted-foreground">Bitte warten…</p>
 				</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>
-	);
+	// RHL-032:
+	// Do not render a second "session checking" UI here.
+	// The TopNav SessionIndicator is the single source of feedback.
+	return null;
 }

+ 51 - 25
components/auth/AuthProvider.jsx

@@ -9,24 +9,16 @@ import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { AuthProvider as AuthContextProvider } from "@/components/auth/authContext";
 
 /**
- * AuthProvider (RHL-020)
+ * AuthProvider (RHL-020 / RHL-032)
  *
- * Responsibilities:
- * - Run a session check via GET /api/auth/me.
- * - Store the result in AuthContext.
- * - Redirect to /login when unauthenticated (reason=expired, next=current URL).
+ * RHL-032 improvement:
+ * - When revalidating while already authenticated, we keep:
+ *   - status="authenticated"
+ *   - user != null
+ * - and only flip `isValidating=true`
  *
- * 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
+ * This prevents AuthGate from rendering the "Sitzung wird geprüft" content card
+ * during fast navigations (branch switch), eliminating flicker.
  */
 export default function AuthProvider({ children }) {
 	const router = useRouter();
@@ -39,6 +31,7 @@ export default function AuthProvider({ children }) {
 		status: "loading",
 		user: null,
 		error: null,
+		isValidating: false,
 	});
 
 	// Retry tick triggers a refetch without tying auth checks to route changes.
@@ -52,26 +45,47 @@ export default function AuthProvider({ children }) {
 		let cancelled = false;
 
 		async function runSessionCheck() {
-			setAuth({ status: "loading", user: null, error: null });
+			// If we already have a valid authenticated session, revalidate in the background:
+			// keep content stable and only set isValidating=true.
+			setAuth((prev) => {
+				const hasUser = prev.status === "authenticated" && prev.user;
+				if (hasUser) {
+					return { ...prev, error: null, isValidating: true };
+				}
+				return {
+					status: "loading",
+					user: null,
+					error: null,
+					isValidating: false,
+				};
+			});
 
 			try {
 				const res = await getMe();
 				if (cancelled) return;
 
 				if (res?.user) {
-					// Authenticated session.
 					didRedirectRef.current = false;
-					setAuth({ status: "authenticated", user: res.user, error: null });
+					setAuth({
+						status: "authenticated",
+						user: res.user,
+						error: null,
+						isValidating: false,
+					});
 					return;
 				}
 
 				// Unauthenticated session (frontend-friendly endpoint returns 200 + user:null).
-				setAuth({ status: "unauthenticated", user: null, error: null });
+				setAuth({
+					status: "unauthenticated",
+					user: null,
+					error: null,
+					isValidating: false,
+				});
 
 				if (!didRedirectRef.current) {
 					didRedirectRef.current = true;
 
-					// 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}`
@@ -87,10 +101,22 @@ export default function AuthProvider({ children }) {
 			} catch (err) {
 				if (cancelled) return;
 
-				setAuth({
-					status: "error",
-					user: null,
-					error: "Sitzung konnte nicht geprüft werden. Bitte erneut versuchen.",
+				// If we were already authenticated, fail open:
+				// keep the last known user/session and just end validating.
+				setAuth((prev) => {
+					const hasUser = prev.status === "authenticated" && prev.user;
+					if (hasUser) {
+						return { ...prev, isValidating: false };
+					}
+
+					// Initial load failed -> show error state (AuthGate handles it).
+					return {
+						status: "error",
+						user: null,
+						error:
+							"Sitzung konnte nicht geprüft werden. Bitte erneut versuchen.",
+						isValidating: false,
+					};
 				});
 			}
 		}

+ 4 - 5
components/auth/authContext.jsx

@@ -10,11 +10,8 @@ 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.
+ *   - isValidating: boolean (true while a background session re-check runs)
+ *   - retry: () => void | null (re-run the session check)
  */
 
 /**
@@ -33,6 +30,7 @@ import React from "react";
  * @property {AuthStatus} status
  * @property {AuthUser|null} user
  * @property {string|null} error
+ * @property {boolean} isValidating
  * @property {(() => void)|null} retry
  */
 
@@ -41,6 +39,7 @@ export const DEFAULT_AUTH_STATE = Object.freeze({
 	status: "unknown",
 	user: null,
 	error: null,
+	isValidating: false,
 	retry: null,
 });