feat: User onboarding finished

This commit is contained in:
Eric Gullickson
2025-12-23 10:26:10 -06:00
parent 55cf4923b8
commit 96ee43ea94
19 changed files with 698 additions and 67 deletions

View File

@@ -8,7 +8,7 @@ import { AuthService } from '../domain/auth.service';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { pool } from '../../../core/config/database'; import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { signupSchema } from './auth.validation'; import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
export class AuthController { export class AuthController {
private authService: AuthService; private authService: AuthService;
@@ -119,4 +119,67 @@ export class AuthController {
}); });
} }
} }
/**
* POST /api/auth/resend-verification-public
* Resend verification email by email address
* Public endpoint - no JWT required (for pre-login verification page)
*/
async resendVerificationPublic(request: FastifyRequest, reply: FastifyReply) {
try {
const validation = resendVerificationPublicSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Validation error',
message: validation.error.errors[0]?.message || 'Invalid input',
});
}
const { email } = validation.data;
const result = await this.authService.resendVerificationByEmail(email);
logger.info('Public resend verification requested', { email: email.substring(0, 3) + '***' });
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to resend verification email (public)', { error });
// Always return success for security (don't reveal if email exists)
return reply.code(200).send({
message: 'If an account exists with this email, a verification link will be sent.',
});
}
}
/**
* GET /api/auth/user-status
* Get user status for routing decisions
* Protected endpoint - requires JWT
*/
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const result = await this.authService.getUserStatus(userId);
logger.info('User status retrieved', {
userId: userId.substring(0, 8) + '...',
emailVerified: result.emailVerified,
onboardingCompleted: result.onboardingCompleted,
});
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to get user status', {
error,
userId: (request as any).user?.sub,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get user status',
});
}
}
} }

View File

@@ -27,4 +27,13 @@ export const authRoutes: FastifyPluginAsync = async (
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
handler: authController.resendVerification.bind(authController), handler: authController.resendVerification.bind(authController),
}); });
// POST /api/auth/resend-verification-public - Resend verification by email (public, no JWT)
fastify.post('/auth/resend-verification-public', authController.resendVerificationPublic.bind(authController));
// GET /api/auth/user-status - Get user status for routing (requires JWT, verification exempt)
fastify.get('/auth/user-status', {
preHandler: [fastify.authenticate],
handler: authController.getUserStatus.bind(authController),
});
}; };

View File

@@ -21,3 +21,10 @@ export const signupSchema = z.object({
}); });
export type SignupInput = z.infer<typeof signupSchema>; export type SignupInput = z.infer<typeof signupSchema>;
// Schema for public resend verification endpoint (no JWT required)
export const resendVerificationPublicSchema = z.object({
email: z.string().email('Invalid email format'),
});
export type ResendVerificationPublicInput = z.infer<typeof resendVerificationPublicSchema>;

View File

@@ -127,4 +127,87 @@ export class AuthService {
throw error; throw error;
} }
} }
/**
* Resend verification email by email address (public endpoint)
* Looks up user by email and sends verification if not verified
*/
async resendVerificationByEmail(email: string): Promise<ResendVerificationResponse> {
try {
// Look up user by email in our database
const userProfile = await this.userProfileRepository.getByEmail(email);
if (!userProfile) {
// Don't reveal if email exists - return success message regardless
logger.info('Resend verification requested for unknown email', {
email: email.substring(0, 3) + '***',
});
return {
message: 'If an account exists with this email, a verification link will be sent.',
};
}
// Check if already verified via Auth0
const verified = await auth0ManagementClient.checkEmailVerified(userProfile.auth0Sub);
if (verified) {
logger.info('Email already verified, skipping resend', { email: email.substring(0, 3) + '***' });
return {
message: 'If an account exists with this email, a verification link will be sent.',
};
}
// Request Auth0 to resend verification email
await auth0ManagementClient.resendVerificationEmail(userProfile.auth0Sub);
logger.info('Verification email resent via public endpoint', {
email: email.substring(0, 3) + '***',
});
return {
message: 'If an account exists with this email, a verification link will be sent.',
};
} catch (error) {
logger.error('Failed to resend verification email by email', {
email: email.substring(0, 3) + '***',
error,
});
// Don't reveal error details - return generic success for security
return {
message: 'If an account exists with this email, a verification link will be sent.',
};
}
}
/**
* Get user status for routing decisions
* Returns email verification and onboarding completion status
*/
async getUserStatus(auth0Sub: string): Promise<{
emailVerified: boolean;
onboardingCompleted: boolean;
email: string;
}> {
try {
// Get user details from Auth0
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
// Get local profile for onboarding status
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
// Sync email verification status if needed
if (localProfile && localProfile.emailVerified !== auth0User.emailVerified) {
await this.userProfileRepository.updateEmailVerified(auth0Sub, auth0User.emailVerified);
}
return {
emailVerified: auth0User.emailVerified,
onboardingCompleted: localProfile?.onboardingCompletedAt !== null,
email: auth0User.email,
};
} catch (error) {
logger.error('Failed to get user status', { auth0Sub, error });
throw error;
}
}
} }

View File

@@ -44,6 +44,26 @@ export class UserProfileRepository {
} }
} }
async getByEmail(email: string): Promise<UserProfile | null> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
FROM user_profiles
WHERE email = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [email]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error fetching user profile by email', { error });
throw error;
}
}
async create( async create(
auth0Sub: string, auth0Sub: string,
email: string, email: string,

View File

@@ -47,8 +47,10 @@ const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/m
// Auth pages (lazy-loaded) // Auth pages (lazy-loaded)
const SignupPage = lazy(() => import('./features/auth/pages/SignupPage').then(m => ({ default: m.SignupPage }))); const SignupPage = lazy(() => import('./features/auth/pages/SignupPage').then(m => ({ default: m.SignupPage })));
const VerifyEmailPage = lazy(() => import('./features/auth/pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage }))); const VerifyEmailPage = lazy(() => import('./features/auth/pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage })));
const CallbackPage = lazy(() => import('./features/auth/pages/CallbackPage').then(m => ({ default: m.CallbackPage })));
const SignupMobileScreen = lazy(() => import('./features/auth/mobile/SignupMobileScreen').then(m => ({ default: m.SignupMobileScreen }))); const SignupMobileScreen = lazy(() => import('./features/auth/mobile/SignupMobileScreen').then(m => ({ default: m.SignupMobileScreen })));
const VerifyEmailMobileScreen = lazy(() => import('./features/auth/mobile/VerifyEmailMobileScreen').then(m => ({ default: m.VerifyEmailMobileScreen }))); const VerifyEmailMobileScreen = lazy(() => import('./features/auth/mobile/VerifyEmailMobileScreen').then(m => ({ default: m.VerifyEmailMobileScreen })));
const CallbackMobileScreen = lazy(() => import('./features/auth/mobile/CallbackMobileScreen').then(m => ({ default: m.CallbackMobileScreen })));
// Onboarding pages (lazy-loaded) // Onboarding pages (lazy-loaded)
const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage }))); const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
@@ -469,13 +471,19 @@ function App() {
); );
} }
if (isCallbackRoute) { // Callback route requires authentication - handled by CallbackPage component
if (isCallbackRoute && isAuthenticated) {
return ( return (
<ThemeProvider theme={md3Theme}> <ThemeProvider theme={md3Theme}>
<CssBaseline /> <CssBaseline />
<div className="flex items-center justify-center min-h-screen"> <React.Suspense fallback={
<div className="text-lg">Processing login...</div> <div className="flex items-center justify-center min-h-screen">
</div> <div className="text-lg">Processing login...</div>
</div>
}>
{mobileMode ? <CallbackMobileScreen /> : <CallbackPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider> </ThemeProvider>
); );
} }
@@ -507,11 +515,8 @@ function App() {
); );
} }
if (!isAuthenticated) { // Verify email is public - shown after signup before user can login
return <Navigate to="/" replace />; // (Auth0 blocks unverified users from logging in)
}
// Verify email and onboarding routes require authentication but not full initialization
if (isVerifyEmailRoute) { if (isVerifyEmailRoute) {
return ( return (
<ThemeProvider theme={md3Theme}> <ThemeProvider theme={md3Theme}>
@@ -528,6 +533,10 @@ function App() {
); );
} }
if (!isAuthenticated) {
return <Navigate to="/" replace />;
}
if (isOnboardingRoute) { if (isOnboardingRoute) {
return ( return (
<ThemeProvider theme={md3Theme}> <ThemeProvider theme={md3Theme}>

View File

@@ -26,8 +26,12 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const onRedirectCallback = (appState?: { returnTo?: string }) => { const onRedirectCallback = (appState?: { returnTo?: string }) => {
console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo });
const target = appState?.returnTo || '/garage'; // Route to callback page which will check user status and redirect appropriately
navigate(target, { replace: true }); // Pass the intended destination as state for after status check
navigate('/callback', {
replace: true,
state: { returnTo: appState?.returnTo || '/garage' },
});
}; };
return ( return (

View File

@@ -0,0 +1,25 @@
/**
* @ai-summary React Query hook for user status (email verification + onboarding)
* @ai-context Used by CallbackPage to determine routing after Auth0 callback
*/
import { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { authApi } from '../../features/auth/api/auth.api';
import { UserStatusResponse } from '../../features/auth/types/auth.types';
interface UseUserStatusOptions {
enabled?: boolean;
}
export const useUserStatus = (options?: UseUserStatusOptions) => {
const { isAuthenticated, isLoading: isAuthLoading } = useAuth0();
return useQuery<UserStatusResponse>({
queryKey: ['userStatus'],
queryFn: authApi.getUserStatus,
enabled: (options?.enabled !== false) && isAuthenticated && !isAuthLoading,
retry: 2,
staleTime: 30000, // Cache for 30 seconds
});
};

View File

@@ -9,11 +9,13 @@ import {
SignupResponse, SignupResponse,
VerifyStatusResponse, VerifyStatusResponse,
ResendVerificationResponse, ResendVerificationResponse,
ResendVerificationPublicRequest,
UserStatusResponse,
} from '../types/auth.types'; } from '../types/auth.types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// Create unauthenticated client for public signup endpoint // Create unauthenticated client for public endpoints (no JWT required)
const unauthenticatedClient = axios.create({ const unauthenticatedClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 10000,
@@ -37,4 +39,22 @@ export const authApi = {
const response = await apiClient.post('/auth/resend-verification'); const response = await apiClient.post('/auth/resend-verification');
return response.data; return response.data;
}, },
/**
* Resend verification email by email address (public, no auth required)
* Used on the "Check Your Email" page before user can login
*/
resendVerificationPublic: async (data: ResendVerificationPublicRequest): Promise<ResendVerificationResponse> => {
const response = await unauthenticatedClient.post('/auth/resend-verification-public', data);
return response.data;
},
/**
* Get user status for routing decisions (requires auth)
* Returns email verification and onboarding completion status
*/
getUserStatus: async (): Promise<UserStatusResponse> => {
const response = await apiClient.get('/auth/user-status');
return response.data;
},
}; };

View File

@@ -52,3 +52,21 @@ export const useResendVerification = () => {
}, },
}); });
}; };
/**
* Public resend verification - no authentication required
* Used on the "Check Your Email" page before user can login
*/
export const useResendVerificationPublic = () => {
return useMutation({
mutationFn: (email: string) => authApi.resendVerificationPublic({ email }),
onSuccess: (data) => {
toast.success(data.message || 'If an account exists, a verification link will be sent.');
},
onError: (error: ApiError) => {
// Always show success message for security (don't reveal if email exists)
toast.success('If an account exists, a verification link will be sent.');
console.error('Resend verification error:', error);
},
});
};

View File

@@ -0,0 +1,86 @@
/**
* @ai-summary Mobile Auth0 callback handler - routes users based on their status
* @ai-context Fetches user status after Auth0 callback and redirects appropriately
*/
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useUserStatus } from '../../../core/auth/useUserStatus';
export const CallbackMobileScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { data: userStatus, isLoading, error } = useUserStatus();
// Get returnTo from location state (passed from Auth0Provider onRedirectCallback)
const returnTo = (location.state as { returnTo?: string })?.returnTo || '/garage';
useEffect(() => {
if (isLoading) return;
if (error) {
console.error('[CallbackMobileScreen] Error fetching user status:', error);
// On error, redirect to garage and let normal auth flow handle it
navigate('/garage', { replace: true });
return;
}
if (userStatus) {
// Note: Unverified users should never reach this page if Auth0 action is configured
// But as a safety check, redirect to verify-email if not verified
if (!userStatus.emailVerified) {
console.log('[CallbackMobileScreen] User not verified, redirecting to verify-email');
navigate('/verify-email', { replace: true });
return;
}
// Check if onboarding is completed
if (!userStatus.onboardingCompleted) {
console.log('[CallbackMobileScreen] User not onboarded, redirecting to onboarding');
navigate('/onboarding', { replace: true });
return;
}
// User is verified and onboarded - go to requested destination
console.log('[CallbackMobileScreen] User verified and onboarded, redirecting to:', returnTo);
navigate(returnTo, { replace: true });
}
}, [userStatus, isLoading, error, navigate, returnTo]);
return (
<MobileContainer>
<div className="flex-1 flex items-center justify-center">
<div className="text-center px-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary-100 mb-4">
<svg
className="w-8 h-8 text-primary-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">
Setting up your session...
</h2>
<p className="text-slate-600">Please wait while we prepare your garage.</p>
</div>
</div>
</MobileContainer>
);
};
export default CallbackMobileScreen;

View File

@@ -17,7 +17,11 @@ export const SignupMobileScreen: React.FC = () => {
const handleSubmit = (data: SignupRequest) => { const handleSubmit = (data: SignupRequest) => {
signup(data, { signup(data, {
onSuccess: () => { onSuccess: () => {
navigate('/verify-email'); // Store email in localStorage for post-verification login_hint
// (navigation state is lost when Auth0 redirects after verification)
localStorage.setItem('pendingVerificationEmail', data.email);
// Pass email to verify-email page for resend functionality
navigate('/verify-email', { state: { email: data.email } });
}, },
}); });
}; };

View File

@@ -1,36 +1,115 @@
/** /**
* @ai-summary Mobile email verification screen with polling and resend * @ai-summary Mobile "Check Your Email" screen - handles both pre-verification and post-verification states
* @ai-context No authentication required - auto-triggers login when verification success is detected
*/ */
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button'; import { Button } from '../../../shared-minimal/components/Button';
import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus'; import { useResendVerificationPublic } from '../hooks/useVerifyStatus';
export const VerifyEmailMobileScreen: React.FC = () => { export const VerifyEmailMobileScreen: React.FC = () => {
const navigate = useNavigate(); const location = useLocation();
const { data: verifyStatus, isLoading } = useVerifyStatus({ const { loginWithRedirect, isAuthenticated, isLoading: isAuthLoading } = useAuth0();
enablePolling: true, const { mutate: resendVerification, isPending: isResending } = useResendVerificationPublic();
});
const { mutate: resendVerification, isPending: isResending } = useResendVerification();
// Track if auto-login is in progress
const [isAutoLoginTriggered, setIsAutoLoginTriggered] = useState(false);
// Get email from navigation state (passed from signup page) or localStorage
// localStorage is used when Auth0 redirects after verification (state is lost)
const stateEmail = (location.state as { email?: string })?.email || '';
const storedEmail = localStorage.getItem('pendingVerificationEmail') || '';
const email = stateEmail || storedEmail;
// Parse URL search params for verification success detection
// Auth0 redirects with ?message=Your%20email%20was%20verified... on success
const searchParams = new URLSearchParams(location.search);
const message = searchParams.get('message');
const verificationSuccessful = message && message.toLowerCase().includes('verified');
// Auto-login effect: triggers when verification success is detected
useEffect(() => { useEffect(() => {
if (verifyStatus?.emailVerified) { if (isAuthLoading) return;
navigate('/onboarding'); if (isAuthenticated) return;
if (verificationSuccessful && !isAutoLoginTriggered) {
setIsAutoLoginTriggered(true);
console.log('[VerifyEmailMobileScreen] Verification success detected, triggering auto-login with email:', email);
// Clear the stored email after successful verification
localStorage.removeItem('pendingVerificationEmail');
// Redirect to Auth0 login with email pre-filled
loginWithRedirect({
appState: { returnTo: '/callback' },
authorizationParams: {
login_hint: email || undefined,
},
});
} }
}, [verifyStatus, navigate]); }, [verificationSuccessful, isAuthenticated, isAuthLoading, isAutoLoginTriggered, loginWithRedirect, email]);
const handleResend = () => { const handleResend = () => {
resendVerification(); if (email) {
resendVerification(email);
}
}; };
if (isLoading) { const handleBackToLogin = () => {
loginWithRedirect();
};
// Show loading state when auto-login is in progress
if (isAutoLoginTriggered || (verificationSuccessful && !isAuthLoading)) {
return ( return (
<MobileContainer> <MobileContainer>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-lg text-slate-600">Loading...</div> <div className="text-center px-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">
Email Verified!
</h2>
<p className="text-slate-600 mb-4">Logging you in automatically...</p>
<div className="inline-flex items-center justify-center w-8 h-8">
<svg
className="w-8 h-8 text-primary-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
</div>
</div> </div>
</MobileContainer> </MobileContainer>
); );
@@ -59,28 +138,42 @@ export const VerifyEmailMobileScreen: React.FC = () => {
<p className="text-slate-600"> <p className="text-slate-600">
We've sent a verification link to We've sent a verification link to
</p> </p>
<p className="text-primary-600 font-medium mt-1 break-words px-4"> {email && (
{verifyStatus?.email} <p className="text-primary-600 font-medium mt-1 break-words px-4">
</p> {email}
</p>
)}
</div> </div>
<GlassCard> <GlassCard>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-700"> <div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-700">
<p className="mb-2">Click the link in the email to verify your account.</p> <p className="mb-2">Click the link in the email to verify your account.</p>
<p>Once verified, you'll be automatically redirected to complete your profile.</p> <p>Once verified, you can log in to complete your profile setup.</p>
</div> </div>
<div className="text-center"> <div className="space-y-3">
<p className="text-sm text-slate-600 mb-3">Didn't receive the email?</p>
<Button <Button
onClick={handleResend} onClick={handleBackToLogin}
loading={isResending} variant="primary"
variant="secondary"
className="w-full min-h-[44px]" className="w-full min-h-[44px]"
> >
Resend Verification Email Back to Login
</Button> </Button>
{email && (
<div className="text-center">
<p className="text-sm text-slate-600 mb-2">Didn't receive the email?</p>
<Button
onClick={handleResend}
loading={isResending}
variant="secondary"
className="w-full min-h-[44px]"
>
Resend Verification Email
</Button>
</div>
)}
</div> </div>
</div> </div>
</GlassCard> </GlassCard>

View File

@@ -0,0 +1,83 @@
/**
* @ai-summary Auth0 callback handler page - routes users based on their status
* @ai-context Fetches user status after Auth0 callback and redirects appropriately
*/
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useUserStatus } from '../../../core/auth/useUserStatus';
export const CallbackPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { data: userStatus, isLoading, error } = useUserStatus();
// Get returnTo from location state (passed from Auth0Provider onRedirectCallback)
const returnTo = (location.state as { returnTo?: string })?.returnTo || '/garage';
useEffect(() => {
if (isLoading) return;
if (error) {
console.error('[CallbackPage] Error fetching user status:', error);
// On error, redirect to garage and let normal auth flow handle it
navigate('/garage', { replace: true });
return;
}
if (userStatus) {
// Note: Unverified users should never reach this page if Auth0 action is configured
// But as a safety check, redirect to verify-email if not verified
if (!userStatus.emailVerified) {
console.log('[CallbackPage] User not verified, redirecting to verify-email');
navigate('/verify-email', { replace: true });
return;
}
// Check if onboarding is completed
if (!userStatus.onboardingCompleted) {
console.log('[CallbackPage] User not onboarded, redirecting to onboarding');
navigate('/onboarding', { replace: true });
return;
}
// User is verified and onboarded - go to requested destination
console.log('[CallbackPage] User verified and onboarded, redirecting to:', returnTo);
navigate(returnTo, { replace: true });
}
}, [userStatus, isLoading, error, navigate, returnTo]);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary-100 mb-4">
<svg
className="w-8 h-8 text-primary-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">
Setting up your session...
</h2>
<p className="text-slate-600">Please wait while we prepare your garage.</p>
</div>
</div>
);
};
export default CallbackPage;

View File

@@ -15,7 +15,11 @@ export const SignupPage: React.FC = () => {
const handleSubmit = (data: SignupRequest) => { const handleSubmit = (data: SignupRequest) => {
signup(data, { signup(data, {
onSuccess: () => { onSuccess: () => {
navigate('/verify-email'); // Store email in localStorage for post-verification login_hint
// (navigation state is lost when Auth0 redirects after verification)
localStorage.setItem('pendingVerificationEmail', data.email);
// Pass email to verify-email page for resend functionality
navigate('/verify-email', { state: { email: data.email } });
}, },
}); });
}; };

View File

@@ -1,33 +1,112 @@
/** /**
* @ai-summary Desktop email verification page with polling and resend functionality * @ai-summary Desktop "Check Your Email" page - handles both pre-verification and post-verification states
* @ai-context No authentication required - auto-triggers login when verification success is detected
*/ */
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus'; import { useAuth0 } from '@auth0/auth0-react';
import { useResendVerificationPublic } from '../hooks/useVerifyStatus';
import { Button } from '../../../shared-minimal/components/Button'; import { Button } from '../../../shared-minimal/components/Button';
export const VerifyEmailPage: React.FC = () => { export const VerifyEmailPage: React.FC = () => {
const navigate = useNavigate(); const location = useLocation();
const { data: verifyStatus, isLoading } = useVerifyStatus({ const { loginWithRedirect, isAuthenticated, isLoading: isAuthLoading } = useAuth0();
enablePolling: true, const { mutate: resendVerification, isPending: isResending } = useResendVerificationPublic();
});
const { mutate: resendVerification, isPending: isResending } = useResendVerification();
// Track if auto-login is in progress
const [isAutoLoginTriggered, setIsAutoLoginTriggered] = useState(false);
// Get email from navigation state (passed from signup page) or localStorage
// localStorage is used when Auth0 redirects after verification (state is lost)
const stateEmail = (location.state as { email?: string })?.email || '';
const storedEmail = localStorage.getItem('pendingVerificationEmail') || '';
const email = stateEmail || storedEmail;
// Parse URL search params for verification success detection
// Auth0 redirects with ?message=Your%20email%20was%20verified... on success
const searchParams = new URLSearchParams(location.search);
const message = searchParams.get('message');
const verificationSuccessful = message && message.toLowerCase().includes('verified');
// Auto-login effect: triggers when verification success is detected
useEffect(() => { useEffect(() => {
if (verifyStatus?.emailVerified) { if (isAuthLoading) return;
navigate('/onboarding'); if (isAuthenticated) return;
if (verificationSuccessful && !isAutoLoginTriggered) {
setIsAutoLoginTriggered(true);
console.log('[VerifyEmailPage] Verification success detected, triggering auto-login with email:', email);
// Clear the stored email after successful verification
localStorage.removeItem('pendingVerificationEmail');
// Redirect to Auth0 login with email pre-filled
loginWithRedirect({
appState: { returnTo: '/callback' },
authorizationParams: {
login_hint: email || undefined,
},
});
} }
}, [verifyStatus, navigate]); }, [verificationSuccessful, isAuthenticated, isAuthLoading, isAutoLoginTriggered, loginWithRedirect, email]);
const handleResend = () => { const handleResend = () => {
resendVerification(); if (email) {
resendVerification(email);
}
}; };
if (isLoading) { const handleBackToLogin = () => {
loginWithRedirect();
};
// Show loading state when auto-login is in progress
if (isAutoLoginTriggered || (verificationSuccessful && !isAuthLoading)) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
<div className="text-lg text-gray-600">Loading...</div> <div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">
Email Verified!
</h2>
<p className="text-slate-600 mb-4">Logging you in automatically...</p>
<div className="inline-flex items-center justify-center w-8 h-8">
<svg
className="w-8 h-8 text-primary-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
</div>
</div> </div>
); );
} }
@@ -56,27 +135,41 @@ export const VerifyEmailPage: React.FC = () => {
<p className="text-gray-600"> <p className="text-gray-600">
We've sent a verification link to We've sent a verification link to
</p> </p>
<p className="text-primary-600 font-medium mt-1"> {email && (
{verifyStatus?.email} <p className="text-primary-600 font-medium mt-1">
</p> {email}
</p>
)}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700"> <div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700">
<p className="mb-2">Click the link in the email to verify your account.</p> <p className="mb-2">Click the link in the email to verify your account.</p>
<p>Once verified, you'll be automatically redirected to complete your profile.</p> <p>Once verified, you can log in to complete your profile setup.</p>
</div> </div>
<div className="text-center"> <div className="space-y-3">
<p className="text-sm text-gray-600 mb-3">Didn't receive the email?</p>
<Button <Button
onClick={handleResend} onClick={handleBackToLogin}
loading={isResending} variant="primary"
variant="secondary"
className="w-full min-h-[44px]" className="w-full min-h-[44px]"
> >
Resend Verification Email Back to Login
</Button> </Button>
{email && (
<div className="text-center">
<p className="text-sm text-gray-600 mb-2">Didn't receive the email?</p>
<Button
onClick={handleResend}
loading={isResending}
variant="secondary"
className="w-full min-h-[44px]"
>
Resend Verification Email
</Button>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -21,3 +21,13 @@ export interface VerifyStatusResponse {
export interface ResendVerificationResponse { export interface ResendVerificationResponse {
message: string; message: string;
} }
export interface ResendVerificationPublicRequest {
email: string;
}
export interface UserStatusResponse {
emailVerified: boolean;
onboardingCompleted: boolean;
email: string;
}

View File

@@ -57,7 +57,7 @@ export const OnboardingMobileScreen: React.FC = () => {
const handleComplete = async () => { const handleComplete = async () => {
try { try {
await completeOnboarding.mutateAsync(); await completeOnboarding.mutateAsync();
navigate('/vehicles'); navigate('/garage');
} catch (error) { } catch (error) {
// Error is handled by the mutation hook // Error is handled by the mutation hook
} }

View File

@@ -55,7 +55,7 @@ export const OnboardingPage: React.FC = () => {
const handleComplete = async () => { const handleComplete = async () => {
try { try {
await completeOnboarding.mutateAsync(); await completeOnboarding.mutateAsync();
navigate('/vehicles'); navigate('/garage');
} catch (error) { } catch (error) {
// Error is handled by the mutation hook // Error is handled by the mutation hook
} }