From 9d5a40944ff2312c8a9d57ed3c22eaaaf5d6c24b Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 17:37:38 -0800 Subject: [PATCH] Implement web login page with OTP verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add login page with email validation and invite code support - Add OTP verification page with 6-digit code input - Implement redirect logic based on user state - Add placeholder dashboard page - Add comprehensive test suite for login flow - Export api as default for cleaner imports - All tests passing (12/12) Features: - Email validation with proper error handling - Optional invite code input - OTP resend with 60s countdown - Auto-submit when all 6 digits entered - Loading states and error messages - Redirect to dashboard if already authenticated - Proper navigation flow after OTP verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- web/__tests__/login.test.tsx | 222 ++++++++++++++++++++++++ web/app/(auth)/login/page.tsx | 247 +++++++++++++++++++++++++++ web/app/(auth)/verify-otp/page.tsx | 262 +++++++++++++++++++++++++++++ web/app/(main)/dashboard/page.tsx | 12 ++ web/lib/api.ts | 1 + 5 files changed, 744 insertions(+) create mode 100644 web/__tests__/login.test.tsx create mode 100644 web/app/(auth)/login/page.tsx create mode 100644 web/app/(auth)/verify-otp/page.tsx create mode 100644 web/app/(main)/dashboard/page.tsx diff --git a/web/__tests__/login.test.tsx b/web/__tests__/login.test.tsx new file mode 100644 index 0000000..20680af --- /dev/null +++ b/web/__tests__/login.test.tsx @@ -0,0 +1,222 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { useRouter } from 'next/navigation'; +import LoginPage from '../app/(auth)/login/page'; +import api from '../lib/api'; + +// Mock Next.js router +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})); + +// Mock API +jest.mock('../lib/api', () => ({ + __esModule: true, + default: { + getToken: jest.fn(), + checkEmail: jest.fn(), + requestOTP: jest.fn(), + saveEmail: jest.fn(), + }, +})); + +describe('LoginPage', () => { + const mockPush = jest.fn(); + const mockReplace = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + replace: mockReplace, + }); + (api.getToken as jest.Mock).mockResolvedValue(null); + }); + + it('renders login form correctly', () => { + render(); + + expect(screen.getByText('Welcome to WellNuo')).toBeInTheDocument(); + expect(screen.getByLabelText('Email address')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /get verification code/i })).toBeInTheDocument(); + }); + + it('redirects to dashboard if already authenticated', async () => { + (api.getToken as jest.Mock).mockResolvedValue('mock-token'); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('shows error for empty email', async () => { + render(); + + const form = screen.getByRole('button', { name: /get verification code/i }).closest('form'); + fireEvent.submit(form!); + + await waitFor(() => { + expect(screen.getByText('Email is required')).toBeInTheDocument(); + }); + }); + + it('shows error for invalid email format', async () => { + render(); + + const emailInput = screen.getByLabelText('Email address'); + const form = screen.getByRole('button', { name: /get verification code/i }).closest('form'); + + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + fireEvent.submit(form!); + + await waitFor(() => { + expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument(); + }); + }); + + it('validates correct email format', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } }); + (api.requestOTP as jest.Mock).mockResolvedValue({ ok: true }); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.checkEmail).toHaveBeenCalledWith('test@example.com'); + expect(api.requestOTP).toHaveBeenCalledWith('test@example.com'); + }); + }); + + it('navigates to verify-otp on successful OTP request', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } }); + (api.requestOTP as jest.Mock).mockResolvedValue({ ok: true }); + (api.saveEmail as jest.Mock).mockResolvedValue(undefined); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const form = screen.getByRole('button', { name: /get verification code/i }).closest('form'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.submit(form!); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + expect.stringMatching(/\/verify-otp\?email=test(%40|@)example\.com/) + ); + }); + }); + + it('shows loading state during OTP request', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } }); + (api.requestOTP as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('Sending code...')).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); + }); + }); + + it('shows error message on failed OTP request', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } }); + (api.requestOTP as jest.Mock).mockResolvedValue({ + ok: false, + error: { message: 'Failed to send OTP' }, + }); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('Failed to send OTP')).toBeInTheDocument(); + }); + }); + + it('shows invite code field when toggled', () => { + render(); + + const inviteToggle = screen.getByText('Have an invite code?'); + fireEvent.click(inviteToggle); + + expect(screen.getByLabelText('Invite code (optional)')).toBeInTheDocument(); + }); + + it('includes invite code in navigation params when provided', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: false } }); + (api.requestOTP as jest.Mock).mockResolvedValue({ ok: true }); + (api.saveEmail as jest.Mock).mockResolvedValue(undefined); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const inviteToggle = screen.getByText('Have an invite code?'); + fireEvent.click(inviteToggle); + + const inviteInput = screen.getByLabelText('Invite code (optional)'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + fireEvent.change(inviteInput, { target: { value: 'INVITE123' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + expect.stringContaining('inviteCode=INVITE123') + ); + }); + }); + + it('handles network errors gracefully', async () => { + (api.checkEmail as jest.Mock).mockRejectedValue(new Error('Network error')); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('Network error. Please check your connection.')).toBeInTheDocument(); + }); + }); + + it('trims and lowercases email before submission', async () => { + (api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } }); + (api.requestOTP as jest.Mock).mockResolvedValue({ ok: true }); + + render(); + + const emailInput = screen.getByLabelText('Email address'); + const submitButton = screen.getByRole('button', { name: /get verification code/i }); + + fireEvent.change(emailInput, { target: { value: ' TEST@EXAMPLE.COM ' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.checkEmail).toHaveBeenCalledWith('test@example.com'); + expect(api.requestOTP).toHaveBeenCalledWith('test@example.com'); + }); + }); +}); diff --git a/web/app/(auth)/login/page.tsx b/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..b4996db --- /dev/null +++ b/web/app/(auth)/login/page.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import api from '@/lib/api'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [inviteCode, setInviteCode] = useState(''); + const [showInviteCode, setShowInviteCode] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Check if already authenticated + useEffect(() => { + const checkAuth = async () => { + const token = await api.getToken(); + if (token) { + router.replace('/dashboard'); + } + }; + checkAuth(); + }, [router]); + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleContinue = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedEmail = email.trim().toLowerCase(); + + // Validate email + if (!trimmedEmail) { + setError('Email is required'); + return; + } + + if (!validateEmail(trimmedEmail)) { + setError('Please enter a valid email address'); + return; + } + + setIsLoading(true); + + try { + // Check if email exists in database + const checkResult = await api.checkEmail(trimmedEmail); + + // Request OTP code + const otpResult = await api.requestOTP(trimmedEmail); + + if (otpResult.ok) { + // Save email for OTP verification page + await api.saveEmail(trimmedEmail); + + // Navigate to OTP verification + const params = new URLSearchParams({ + email: trimmedEmail, + isNewUser: checkResult.ok && checkResult.data?.exists ? '0' : '1', + }); + + if (inviteCode.trim()) { + params.set('inviteCode', inviteCode.trim()); + } + + router.push(`/verify-otp?${params.toString()}`); + } else { + setError(otpResult.error?.message || 'Failed to send verification code. Please try again.'); + } + } catch { + setError('Network error. Please check your connection.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ W +
+

+ Welcome to WellNuo +

+

+ Enter your email to get started +

+
+ + {/* Login Form */} +
+
+ {/* Email Input */} +
+ +
+ setEmail(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + placeholder="you@example.com" + disabled={isLoading} + /> +
+
+ + {/* Invite Code Toggle */} +
+ +
+ + {/* Invite Code Input */} + {showInviteCode && ( +
+ +
+ setInviteCode(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + placeholder="Enter invite code" + disabled={isLoading} + /> +
+
+ )} + + {/* Error Message */} + {error && ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ )} + + {/* Submit Button */} +
+ +
+
+ + {/* Footer */} +
+
+
+
+
+
+ + By continuing, you agree to our Terms & Privacy Policy + +
+
+
+
+ + {/* Additional Info */} +
+

We'll send you a verification code to your email

+
+
+
+ ); +} diff --git a/web/app/(auth)/verify-otp/page.tsx b/web/app/(auth)/verify-otp/page.tsx new file mode 100644 index 0000000..104a6e6 --- /dev/null +++ b/web/app/(auth)/verify-otp/page.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import api from '@/lib/api'; + +export default function VerifyOtpPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get('email') || ''; + + const [otp, setOtp] = useState(['', '', '', '', '', '']); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [canResend, setCanResend] = useState(false); + const [countdown, setCountdown] = useState(60); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + // Countdown timer for resend + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + } else { + setCanResend(true); + } + }, [countdown]); + + // Focus first input on mount + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index: number, value: string) => { + // Only allow digits + if (!/^\d*$/.test(value)) return; + + const newOtp = [...otp]; + newOtp[index] = value.slice(-1); // Take only the last digit + setOtp(newOtp); + + // Auto-focus next input + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit when all 6 digits are entered + if (index === 5 && value && newOtp.every(digit => digit !== '')) { + handleVerify(newOtp.join('')); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + // Handle backspace + if (e.key === 'Backspace' && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + + if (pastedData.length === 6) { + const newOtp = pastedData.split(''); + setOtp(newOtp); + inputRefs.current[5]?.focus(); + handleVerify(pastedData); + } + }; + + const handleVerify = async (code?: string) => { + setError(null); + const otpCode = code || otp.join(''); + + if (otpCode.length !== 6) { + setError('Please enter the complete 6-digit code'); + return; + } + + setIsLoading(true); + + try { + const result = await api.verifyOTP(email, otpCode); + + if (result.ok && result.data) { + // Save auth token + localStorage.setItem('accessToken', result.data.accessToken); + localStorage.setItem('userId', result.data.userId.toString()); + + // Get user profile to determine next screen + const profileResult = await api.getMe(); + + if (profileResult.ok && profileResult.data) { + const user = profileResult.data.user; + const beneficiaries = profileResult.data.beneficiaries || []; + + // Navigation logic (similar to NavigationController) + if (!user.firstName) { + router.replace('/enter-name'); + } else if (beneficiaries.length === 0) { + router.replace('/add-loved-one'); + } else { + router.replace('/dashboard'); + } + } else { + router.replace('/dashboard'); + } + } else { + setError(result.error?.message || 'Invalid verification code'); + setOtp(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + } + } catch { + setError('Network error. Please try again.'); + setOtp(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + } finally { + setIsLoading(false); + } + }; + + const handleResend = async () => { + setError(null); + setCanResend(false); + setCountdown(60); + + try { + const result = await api.requestOTP(email); + if (!result.ok) { + setError('Failed to resend code. Please try again.'); + setCanResend(true); + } + } catch { + setError('Network error. Please try again.'); + setCanResend(true); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

+ Check your email +

+

+ We sent a verification code to +

+

+ {email} +

+
+ + {/* OTP Form */} +
+
+ {/* OTP Inputs */} +
+ +
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + disabled={isLoading} + className="w-12 h-12 text-center text-2xl font-bold border-2 border-gray-300 rounded-lg focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors" + /> + ))} +
+
+ + {/* Error Message */} + {error && ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ )} + + {/* Verify Button */} +
+ +
+ + {/* Resend Code */} +
+ {canResend ? ( + + ) : ( +

+ Resend code in {countdown}s +

+ )} +
+
+
+ + {/* Back to Login */} +
+ +
+
+
+ ); +} diff --git a/web/app/(main)/dashboard/page.tsx b/web/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..89f36f5 --- /dev/null +++ b/web/app/(main)/dashboard/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +export default function DashboardPage() { + return ( +
+
+

Dashboard

+

Coming soon...

+
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 69dcaed..0796f12 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -891,3 +891,4 @@ class ApiService { } export const api = new ApiService(); +export default api;