mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 14:39:39 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
503 lines
No EOL
18 KiB
TypeScript
503 lines
No EOL
18 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
|
import { router } from 'expo-router';
|
|
import { authService } from '../services/authService';
|
|
import { updateSupabaseAuth, setSupabaseToken } from '../lib/supabaseClient';
|
|
import { setupGlobalFetchInterceptor, setupTokenObservers } from '../utils/fetchInterceptor';
|
|
import { tokenManager, TokenState } from '../services/tokenManager';
|
|
import {
|
|
initializeRevenueCatConditionally,
|
|
identifyRevenueCatUser,
|
|
resetRevenueCatUser,
|
|
} from '~/features/subscription/revenueCatManager';
|
|
|
|
// User type
|
|
type User = {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
name: string;
|
|
};
|
|
|
|
// Define type for the Auth context
|
|
type AuthContextType = {
|
|
isAuthenticated: boolean;
|
|
user: User | null;
|
|
loading: boolean;
|
|
showPasswordResetModal: boolean;
|
|
setShowPasswordResetModal: (show: boolean) => void;
|
|
authModeOverride: string | null;
|
|
setAuthModeOverride: (mode: string | null) => void;
|
|
setUser: (userData: { id: string; email: string; role: string }) => void;
|
|
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
|
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
|
signInWithGoogle: () => Promise<{ success: boolean; error?: string }>;
|
|
signOut: () => Promise<void>;
|
|
};
|
|
|
|
// Create context with default values
|
|
const AuthContext = createContext<AuthContextType>({
|
|
isAuthenticated: false,
|
|
user: null,
|
|
loading: false,
|
|
showPasswordResetModal: false,
|
|
setShowPasswordResetModal: () => {},
|
|
authModeOverride: null,
|
|
setAuthModeOverride: () => {},
|
|
setUser: () => {},
|
|
signIn: async () => ({ success: false }),
|
|
signUp: async () => ({ success: false }),
|
|
signInWithGoogle: async () => ({ success: false }),
|
|
signOut: async () => {},
|
|
});
|
|
|
|
// Hook for easy access to the Auth context
|
|
export const useAuth = () => useContext(AuthContext);
|
|
|
|
// Auth Provider component
|
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
|
const [showPasswordResetModal, setShowPasswordResetModal] = useState(false);
|
|
const [authModeOverride, setAuthModeOverride] = useState<string | null>(null);
|
|
|
|
// Use a ref to track the latest auth state for the token observer
|
|
const authStateRef = useRef({ isAuthenticated, user });
|
|
|
|
// Update the ref whenever auth state changes
|
|
useEffect(() => {
|
|
authStateRef.current = { isAuthenticated, user };
|
|
}, [isAuthenticated, user]);
|
|
|
|
// Update user data when it changes
|
|
const updateUserData = useCallback((userData: { id: string; email: string; role: string }) => {
|
|
console.debug('Auth: updateUserData called with:', userData);
|
|
if (userData) {
|
|
console.debug('Auth: Setting isAuthenticated to true and updating user');
|
|
setIsAuthenticated(true);
|
|
// Add name field derived from email
|
|
const userWithName = {
|
|
...userData,
|
|
name: userData.email.split('@')[0]
|
|
};
|
|
setUser(userWithName);
|
|
console.debug('Auth: User data updated successfully');
|
|
} else {
|
|
console.debug('Auth: updateUserData called with null/undefined userData');
|
|
}
|
|
}, []);
|
|
|
|
// Load auth state on startup
|
|
useEffect(() => {
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
const initAuth = async () => {
|
|
try {
|
|
// Set up the global fetch interceptor FIRST - this is critical for handling 401s during auth check
|
|
setupGlobalFetchInterceptor();
|
|
|
|
// Set up token manager observers for Supabase integration
|
|
setupTokenObservers();
|
|
|
|
// Register the token refresh callback BEFORE any auth operations
|
|
authService.onTokenRefresh = updateUserData;
|
|
|
|
// Subscribe to token state changes for UI updates
|
|
unsubscribe = tokenManager.subscribe((state: TokenState, token?: string) => {
|
|
console.debug('Auth: Token state changed to', state);
|
|
console.debug('Auth: Current auth state - isAuthenticated:', authStateRef.current.isAuthenticated, 'user:', !!authStateRef.current.user);
|
|
|
|
if (state === TokenState.VALID && token) {
|
|
// We have a valid token, try to get user data
|
|
console.debug('Auth: Token is VALID, attempting to get user data...');
|
|
authService.getUserFromToken().then(userData => {
|
|
console.debug('Auth: getUserFromToken result:', userData);
|
|
if (userData) {
|
|
console.debug('Auth: Calling updateUserData with:', userData);
|
|
updateUserData(userData);
|
|
// Force a re-check of auth state after update
|
|
setTimeout(() => {
|
|
console.debug('Auth: After updateUserData - isAuthenticated:', authStateRef.current.isAuthenticated, 'user:', !!authStateRef.current.user);
|
|
}, 100);
|
|
} else {
|
|
console.debug('Auth: getUserFromToken returned null/undefined');
|
|
}
|
|
}).catch(error => {
|
|
console.debug('Error getting user data from token:', error);
|
|
});
|
|
} else if (state === TokenState.EXPIRED_OFFLINE) {
|
|
// Token expired while offline - preserve auth state, don't logout
|
|
console.debug('Auth: Token expired offline, preserving auth state');
|
|
// User can continue using the app offline, token will refresh when connection returns
|
|
return; // Don't trigger logout
|
|
} else if (state === TokenState.EXPIRED && !isSigningOut) {
|
|
// Only handle token expiration if we're not actively signing out
|
|
console.debug('Auth: Token expired, handling expiration. isSigningOut:', isSigningOut);
|
|
handleTokenExpiration().catch(error => {
|
|
console.debug('Error during token expiration handling:', error);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check if user is authenticated (uses local validation to avoid race conditions)
|
|
const authenticated = await authService.isAuthenticated();
|
|
|
|
if (authenticated) {
|
|
// Get user information from token
|
|
const userData = await authService.getUserFromToken();
|
|
|
|
if (userData) {
|
|
updateUserData(userData);
|
|
|
|
// Update Supabase auth state only if we have a valid current token
|
|
// to avoid triggering unnecessary token refresh during startup
|
|
try {
|
|
const currentToken = await authService.getAppToken();
|
|
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
|
// Only update Supabase if we have a valid token to avoid refresh cycles
|
|
console.debug('Updating Supabase auth with existing valid token');
|
|
await setSupabaseToken(currentToken);
|
|
}
|
|
} catch (error) {
|
|
console.debug('Error setting Supabase token during startup:', error);
|
|
}
|
|
|
|
// Initialize RevenueCat conditionally for existing authenticated users
|
|
try {
|
|
await initializeRevenueCatConditionally();
|
|
await identifyRevenueCatUser(userData.id);
|
|
} catch (error) {
|
|
console.error('Error with RevenueCat initialization for existing user:', error);
|
|
// Don't block startup if initialization fails
|
|
}
|
|
} else {
|
|
// If user data couldn't be retrieved, sign out
|
|
await handleSignOut();
|
|
}
|
|
} else {
|
|
// If not authenticated, make sure we're on the login screen
|
|
if (router.canGoBack()) {
|
|
router.replace('/(public)/login');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initializing auth state:', error);
|
|
// On error, make sure we're signed out and on login screen
|
|
await handleSignOut();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
initAuth();
|
|
|
|
// Cleanup the callback and subscription on unmount
|
|
return () => {
|
|
authService.onTokenRefresh = null;
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
}
|
|
};
|
|
}, []); // Empty dependencies - we use refs to access latest values
|
|
|
|
// Handle sign in
|
|
const handleSignIn = async (email: string, password: string) => {
|
|
// Set loading state immediately
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Authenticate with the API
|
|
const result = await authService.signIn(email, password);
|
|
|
|
if (result.success) {
|
|
// Reset password reset modal on successful login
|
|
setShowPasswordResetModal(false);
|
|
|
|
try {
|
|
// Get user information from token
|
|
const userData = await authService.getUserFromToken();
|
|
|
|
if (userData) {
|
|
updateUserData(userData);
|
|
|
|
// Update Supabase auth state
|
|
await updateSupabaseAuth();
|
|
|
|
// Initialize RevenueCat conditionally based on B2B settings
|
|
try {
|
|
await initializeRevenueCatConditionally();
|
|
await identifyRevenueCatUser(userData.id);
|
|
} catch (error) {
|
|
console.error('Error with RevenueCat initialization:', error);
|
|
// Don't block auth flow if initialization fails
|
|
}
|
|
|
|
// Use replace to prevent swiping back to login
|
|
// Small delay to ensure auth state is updated
|
|
setTimeout(() => {
|
|
router.replace('/(protected)/(tabs)/');
|
|
}, 100);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error getting user data after sign in:', err);
|
|
// Keep loading false but return the success result
|
|
// This will allow navigation to proceed even if we couldn't get user data
|
|
}
|
|
}
|
|
|
|
// Check if it's a password reset required error
|
|
if (!result.success && result.error === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED') {
|
|
console.log('AuthContext: Setting showPasswordResetModal to true');
|
|
setShowPasswordResetModal(true);
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error in handleSignIn:', error);
|
|
return { success: false, error: 'An unexpected error occurred' };
|
|
} finally {
|
|
// Always set loading to false when done, regardless of success or failure
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Handle sign up
|
|
const handleSignUp = async (email: string, password: string) => {
|
|
// Set loading state immediately
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Register with the API
|
|
const result = await authService.signUp(email, password);
|
|
|
|
if (result.success) {
|
|
// Check if email verification is needed
|
|
if (result.needsVerification) {
|
|
// Don't authenticate user, just return success with verification flag
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
// Get user information from token (only if no verification needed)
|
|
const userData = await authService.getUserFromToken();
|
|
|
|
if (userData) {
|
|
updateUserData(userData);
|
|
|
|
// Update Supabase auth state
|
|
await updateSupabaseAuth();
|
|
|
|
// Initialize RevenueCat conditionally based on B2B settings
|
|
try {
|
|
await initializeRevenueCatConditionally();
|
|
await identifyRevenueCatUser(userData.id);
|
|
} catch (error) {
|
|
console.error('Error with RevenueCat initialization:', error);
|
|
// Don't block auth flow if initialization fails
|
|
}
|
|
|
|
// Use replace to prevent swiping back to login
|
|
// Small delay to ensure auth state is updated
|
|
setTimeout(() => {
|
|
router.replace('/(protected)/(tabs)/');
|
|
}, 100);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error getting user data after sign up:', err);
|
|
// Keep loading false but return the success result
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error in handleSignUp:', error);
|
|
return { success: false, error: 'An unexpected error occurred' };
|
|
} finally {
|
|
// Always set loading to false when done, regardless of success or failure
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Handle sign out
|
|
const handleSignOut = async () => {
|
|
// Prevent multiple simultaneous logout calls
|
|
if (isSigningOut) {
|
|
console.debug('Auth: Sign out already in progress, skipping');
|
|
return;
|
|
}
|
|
|
|
setIsSigningOut(true);
|
|
try {
|
|
// Track sign out event before clearing user data
|
|
// Use TokenManager to clear tokens and state
|
|
await tokenManager.clearTokens();
|
|
|
|
// Also call authService signOut for any server-side cleanup
|
|
await authService.signOut();
|
|
|
|
setIsAuthenticated(false);
|
|
setUser(null);
|
|
|
|
// Update Supabase auth state
|
|
await updateSupabaseAuth();
|
|
|
|
// Reset RevenueCat user to anonymous
|
|
try {
|
|
await resetRevenueCatUser();
|
|
} catch (error) {
|
|
console.error('Error resetting RevenueCat user:', error);
|
|
// Don't block sign out if reset fails
|
|
}
|
|
|
|
// Don't navigate here - let the root layout handle navigation based on auth state
|
|
// This prevents the infinite redirect loop on Android
|
|
} catch (error) {
|
|
console.error('Error signing out:', error);
|
|
} finally {
|
|
setIsSigningOut(false);
|
|
}
|
|
};
|
|
|
|
// Handle token expiration - try to refresh before logging out
|
|
const handleTokenExpiration = async () => {
|
|
console.debug('Auth: Handling token expiration');
|
|
|
|
// Check if we're offline first
|
|
const { hasStableConnection, isDeviceConnected } = await import('~/features/errorHandling/utils/networkErrorUtils');
|
|
const isOnline = await isDeviceConnected();
|
|
|
|
if (!isOnline) {
|
|
console.debug('Auth: Device offline, preserving auth state');
|
|
return; // Don't logout when offline
|
|
}
|
|
|
|
// Check if connection is stable before attempting critical operations
|
|
const isStable = await hasStableConnection();
|
|
if (!isStable) {
|
|
console.debug('Auth: Connection unstable during token expiration, deferring action');
|
|
// Schedule a retry after network stabilizes
|
|
setTimeout(() => {
|
|
// Re-check token state after delay
|
|
const currentState = tokenManager.getState();
|
|
if (currentState === TokenState.EXPIRED) {
|
|
handleTokenExpiration();
|
|
}
|
|
}, 3000);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check if we can attempt a refresh (has valid refresh token)
|
|
const canRefresh = await tokenManager.canAttemptRefresh();
|
|
|
|
if (canRefresh) {
|
|
console.debug('Auth: Refresh token available, attempting refresh before logout');
|
|
|
|
// Try to get a valid token (this will trigger refresh if needed)
|
|
const validToken = await tokenManager.getValidToken();
|
|
|
|
if (validToken) {
|
|
console.debug('Auth: Token refresh successful, staying logged in');
|
|
// Token refresh succeeded, the TokenManager will notify us of the new valid state
|
|
// Update user data to ensure UI is synchronized
|
|
const userData = await authService.getUserFromToken();
|
|
if (userData) {
|
|
updateUserData(userData);
|
|
}
|
|
return;
|
|
} else {
|
|
console.debug('Auth: Token refresh failed despite having refresh token');
|
|
}
|
|
} else {
|
|
console.debug('Auth: No refresh token available');
|
|
}
|
|
|
|
// No refresh token or refresh failed, proceed with logout
|
|
console.debug('Auth: Proceeding with logout due to expired tokens');
|
|
await handleSignOut();
|
|
|
|
} catch (error) {
|
|
console.debug('Error during token expiration handling:', error);
|
|
// If anything fails, fall back to logout to ensure security
|
|
await handleSignOut();
|
|
}
|
|
};
|
|
|
|
// Handle Google Sign-In
|
|
const handleGoogleSignIn = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
// The actual Google Sign-In is handled in the GoogleSignInButton component
|
|
// Here we just handle the post-authentication flow
|
|
|
|
try {
|
|
// Get user information from token
|
|
const userData = await authService.getUserFromToken();
|
|
|
|
if (userData) {
|
|
updateUserData(userData);
|
|
|
|
// Update Supabase auth state
|
|
await updateSupabaseAuth();
|
|
|
|
// Identify user with RevenueCat
|
|
try {
|
|
await identifyRevenueCatUser(userData.id);
|
|
} catch (rcError) {
|
|
console.error('Error identifying user with RevenueCat:', rcError);
|
|
// Don't block auth flow if RevenueCat identification fails
|
|
}
|
|
|
|
// Track Google sign in event
|
|
identify(userData.id, {
|
|
email: userData.email,
|
|
role: userData.role,
|
|
});
|
|
track('user_signed_in', {
|
|
method: 'google',
|
|
});
|
|
|
|
// Use replace to prevent swiping back to login
|
|
// Small delay to ensure auth state is updated
|
|
setTimeout(() => {
|
|
router.replace('/(protected)/(tabs)/');
|
|
}, 100);
|
|
|
|
return { success: true };
|
|
}
|
|
} catch (err) {
|
|
console.error('Error getting user data after Google Sign-In:', err);
|
|
}
|
|
|
|
return { success: false, error: 'No user data available after Google Sign-In' };
|
|
} catch (error) {
|
|
console.error('Error in handleGoogleSignIn:', error);
|
|
return { success: false, error: 'An unexpected error occurred during Google Sign-In' };
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthContext.Provider
|
|
value={{
|
|
isAuthenticated,
|
|
user,
|
|
loading,
|
|
showPasswordResetModal,
|
|
setShowPasswordResetModal,
|
|
authModeOverride,
|
|
setAuthModeOverride,
|
|
setUser: updateUserData,
|
|
signIn: handleSignIn,
|
|
signUp: handleSignUp,
|
|
signInWithGoogle: handleGoogleSignIn,
|
|
signOut: handleSignOut,
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}; |