Advertisement

← vibesdk

src/contexts/auth-context.tsx

13.0 KB Download file
/**
 * Enhanced Auth Context
 * Provides OAuth + Email/Password authentication with backward compatibility
 */

import React, {
	createContext,
	useContext,
	useState,
	useEffect,
	useCallback,
	useMemo,
} from 'react';
import { useNavigate } from 'react-router';
import {
	useMutation,
	useQuery,
	useQueryClient,
} from '@tanstack/react-query';
import { apiClient, ApiError } from '@/lib/api-client';
import { useSentryUser } from '@/hooks/useSentryUser';
import { queryKeys } from '@/lib/query-keys';
import type {
	AuthProvidersResponseData,
	AuthSession,
	AuthUser,
	LoginResponseData,
	ProfileResponseData,
	SessionResponse,
} from '../api-types';

/**
 * Cached session shape. Deliberately plain-serializable (no `Date`): TanStack's
 * structural sharing (`replaceEqualDeep`) bails out on non-plain values, so a
 * `Date` here would produce a new object identity on every poll/focus refetch
 * and re-render every `useAuth` consumer.
 */
interface CachedAuthSession {
	user: AuthUser;
	sessionId: string;
	/** ISO string; null when the endpoint does not report an expiry. */
	expiresAt: string | null;
}

interface AuthContextType {
	user: AuthUser | null;
	token: string | null;
	session: AuthSession | null;
	isAuthenticated: boolean;
	isLoading: boolean;
	error: string | null;

	// Auth provider configuration
	authProviders: {
		google: boolean;
		github: boolean;
		cloudflare: boolean;
		email: boolean;
	} | null;
	hasOAuth: boolean;
	requiresEmailAuth: boolean;

	// OAuth login method with redirect support
	login: (
		provider: 'google' | 'github' | 'cloudflare',
		redirectUrl?: string,
	) => void;

	// Email/password login method
	loginWithEmail: (credentials: {
		email: string;
		password: string;
	}) => Promise<void>;
	register: (data: {
		email: string;
		password: string;
		name?: string;
	}) => Promise<void>;

	logout: () => Promise<void>;
	refreshUser: () => Promise<void>;
	clearError: () => void;

	// Redirect URL management
	setIntendedUrl: (url: string) => void;
	getIntendedUrl: () => string | null;
	clearIntendedUrl: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Session validation interval - tokens last 24h, so check infrequently.
const TOKEN_REFRESH_INTERVAL = 60 * 60 * 1000; // 1 hour

// Assumed session lifetime when the profile endpoint reports no explicit expiry.
const ASSUMED_SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours

const DEFAULT_AUTH_PROVIDERS: AuthProvidersResponseData = {
	providers: {
		google: false,
		github: false,
		cloudflare: false,
		email: true,
	},
	hasOAuth: false,
	requiresEmailAuth: true,
};

const INTENDED_URL_KEY = 'auth_intended_url';

function buildSessionFromProfile(
	data: ProfileResponseData,
): CachedAuthSession {
	return {
		user: { ...data.user, isAnonymous: false },
		sessionId: data.sessionId || data.user.id,
		// Profile endpoint does not report expiry.
		expiresAt: null,
	};
}

function buildSessionFromLogin(data: SessionResponse): CachedAuthSession {
	return {
		user: { ...data.user, isAnonymous: false },
		sessionId: data.sessionId,
		// Typed as `Date` but arrives as an ISO string over the wire; normalize both.
		expiresAt: data.expiresAt
			? new Date(data.expiresAt).toISOString()
			: null,
	};
}

async function fetchAuthSession(): Promise<CachedAuthSession | null> {
	try {
		const response = await apiClient.getProfile(true);

		if (response.success && response.data?.user) {
			return buildSessionFromProfile(response.data);
		}

		return null;
	} catch (error) {
		// Unauthenticated is a valid state, not a hard failure.
		if (
			error instanceof ApiError &&
			(error.status === 401 || error.status === 403)
		) {
			return null;
		}

		console.error('Auth check failed:', error);
		throw error;
	}
}

async function fetchAuthProviders(): Promise<AuthProvidersResponseData> {
	// Must throw on failure: swallowing the error would cache the email-only
	// fallback as a successful result and strand OAuth-only deployments.
	const response = await apiClient.getAuthProviders();
	if (!response.success || !response.data) {
		throw new Error(
			response.error?.message || 'Failed to fetch auth providers',
		);
	}
	return response.data;
}

function getAuthActionError(error: unknown): string {
	// Covers ApiError, SecurityError and RateLimitExceededError alike.
	if (error instanceof Error && error.message) {
		return error.message;
	}
	return 'Connection error. Please try again.';
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
	const [error, setError] = useState<string | null>(null);
	const navigate = useNavigate();
	const queryClient = useQueryClient();

	const setIntendedUrl = useCallback((url: string) => {
		try {
			sessionStorage.setItem(INTENDED_URL_KEY, url);
		} catch (storageError) {
			console.warn('Failed to store intended URL:', storageError);
		}
	}, []);

	const getIntendedUrl = useCallback((): string | null => {
		try {
			return sessionStorage.getItem(INTENDED_URL_KEY);
		} catch (storageError) {
			console.warn('Failed to retrieve intended URL:', storageError);
			return null;
		}
	}, []);

	const clearIntendedUrl = useCallback(() => {
		try {
			sessionStorage.removeItem(INTENDED_URL_KEY);
		} catch (storageError) {
			console.warn('Failed to clear intended URL:', storageError);
		}
	}, []);

	const sessionQuery = useQuery({
		queryKey: queryKeys.auth.session(),
		queryFn: fetchAuthSession,
		staleTime: 5 * 60_000,
		// Periodic cookie/session validation (replaces manual setInterval).
		// Only poll while a session exists — matches prior setupTokenRefresh behavior.
		refetchInterval: (query) =>
			query.state.data ? TOKEN_REFRESH_INTERVAL : false,
		// 401/403 are already normalized to `null`, so anything thrown here is
		// transient (offline/5xx) and worth retrying rather than reporting the
		// user as signed out.
		retry: (failureCount, error) => {
			if (error instanceof ApiError && error.status < 500) {
				return false;
			}
			return failureCount < 2;
		},
	});

	const providersQuery = useQuery({
		queryKey: queryKeys.auth.providers(),
		queryFn: fetchAuthProviders,
		staleTime: 60 * 60_000,
	});

	const cachedSession = sessionQuery.data ?? null;
	const user = cachedSession?.user ?? null;

	// Derived here (not cached) so the fabricated expiry cannot churn identities.
	const session = useMemo<AuthSession | null>(() => {
		if (!cachedSession) {
			return null;
		}
		return {
			userId: cachedSession.user.id,
			email: cachedSession.user.email,
			sessionId: cachedSession.sessionId,
			expiresAt: cachedSession.expiresAt
				? new Date(cachedSession.expiresAt)
				: new Date(Date.now() + ASSUMED_SESSION_TTL),
		};
	}, [cachedSession]);

	// Sync user context with Sentry for error tracking
	useSentryUser(user);

	const applyAuthenticatedSession = useCallback(
		async (data: LoginResponseData) => {
			// Cancel any in-flight validation so a late response cannot clobber
			// the session we just established.
			await queryClient.cancelQueries({
				queryKey: queryKeys.auth.session(),
			});
			queryClient.setQueryData(
				queryKeys.auth.session(),
				buildSessionFromLogin(data),
			);
		},
		[queryClient],
	);

	const clearAuthenticatedSession = useCallback(async () => {
		// Cancel first: an in-flight profile refetch landing after this write
		// would otherwise resurrect the session we are tearing down.
		await queryClient.cancelQueries({
			queryKey: queryKeys.auth.session(),
		});
		queryClient.setQueryData(queryKeys.auth.session(), null);
		// Drop user-scoped caches so the next account cannot see prior data.
		queryClient.removeQueries({ queryKey: queryKeys.account.all });
	}, [queryClient]);

	const navigateAfterAuth = useCallback(() => {
		const intendedUrl = getIntendedUrl();
		clearIntendedUrl();
		navigate(intendedUrl || '/');
	}, [clearIntendedUrl, getIntendedUrl, navigate]);

	// OAuth login method with redirect support
	const login = useCallback(
		(
			provider: 'google' | 'github' | 'cloudflare',
			redirectUrl?: string,
		) => {
			const intendedUrl =
				redirectUrl ||
				window.location.pathname + window.location.search;
			setIntendedUrl(intendedUrl);

			const oauthUrl = new URL(
				`/api/auth/oauth/${provider}`,
				window.location.origin,
			);
			oauthUrl.searchParams.set('redirect_url', intendedUrl);
			window.location.href = oauthUrl.toString();
		},
		[setIntendedUrl],
	);

	const loginMutation = useMutation({
		mutationFn: async (credentials: {
			email: string;
			password: string;
		}) => {
			const response = await apiClient.loginWithEmail(credentials);
			if (!response.success || !response.data) {
				throw new Error(
					response.error?.message || 'Login failed',
				);
			}
			return response.data;
		},
		onMutate: () => {
			setError(null);
		},
		onSuccess: async (data) => {
			await applyAuthenticatedSession(data);
			navigateAfterAuth();
		},
		onError: (err) => {
			console.error('Login error:', err);
			setError(getAuthActionError(err));
		},
	});

	const registerMutation = useMutation({
		mutationFn: async (data: {
			email: string;
			password: string;
			name?: string;
		}) => {
			const response = await apiClient.register(data);
			if (!response.success || !response.data) {
				throw new Error(
					response.error?.message || 'Registration failed',
				);
			}
			return response.data;
		},
		onMutate: () => {
			setError(null);
		},
		onSuccess: async (data) => {
			await applyAuthenticatedSession(data);
			navigateAfterAuth();
		},
		onError: (err) => {
			console.error('Registration error:', err);
			setError(getAuthActionError(err));
		},
	});

	const logoutMutation = useMutation({
		mutationFn: async () => {
			try {
				await apiClient.logout();
			} catch (err) {
				console.error('Logout error:', err);
			}
		},
		onSettled: async () => {
			// Navigate before dropping app caches: removing a still-observed
			// query makes its observer refetch, and that request 401s and pops
			// the sign-in modal mid-logout.
			navigate('/');
			await clearAuthenticatedSession();
		},
	});

	const { mutateAsync: loginMutateAsync } = loginMutation;
	const { mutateAsync: registerMutateAsync } = registerMutation;
	const { mutateAsync: logoutMutateAsync } = logoutMutation;

	const loginWithEmail = useCallback(
		async (credentials: { email: string; password: string }) => {
			// Errors are stored on context; rethrow so modal callers can react.
			await loginMutateAsync(credentials);
		},
		[loginMutateAsync],
	);

	const register = useCallback(
		async (data: {
			email: string;
			password: string;
			name?: string;
		}) => {
			await registerMutateAsync(data);
		},
		[registerMutateAsync],
	);

	const logout = useCallback(async () => {
		await logoutMutateAsync();
	}, [logoutMutateAsync]);

	const refreshUser = useCallback(async () => {
		await queryClient.refetchQueries({
			queryKey: queryKeys.auth.session(),
		});
	}, [queryClient]);

	const clearError = useCallback(() => {
		setError(null);
	}, []);

	// Degrade to the email-only fallback only once the request has actually
	// failed, so a transient 500 is retried instead of cached as truth.
	const providers =
		providersQuery.data ??
		(providersQuery.isError ? DEFAULT_AUTH_PROVIDERS : null);

	// Bootstrap only. Deliberately excludes login/register/logout: consumers
	// like AuthButton early-return a skeleton on `isLoading`, which would
	// unmount the in-flight LoginModal and destroy typed credentials.
	// `isPending` is first-resolution only, so background polls never flash UI.
	const isLoading = sessionQuery.isPending || providersQuery.isPending;

	const value = useMemo<AuthContextType>(
		() => ({
			user,
			// Cookie-based auth; no bearer token is ever exposed to the client.
			token: null,
			session,
			isAuthenticated: !!user,
			isLoading,
			error,
			authProviders: providers?.providers ?? null,
			hasOAuth: providers?.hasOAuth ?? false,
			requiresEmailAuth: providers?.requiresEmailAuth ?? true,
			login,
			loginWithEmail,
			register,
			logout,
			refreshUser,
			clearError,
			setIntendedUrl,
			getIntendedUrl,
			clearIntendedUrl,
		}),
		[
			user,
			session,
			isLoading,
			error,
			providers,
			login,
			loginWithEmail,
			register,
			logout,
			refreshUser,
			clearError,
			setIntendedUrl,
			getIntendedUrl,
			clearIntendedUrl,
		],
	);

	return (
		<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
	);
}

export function useAuth() {
	const context = useContext(AuthContext);
	if (context === undefined) {
		throw new Error('useAuth must be used within an AuthProvider');
	}
	return context;
}

// Helper hook for protected routes
export function useRequireAuth(redirectTo = '/') {
	const { isAuthenticated, isLoading } = useAuth();
	const navigate = useNavigate();

	useEffect(() => {
		if (!isLoading && !isAuthenticated) {
			navigate(redirectTo);
		}
	}, [isAuthenticated, isLoading, navigate, redirectTo]);

	return { isAuthenticated, isLoading };
}