session-provider.tsx3.8 KBView on GitHub
/**
 * SessionGuard - Authentication gate without context
 *
 * Simply checks if user is authenticated and shows loading/redirect states.
 * No context provider = no re-renders when session changes.
 * Components can use useSession() directly from better-auth for session data.
 *
 * Benefits:
 * - Zero re-renders from session state changes
 * - Simple authentication gate at layout level
 * - Better-auth handles all session management
 * - Components get session data directly when needed
 */

import { useSession } from '@/modules/auth/utils/auth-client';
import { useEffect, useState, type PropsWithChildren } from 'react';
import { Link, Navigate } from 'react-router';

export function SessionGuard({ children }: PropsWithChildren) {
  const { data: session, isPending, error } = useSession();

  // Loading state - show spinner while checking session
  if (isPending) {
    return (
      <div className="flex h-screen w-screen items-center justify-center">
        <div className="text-center">
          <div className="border-accent mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
          <div className="text-muted-foreground text-sm">Verifying authentication...</div>
        </div>
      </div>
    );
  }

  // Distinguish transient errors (network / 5xx) from explicit auth rejection.
  // BetterFetchError always has a numeric `status`; treat 0/missing as network
  // failure, 5xx as transient backend, and 4xx as a real auth rejection.
  if (error) {
    const status = error.status ?? 0;
    const isTransient = status === 0 || status >= 500;

    if (!isTransient) {
      console.warn('[SessionGuard] Session fetch returned', status, '— redirecting to login');
      return <Navigate to="/login" replace />;
    }

    // Transient error with a cached session: keep rendering — better-auth will
    // retry in the background. Children see the last-known session via useSession().
    if (session) {
      console.warn('[SessionGuard] Transient session fetch error, keeping user in place:', error);
      return <>{children}</>;
    }

    // Transient error with no cached session: showing children would render the
    // app with a null user (broken inbox, failing tRPC calls). Show a retry
    // state with an escape hatch — manual link, plus a 30s timeout that falls
    // back to /login so a persistent backend outage doesn't strand the user.
    console.warn('[SessionGuard] Transient session fetch error and no cached session:', error);
    return <TransientSessionErrorFallback />;
  }

  if (!session) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

function TransientSessionErrorFallback() {
  const [shouldRedirect, setShouldRedirect] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setShouldRedirect(true), 30_000);
    return () => clearTimeout(timer);
  }, []);

  if (shouldRedirect) {
    return <Navigate to="/login" replace />;
  }

  return (
    <div className="flex h-screen w-screen items-center justify-center">
      <div className="text-center">
        <div className="border-accent mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
        <div className="text-muted-foreground text-sm">Connection problem, retrying…</div>
        <Link to="/login" replace className="text-muted-foreground mt-3 inline-block text-sm underline">
          Go to login
        </Link>
      </div>
    </div>
  );
}

/**
 * Hook to manually invalidate session cache (e.g., on logout)
 * Components can use useSession() directly for session data
 */
export function useInvalidateSession() {
  const { refetch } = useSession();
  return () => {
    // Trigger a refetch of the session data
    refetch();
  };
}

// Legacy export for backward compatibility - prefer using useSession() directly
export const SessionProvider = SessionGuard;