Forráskód Böngészése

RHL-020 feat(auth): add AuthContext and AuthProvider for centralized authentication state management

Code_Uwe 1 hónapja
szülő
commit
b1fc01e237
2 módosított fájl, 259 hozzáadás és 0 törlés
  1. 176 0
      components/auth/AuthProvider.jsx
  2. 83 0
      components/auth/authContext.jsx

+ 176 - 0
components/auth/AuthProvider.jsx

@@ -0,0 +1,176 @@
+// ---------------------------------------------------------------------------
+// Folder: components/auth
+// File: AuthProvider.jsx
+// Relative Path: components/auth/AuthProvider.jsx
+// ---------------------------------------------------------------------------
+
+"use client";
+
+import React from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+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.
+ *
+ * @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);
+
+	// Auth state exposed via context.
+	const [auth, setAuth] = React.useState({
+		status: "loading",
+		user: null,
+		error: null,
+	});
+
+	// Retry tick for the error UI.
+	const [retryTick, setRetryTick] = React.useState(0);
+
+	React.useEffect(() => {
+		let cancelled = false;
+
+		async function runSessionCheck() {
+			setAuth({ status: "loading", user: null, error: null });
+
+			try {
+				const res = await getMe();
+
+				if (cancelled) return;
+
+				// /api/auth/me:
+				// - 200 { user: null } when unauthenticated
+				// - 200 { user: {...} } when authenticated
+				if (res?.user) {
+					setAuth({ status: "authenticated", user: res.user, error: null });
+					return;
+				}
+
+				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;
+
+					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,
+					error: "Unable to verify your session. Please try again.",
+				});
+			}
+		}
+
+		runSessionCheck();
+
+		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>Checking session...</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>Session check failed</AlertTitle>
+								<AlertDescription>{auth.error}</AlertDescription>
+							</Alert>
+
+							<Button
+								type="button"
+								className="w-full"
+								onClick={() => setRetryTick((n) => n + 1)}
+							>
+								Retry
+							</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>Redirecting to login...</span>
+						</div>
+					</div>
+				</div>
+			</AuthContextProvider>
+		);
+	}
+
+	// Authenticated
+	return <AuthContextProvider value={auth}>{children}</AuthContextProvider>;
+}

+ 83 - 0
components/auth/authContext.jsx

@@ -0,0 +1,83 @@
+// ---------------------------------------------------------------------------
+// Folder: components/auth
+// File: authContext.js
+// Relative Path: components/auth/authContext.js
+// ---------------------------------------------------------------------------
+
+"use client";
+
+import React from "react";
+
+/**
+ * Auth Context (RHL-020)
+ *
+ * Purpose:
+ * - Provide a tiny, app-wide session state for the UI:
+ *   - status: "unknown" | "loading" | "authenticated" | "unauthenticated" | "error"
+ *   - user: { userId, role, branchId } | null
+ *   - error: string | null
+ *
+ * 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).
+ */
+
+/**
+ * @typedef {"unknown"|"loading"|"authenticated"|"unauthenticated"|"error"} AuthStatus
+ */
+
+/**
+ * @typedef {Object} AuthUser
+ * @property {string} userId
+ * @property {string} role
+ * @property {string|null} branchId
+ */
+
+/**
+ * @typedef {Object} AuthState
+ * @property {AuthStatus} status
+ * @property {AuthUser|null} user
+ * @property {string|null} error
+ */
+
+/** @type {AuthState} */
+export const DEFAULT_AUTH_STATE = Object.freeze({
+	status: "unknown",
+	user: null,
+	error: 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);
+
+/**
+ * Consume the auth context.
+ *
+ * @returns {AuthState}
+ */
+export function useAuth() {
+	return React.useContext(AuthContext);
+}
+
+/**
+ * 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 }) {
+	const merged = React.useMemo(() => {
+		return { ...DEFAULT_AUTH_STATE, ...(value || {}) };
+	}, [value]);
+
+	return <AuthContext.Provider value={merged}>{children}</AuthContext.Provider>;
+}