diff --git a/src/App.tsx b/src/App.tsx index 4692ac8..e28278e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import { Register } from '@/components/Register'; import { ForgotPassword } from '@/components/ForgotPassword'; import { ResetPassword } from '@/components/ResetPassword'; import { SuccessPage } from '@/components/SuccessPage'; +import { VerifyEmail } from '@/components/VerifyEmail'; +import { AuthCallback } from '@/components/AuthCallback'; import { DashboardPage } from '@/features/dashboard/DashboardPage'; import { AuthGuard } from '@/features/auth/AuthGuard'; import { useUserStore } from '@/stores/userStore'; @@ -18,7 +20,44 @@ function AppContent() { // Run once on app load to restore session via HttpOnly cookie if present useEffect(() => { + if (window.location.pathname === '/verify-email') { + const search = window.location.search; + window.location.replace(window.location.origin + '/#/verify-email' + search); + return; + } + + if (window.location.pathname === '/auth/callback') { + const hash = window.location.hash; + const cleanHash = hash.startsWith('#') ? hash.substring(1) : hash; + window.location.replace(window.location.origin + '/#/auth/callback?' + cleanHash); + return; + } + + if (window.location.pathname === '/reset-password') { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + if (token) { + window.location.replace(window.location.origin + '/#/reset-password/' + token); + return; + } + } + + if (window.location.pathname.startsWith('/reset-password/')) { + const token = window.location.pathname.substring(16); // '/reset-password/'.length === 16 + window.location.replace(window.location.origin + '/#/reset-password/' + token); + return; + } + const initializeSession = async () => { + // Extract Google OAuth token from URL hash if present on the callback route + const hash = window.location.hash; + if (hash.includes('/auth/callback') && hash.includes('token=')) { + const tokenMatch = hash.match(/token=([^&]+)/); + if (tokenMatch && tokenMatch[1]) { + useUserStore.getState().setAccessToken(tokenMatch[1]); + } + } + await initAuth(); setIsInitializing(false); }; @@ -82,6 +121,8 @@ function AppContent() { /> } /> + } /> + } /> navigate(ROUTES.LOGIN)} />} diff --git a/src/components/AuthCallback.tsx b/src/components/AuthCallback.tsx new file mode 100644 index 0000000..07521a3 --- /dev/null +++ b/src/components/AuthCallback.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { AuthLayout } from '@/components/ui/AuthLayout/AuthLayout'; +import { useUserStore } from '@/stores/userStore'; +import { ROUTES } from '@/constants/routes.constants'; + +export const AuthCallback: React.FC = () => { + const navigate = useNavigate(); + const isAuthenticated = useUserStore((s) => s.isAuthenticated); + + useEffect(() => { + if (isAuthenticated) { + // Clean up fragment from url and navigate to dashboard + window.history.replaceState(null, '', '/#/dashboard'); + navigate(ROUTES.DASHBOARD); + } else { + navigate(ROUTES.LOGIN); + } + }, [isAuthenticated, navigate]); + + return ( + +
+
+ +
+
+

Authenticating

+

Loading your Google trading profile...

+
+
+
+ ); +}; diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 697a542..bba427a 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -25,6 +25,22 @@ export const Login: React.FC = ({ const { setAccessToken, setUser } = useUserStore(); + const [resendStatus, setResendStatus] = useState<'idle' | 'loading' | 'success' | 'error'>( + 'idle', + ); + + const handleResendVerification = async () => { + if (!email) return; + try { + setResendStatus('loading'); + await authService.resendVerification(email); + setResendStatus('success'); + } catch (err) { + console.error(err); + setResendStatus('error'); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrorMsg(null); @@ -43,7 +59,9 @@ export const Login: React.FC = ({ onLoginSuccess(); } catch (err: any) { console.error('Login failed', err); - if (err.response?.status === 401 || err.response?.status === 400) { + if (err.response?.status === 403) { + setErrorMsg(err.response.data?.detail || 'Please verify your email before logging in.'); + } else if (err.response?.status === 401 || err.response?.status === 400) { setErrorMsg('Invalid email or password.'); } else { setErrorMsg('An unexpected error occurred. Please try again.'); @@ -66,7 +84,7 @@ export const Login: React.FC = ({
= ({ }} role="alert" > - - {errorMsg} +
+ + {errorMsg} +
+ {errorMsg.toLowerCase().includes('verify') && ( +
+ + {resendStatus === 'success' && ( + + Verification link resent successfully! + + )} + {resendStatus === 'error' && ( + + Failed to resend. Please try again. + + )} +
+ )}
)} @@ -174,6 +235,60 @@ export const Login: React.FC = ({ OR + +
Don't have an account? +
+ )} + + )} + + {status === 'error' && ( + <> +
+ +
+
+

+ Verification Failed +

+

{errorMessage}

+
+ +
+ +
+ + )} + + + ); +}; diff --git a/src/constants/routes.constants.ts b/src/constants/routes.constants.ts index 852a100..ff0a582 100644 --- a/src/constants/routes.constants.ts +++ b/src/constants/routes.constants.ts @@ -4,6 +4,8 @@ export const ROUTES = { FORGOT_PASSWORD: '/forgot-password', RESET_PASSWORD: '/reset-password/:token', REGISTER: '/register', + VERIFY_EMAIL: '/verify-email', + AUTH_CALLBACK: '/auth/callback', SUCCESS: '/success', DASHBOARD: '/dashboard', } as const; diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index d7e37cd..87ec7e2 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -76,16 +76,22 @@ export const authService = { /** * Verify a user's email using a verification token. */ - async verifyEmail(token: string): Promise<{ message: string }> { - const response = await apiClient.post('/auth/verify-email', { token }); + async verifyEmail( + token: string, + ): Promise<{ message: string; access_token?: string; token_type?: string }> { + const response = await apiClient.post<{ + message: string; + access_token?: string; + token_type?: string; + }>('/auth/verify-email', { token }); return response.data; }, /** * Resend email verification link. */ - async resendVerification(email: string): Promise<{ message: string }> { - const response = await apiClient.post('/auth/resend-verification', { email }); + async resendVerification(username_or_email: string): Promise<{ message: string }> { + const response = await apiClient.post('/auth/resend-verification', { username_or_email }); return response.data; }, diff --git a/src/test/Login.test.tsx b/src/test/Login.test.tsx index 35e99da..5068e22 100644 --- a/src/test/Login.test.tsx +++ b/src/test/Login.test.tsx @@ -15,10 +15,10 @@ vi.mock('../features/auth/auth.service', () => ({ /** * Helper to render the Login component within a Router context. */ -const renderLogin = (onSuccess = vi.fn(), onBack = vi.fn()) => +const renderLogin = (onSuccess = vi.fn()) => render( - + , ); diff --git a/vite.config.ts b/vite.config.ts index 6319347..5e2f7e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,11 @@ import path from 'path'; export default defineConfig({ plugins: [react()], + // ── Dev Server ─────────────────────────────────────────────────────────────── + server: { + port: 3000, + }, + // ── Path Aliases ──────────────────────────────────────────────────────────── // Enables: import { X } from '@/features/...' instead of '../../../features/...' resolve: {