From 96ee43ea94bb26d20f8f64c788e187487ebdeead Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:26:10 -0600 Subject: [PATCH] feat: User onboarding finished --- .../src/features/auth/api/auth.controller.ts | 65 +++++++- backend/src/features/auth/api/auth.routes.ts | 9 ++ .../src/features/auth/api/auth.validation.ts | 7 + .../src/features/auth/domain/auth.service.ts | 83 ++++++++++ .../data/user-profile.repository.ts | 20 +++ frontend/src/App.tsx | 27 ++-- frontend/src/core/auth/Auth0Provider.tsx | 8 +- frontend/src/core/auth/useUserStatus.ts | 25 +++ frontend/src/features/auth/api/auth.api.ts | 22 ++- .../features/auth/hooks/useVerifyStatus.ts | 18 +++ .../auth/mobile/CallbackMobileScreen.tsx | 86 +++++++++++ .../auth/mobile/SignupMobileScreen.tsx | 6 +- .../auth/mobile/VerifyEmailMobileScreen.tsx | 143 +++++++++++++++--- .../src/features/auth/pages/CallbackPage.tsx | 83 ++++++++++ .../src/features/auth/pages/SignupPage.tsx | 6 +- .../features/auth/pages/VerifyEmailPage.tsx | 143 +++++++++++++++--- .../src/features/auth/types/auth.types.ts | 10 ++ .../mobile/OnboardingMobileScreen.tsx | 2 +- .../onboarding/pages/OnboardingPage.tsx | 2 +- 19 files changed, 698 insertions(+), 67 deletions(-) create mode 100644 frontend/src/core/auth/useUserStatus.ts create mode 100644 frontend/src/features/auth/mobile/CallbackMobileScreen.tsx create mode 100644 frontend/src/features/auth/pages/CallbackPage.tsx diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts index 83eb666..3f61af8 100644 --- a/backend/src/features/auth/api/auth.controller.ts +++ b/backend/src/features/auth/api/auth.controller.ts @@ -8,7 +8,7 @@ import { AuthService } from '../domain/auth.service'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; -import { signupSchema } from './auth.validation'; +import { signupSchema, resendVerificationPublicSchema } from './auth.validation'; export class AuthController { 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', + }); + } + } } diff --git a/backend/src/features/auth/api/auth.routes.ts b/backend/src/features/auth/api/auth.routes.ts index ba213e7..8cd531d 100644 --- a/backend/src/features/auth/api/auth.routes.ts +++ b/backend/src/features/auth/api/auth.routes.ts @@ -27,4 +27,13 @@ export const authRoutes: FastifyPluginAsync = async ( preHandler: [fastify.authenticate], 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), + }); }; diff --git a/backend/src/features/auth/api/auth.validation.ts b/backend/src/features/auth/api/auth.validation.ts index f51694b..814b839 100644 --- a/backend/src/features/auth/api/auth.validation.ts +++ b/backend/src/features/auth/api/auth.validation.ts @@ -21,3 +21,10 @@ export const signupSchema = z.object({ }); export type SignupInput = z.infer; + +// 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; diff --git a/backend/src/features/auth/domain/auth.service.ts b/backend/src/features/auth/domain/auth.service.ts index 62645cc..e43723f 100644 --- a/backend/src/features/auth/domain/auth.service.ts +++ b/backend/src/features/auth/domain/auth.service.ts @@ -127,4 +127,87 @@ export class AuthService { 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 { + 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; + } + } } diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts index dbc1d58..609f6ce 100644 --- a/backend/src/features/user-profile/data/user-profile.repository.ts +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -44,6 +44,26 @@ export class UserProfileRepository { } } + async getByEmail(email: string): Promise { + 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( auth0Sub: string, email: string, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf6c6a0..86fbc1b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,8 +47,10 @@ const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/m // Auth pages (lazy-loaded) 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 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 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) 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 ( -
-
Processing login...
-
+ +
Processing login...
+ + }> + {mobileMode ? : } +
+
); } @@ -507,11 +515,8 @@ function App() { ); } - if (!isAuthenticated) { - return ; - } - - // Verify email and onboarding routes require authentication but not full initialization + // Verify email is public - shown after signup before user can login + // (Auth0 blocks unverified users from logging in) if (isVerifyEmailRoute) { return ( @@ -528,6 +533,10 @@ function App() { ); } + if (!isAuthenticated) { + return ; + } + if (isOnboardingRoute) { return ( diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 7657762..6dbb857 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -26,8 +26,12 @@ export const Auth0Provider: React.FC = ({ children }) => { const onRedirectCallback = (appState?: { returnTo?: string }) => { console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); - const target = appState?.returnTo || '/garage'; - navigate(target, { replace: true }); + // Route to callback page which will check user status and redirect appropriately + // Pass the intended destination as state for after status check + navigate('/callback', { + replace: true, + state: { returnTo: appState?.returnTo || '/garage' }, + }); }; return ( diff --git a/frontend/src/core/auth/useUserStatus.ts b/frontend/src/core/auth/useUserStatus.ts new file mode 100644 index 0000000..56714e5 --- /dev/null +++ b/frontend/src/core/auth/useUserStatus.ts @@ -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({ + queryKey: ['userStatus'], + queryFn: authApi.getUserStatus, + enabled: (options?.enabled !== false) && isAuthenticated && !isAuthLoading, + retry: 2, + staleTime: 30000, // Cache for 30 seconds + }); +}; diff --git a/frontend/src/features/auth/api/auth.api.ts b/frontend/src/features/auth/api/auth.api.ts index 5eeee26..014c3e6 100644 --- a/frontend/src/features/auth/api/auth.api.ts +++ b/frontend/src/features/auth/api/auth.api.ts @@ -9,11 +9,13 @@ import { SignupResponse, VerifyStatusResponse, ResendVerificationResponse, + ResendVerificationPublicRequest, + UserStatusResponse, } from '../types/auth.types'; 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({ baseURL: API_BASE_URL, timeout: 10000, @@ -37,4 +39,22 @@ export const authApi = { const response = await apiClient.post('/auth/resend-verification'); 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 => { + 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 => { + const response = await apiClient.get('/auth/user-status'); + return response.data; + }, }; diff --git a/frontend/src/features/auth/hooks/useVerifyStatus.ts b/frontend/src/features/auth/hooks/useVerifyStatus.ts index 925460f..c33259d 100644 --- a/frontend/src/features/auth/hooks/useVerifyStatus.ts +++ b/frontend/src/features/auth/hooks/useVerifyStatus.ts @@ -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); + }, + }); +}; diff --git a/frontend/src/features/auth/mobile/CallbackMobileScreen.tsx b/frontend/src/features/auth/mobile/CallbackMobileScreen.tsx new file mode 100644 index 0000000..e214e9c --- /dev/null +++ b/frontend/src/features/auth/mobile/CallbackMobileScreen.tsx @@ -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 ( + +
+
+
+ + + + +
+

+ Setting up your session... +

+

Please wait while we prepare your garage.

+
+
+
+ ); +}; + +export default CallbackMobileScreen; diff --git a/frontend/src/features/auth/mobile/SignupMobileScreen.tsx b/frontend/src/features/auth/mobile/SignupMobileScreen.tsx index 1cd8816..6fb9768 100644 --- a/frontend/src/features/auth/mobile/SignupMobileScreen.tsx +++ b/frontend/src/features/auth/mobile/SignupMobileScreen.tsx @@ -17,7 +17,11 @@ export const SignupMobileScreen: React.FC = () => { const handleSubmit = (data: SignupRequest) => { signup(data, { 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 } }); }, }); }; diff --git a/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx b/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx index 9ecbab3..835d602 100644 --- a/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx +++ b/frontend/src/features/auth/mobile/VerifyEmailMobileScreen.tsx @@ -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 { useNavigate } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAuth0 } from '@auth0/auth0-react'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { Button } from '../../../shared-minimal/components/Button'; -import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus'; +import { useResendVerificationPublic } from '../hooks/useVerifyStatus'; export const VerifyEmailMobileScreen: React.FC = () => { - const navigate = useNavigate(); - const { data: verifyStatus, isLoading } = useVerifyStatus({ - enablePolling: true, - }); - const { mutate: resendVerification, isPending: isResending } = useResendVerification(); + const location = useLocation(); + const { loginWithRedirect, isAuthenticated, isLoading: isAuthLoading } = useAuth0(); + const { mutate: resendVerification, isPending: isResending } = useResendVerificationPublic(); + // 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(() => { - if (verifyStatus?.emailVerified) { - navigate('/onboarding'); + if (isAuthLoading) return; + 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 = () => { - resendVerification(); + if (email) { + resendVerification(email); + } }; - if (isLoading) { + const handleBackToLogin = () => { + loginWithRedirect(); + }; + + // Show loading state when auto-login is in progress + if (isAutoLoginTriggered || (verificationSuccessful && !isAuthLoading)) { return (
-
Loading...
+
+
+ + + +
+

+ Email Verified! +

+

Logging you in automatically...

+
+ + + + +
+
); @@ -59,28 +138,42 @@ export const VerifyEmailMobileScreen: React.FC = () => {

We've sent a verification link to

-

- {verifyStatus?.email} -

+ {email && ( +

+ {email} +

+ )}

Click the link in the email to verify your account.

-

Once verified, you'll be automatically redirected to complete your profile.

+

Once verified, you can log in to complete your profile setup.

-
-

Didn't receive the email?

+
+ + {email && ( +
+

Didn't receive the email?

+ +
+ )}
diff --git a/frontend/src/features/auth/pages/CallbackPage.tsx b/frontend/src/features/auth/pages/CallbackPage.tsx new file mode 100644 index 0000000..5941390 --- /dev/null +++ b/frontend/src/features/auth/pages/CallbackPage.tsx @@ -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 ( +
+
+
+ + + + +
+

+ Setting up your session... +

+

Please wait while we prepare your garage.

+
+
+ ); +}; + +export default CallbackPage; diff --git a/frontend/src/features/auth/pages/SignupPage.tsx b/frontend/src/features/auth/pages/SignupPage.tsx index 4e7b6d5..14ab4c9 100644 --- a/frontend/src/features/auth/pages/SignupPage.tsx +++ b/frontend/src/features/auth/pages/SignupPage.tsx @@ -15,7 +15,11 @@ export const SignupPage: React.FC = () => { const handleSubmit = (data: SignupRequest) => { signup(data, { 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 } }); }, }); }; diff --git a/frontend/src/features/auth/pages/VerifyEmailPage.tsx b/frontend/src/features/auth/pages/VerifyEmailPage.tsx index c07126f..20b8536 100644 --- a/frontend/src/features/auth/pages/VerifyEmailPage.tsx +++ b/frontend/src/features/auth/pages/VerifyEmailPage.tsx @@ -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 { useNavigate } from 'react-router-dom'; -import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus'; +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAuth0 } from '@auth0/auth0-react'; +import { useResendVerificationPublic } from '../hooks/useVerifyStatus'; import { Button } from '../../../shared-minimal/components/Button'; export const VerifyEmailPage: React.FC = () => { - const navigate = useNavigate(); - const { data: verifyStatus, isLoading } = useVerifyStatus({ - enablePolling: true, - }); - const { mutate: resendVerification, isPending: isResending } = useResendVerification(); + const location = useLocation(); + const { loginWithRedirect, isAuthenticated, isLoading: isAuthLoading } = useAuth0(); + const { mutate: resendVerification, isPending: isResending } = useResendVerificationPublic(); + // 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(() => { - if (verifyStatus?.emailVerified) { - navigate('/onboarding'); + if (isAuthLoading) return; + 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 = () => { - resendVerification(); + if (email) { + resendVerification(email); + } }; - if (isLoading) { + const handleBackToLogin = () => { + loginWithRedirect(); + }; + + // Show loading state when auto-login is in progress + if (isAutoLoginTriggered || (verificationSuccessful && !isAuthLoading)) { return (
-
Loading...
+
+
+ + + +
+

+ Email Verified! +

+

Logging you in automatically...

+
+ + + + +
+
); } @@ -56,27 +135,41 @@ export const VerifyEmailPage: React.FC = () => {

We've sent a verification link to

-

- {verifyStatus?.email} -

+ {email && ( +

+ {email} +

+ )}

Click the link in the email to verify your account.

-

Once verified, you'll be automatically redirected to complete your profile.

+

Once verified, you can log in to complete your profile setup.

-
-

Didn't receive the email?

+
+ + {email && ( +
+

Didn't receive the email?

+ +
+ )}
diff --git a/frontend/src/features/auth/types/auth.types.ts b/frontend/src/features/auth/types/auth.types.ts index 214b1c6..b7295da 100644 --- a/frontend/src/features/auth/types/auth.types.ts +++ b/frontend/src/features/auth/types/auth.types.ts @@ -21,3 +21,13 @@ export interface VerifyStatusResponse { export interface ResendVerificationResponse { message: string; } + +export interface ResendVerificationPublicRequest { + email: string; +} + +export interface UserStatusResponse { + emailVerified: boolean; + onboardingCompleted: boolean; + email: string; +} diff --git a/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx b/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx index fe237e2..40076a9 100644 --- a/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx +++ b/frontend/src/features/onboarding/mobile/OnboardingMobileScreen.tsx @@ -57,7 +57,7 @@ export const OnboardingMobileScreen: React.FC = () => { const handleComplete = async () => { try { await completeOnboarding.mutateAsync(); - navigate('/vehicles'); + navigate('/garage'); } catch (error) { // Error is handled by the mutation hook } diff --git a/frontend/src/features/onboarding/pages/OnboardingPage.tsx b/frontend/src/features/onboarding/pages/OnboardingPage.tsx index 0e6ddcd..2a7e648 100644 --- a/frontend/src/features/onboarding/pages/OnboardingPage.tsx +++ b/frontend/src/features/onboarding/pages/OnboardingPage.tsx @@ -55,7 +55,7 @@ export const OnboardingPage: React.FC = () => { const handleComplete = async () => { try { await completeOnboarding.mutateAsync(); - navigate('/vehicles'); + navigate('/garage'); } catch (error) { // Error is handled by the mutation hook }