-
Auth Hooks:
useAuth()- Manages authentication state, providesisAuthenticated,isReady,signIn()useUser()- Fetches user data, depends onuseAuth()useRequireAuth()- Automatically triggers sign-in if not authenticated (exists but not widely used)
-
Root Layout (_layout.jsx):
- Initializes auth on app start
- Shows splash screen until auth is ready (
isReady) - Renders
<AuthModal />globally - Does NOT guard routes - relies on individual screens
-
AuthModal (useAuthModal.jsx):
- Renders when
isOpen && !auth - Uses Zustand store for state management
- Can be triggered programmatically via
useAuthModal().open()
- Renders when
-
Current Home Screen (home.jsx):
- Uses
useAuth()anduseUser() - Has access to
isAuthenticatedandsignIn() - Does NOT enforce authentication - relies on user data being available
- Uses
There is no enforcement mechanism to redirect unauthenticated users to login. Screens can render without a user, leading to:
- Potential crashes when accessing
user.id - Poor UX (users see broken UI)
- Security issues (unauthorized access to protected data)
Pros:
- Explicit and clear
- Works with Expo Router's navigation
- Easy to test
- No hidden magic
Cons:
- Requires adding guard to each protected screen
Implementation:
// /mobile/src/components/ProtectedRoute.jsx
import { useEffect } from 'react';
import { useRouter, useSegments } from 'expo-router';
import { useAuth } from '@/utils/auth/useAuth';
import { ActivityIndicator, View } from 'react-native';
import { useColors } from './useColors';
export function ProtectedRoute({ children }) {
const { isAuthenticated, isReady, signIn } = useAuth();
const router = useRouter();
const segments = useSegments();
const colors = useColors();
useEffect(() => {
if (!isReady) return; // Wait for auth to initialize
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
// Redirect to login
router.replace('/login');
} else if (isAuthenticated && inAuthGroup) {
// Redirect authenticated users away from login
router.replace('/(tabs)/home');
}
}, [isAuthenticated, isReady, segments]);
// Show loading while auth initializes
if (!isReady) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.background }}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// If not authenticated, show nothing (will redirect)
if (!isAuthenticated) {
return null;
}
// Authenticated - render children
return children;
}// /mobile/src/app/(tabs)/home.jsx
import { ProtectedRoute } from '@/components/ProtectedRoute';
export default function HomeScreen() {
// ... existing code
return (
<ProtectedRoute>
{/* existing UI */}
</ProtectedRoute>
);
}// /mobile/src/app/(auth)/login.jsx
import { useAuth } from '@/utils/auth/useAuth';
import { View, TouchableOpacity, Text } from 'react-native';
import { useColors } from '@/components/useColors';
export default function LoginScreen() {
const { signIn, isLoading } = useAuth();
const colors = useColors();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.background }}>
<TouchableOpacity
onPress={signIn}
disabled={isLoading}
style={{
backgroundColor: colors.primary,
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
}}
>
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '600' }}>
{isLoading ? 'Signing in...' : 'Sign in with Google'}
</Text>
</TouchableOpacity>
</View>
);
}Pros:
- Centralized logic
- No need to wrap individual screens
Cons:
- Less flexible
- Harder to customize per-route
- Can cause redirect loops if not careful
Implementation:
// /mobile/src/app/_layout.jsx (modified)
import { useEffect } from 'react';
import { useRouter, useSegments } from 'expo-router';
export default function RootLayout() {
const { isReady, isAuthenticated } = useAuth();
const router = useRouter();
const segments = useSegments();
useEffect(() => {
if (!isReady) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
router.replace('/login');
} else if (isAuthenticated && inAuthGroup) {
router.replace('/(tabs)/home');
}
}, [isAuthenticated, isReady, segments]);
// ... rest of layout
}Pros:
- Already exists
- Minimal code changes
- Easy to add to any screen
Cons:
- Doesn't redirect - just triggers sign-in modal
- No dedicated login screen
- Less control over UX
Implementation:
// /mobile/src/app/(tabs)/home.jsx
import { useRequireAuth } from '@/utils/auth/useAuth';
export default function HomeScreen() {
useRequireAuth(); // Automatically shows login modal if not authenticated
// ... rest of component
}Use Option 1 (Route-Level Guards) for the following reasons:
- Explicit & Testable: Each protected screen clearly declares it requires auth
- Flexible: Can customize behavior per route if needed
- Better UX: Can have a proper login screen instead of modal
- Aligns with Expo Router: Works well with
(auth)and(tabs)route groups - Easy to Debug: Clear where auth checks happen
- File:
/mobile/src/components/ProtectedRoute.jsx - Handles loading state, auth check, redirects
- File:
/mobile/src/app/(auth)/login.jsx - Simple Google sign-in button
- Redirects to home after successful login
- Wrap
home.jsx,history.jsx,profile.jsxwith<ProtectedRoute> - Public screens (if any) don't need wrapping
- Mock
useAuthto returnisAuthenticated: trueby default - Add specific tests for unauthenticated state
- Verify redirects work correctly
describe('ProtectedRoute', () => {
it('should show loading spinner when auth is not ready', () => {
useAuth.mockReturnValue({ isAuthenticated: false, isReady: false });
const { getByTestId } = render(<ProtectedRoute><Text>Content</Text></ProtectedRoute>);
expect(getByTestId('loading-spinner')).toBeTruthy();
});
it('should redirect to login when not authenticated', () => {
const mockRouter = { replace: jest.fn() };
useRouter.mockReturnValue(mockRouter);
useAuth.mockReturnValue({ isAuthenticated: false, isReady: true });
render(<ProtectedRoute><Text>Content</Text></ProtectedRoute>);
expect(mockRouter.replace).toHaveBeenCalledWith('/login');
});
it('should render children when authenticated', () => {
useAuth.mockReturnValue({ isAuthenticated: true, isReady: true });
const { getByText } = render(<ProtectedRoute><Text>Protected Content</Text></ProtectedRoute>);
expect(getByText('Protected Content')).toBeTruthy();
});
});describe('Home Screen - Auth Integration', () => {
it('should require authentication to view home screen', async () => {
useAuth.mockReturnValue({
isAuthenticated: false,
isReady: true,
signIn: jest.fn(),
});
const mockRouter = { replace: jest.fn() };
useRouter.mockReturnValue(mockRouter);
render(<HomeScreen />);
await waitFor(() => {
expect(mockRouter.replace).toHaveBeenCalledWith('/login');
});
});
it('should render home screen when user is authenticated', () => {
useAuth.mockReturnValue({
isAuthenticated: true,
isReady: true,
signIn: jest.fn(),
});
useUser.mockReturnValue({
data: { id: 'user-123', email: 'test@example.com' },
loading: false,
});
const { getByTestId } = render(<HomeScreen />);
expect(getByTestId('camera-button')).toBeTruthy();
expect(getByTestId('mic-button')).toBeTruthy();
});
});- Create
ProtectedRoute.jsxcomponent - Create
login.jsxscreen in(auth)group - Wrap
home.jsxwith<ProtectedRoute> - Wrap
history.jsxwith<ProtectedRoute> - Wrap
profile.jsxwith<ProtectedRoute> - Update
home.test.jsxto verify auth requirement - Add tests for
ProtectedRoutecomponent - Add tests for
login.jsxscreen - Test manual flow: app launch → login → home
- Test logout flow: home → logout → login
- Verify session persistence (close app, reopen)
-
Session Expiry: What happens when session expires mid-use?
- Solution: Auth state listener in
useAuthshould catch this
- Solution: Auth state listener in
-
Network Failure: User can't connect to Supabase
- Solution: Show error message, retry button
-
Logout Redirect: Where to send user after logout?
- Solution: Always redirect to
/login
- Solution: Always redirect to
-
Deep Links: User clicks deep link but not authenticated
- Solution: Store intended route, redirect to login, then to intended route after auth
-
Back Button: User presses back from login screen
- Solution: Disable back button or exit app (depends on UX requirements)
- Token Storage: Tokens are stored in
expo-secure-store(good) - Token Refresh: Supabase handles token refresh automatically (good)
- Session Validation: Always validate session on app start (already doing this)
- API Calls: All API calls should use
getAccessToken()(already doing this)
- Implement
ProtectedRoutecomponent - Create login screen
- Update home screen test to verify authentication
- Test the full flow manually