Skip to content

Commit 1911fc1

Browse files
committed
Implement email verification flow for authentication
Add comprehensive email verification system to prevent unverified users from accessing the application. Features: - Email verification screen after signup with resend functionality - Login blocked until email is verified with clear error message - Resend verification email button with loading states and feedback - Visual consistency with existing auth page designs (teal theme, backdrop blur) Changes: - Signup.tsx: Show verification email screen after successful signup instead of auto-login - Signup.tsx: Add RESEND_VERIFICATION_EMAIL mutation and handler - LoginForm.tsx: Check isEmailVerified flag and prevent login if false - LoginForm.tsx: Remove unused OAuth loading states and handlers - All pages: Maintain consistent visual language with lagoon background
1 parent e562d38 commit 1911fc1

4 files changed

Lines changed: 137 additions & 65 deletions

File tree

packages/web/src/pages/ForgotPassword.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function ForgotPassword() {
4444
} else {
4545
setError(data.error || 'Failed to send reset link');
4646
}
47-
} catch (error) {
47+
} catch {
4848
setError('Failed to send reset link. Please try again.');
4949
} finally {
5050
setLoading(false);

packages/web/src/pages/LoginForm.tsx

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
33
import { useMutation, useQuery, gql } from '@apollo/client';
4-
import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Sparkles, Zap, Check, CheckCircle, XCircle } from 'lucide-react';
4+
import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle } from 'lucide-react';
55
import { useAuth } from '../contexts/AuthContext';
66
import { TlsStatusIndicator } from '../components/TlsStatusIndicator';
77
import { isValidEmail } from '../utils/validation';
@@ -95,7 +95,6 @@ export function LoginForm() {
9595
const [rememberMe, setRememberMe] = useState(false);
9696
const [emailValid, setEmailValid] = useState<boolean | null>(null);
9797
const [magicLinkEmailValid, setMagicLinkEmailValid] = useState<boolean | null>(null);
98-
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
9998

10099
useEffect(() => {
101100
const token = searchParams.get('token');
@@ -135,6 +134,12 @@ export function LoginForm() {
135134

136135
const [login, { loading }] = useMutation(LOGIN_MUTATION, {
137136
onCompleted: (data) => {
137+
if (!data.login.user.isEmailVerified) {
138+
setErrors({
139+
submit: 'Please verify your email before logging in. Check your inbox for the verification link.'
140+
});
141+
return;
142+
}
138143
setAuthUser(data.login.user, data.login.token);
139144
navigate('/');
140145
},
@@ -195,30 +200,6 @@ export function LoginForm() {
195200
await guestLogin();
196201
};
197202

198-
const handleOAuthLogin = async (provider: 'google' | 'linkedin' | 'github') => {
199-
setOauthLoading(provider);
200-
setErrors({});
201-
202-
try {
203-
const apiUrl = import.meta.env.VITE_API_URL || 'https://localhost:4128';
204-
const healthResponse = await fetch(`${apiUrl}/health`, {
205-
method: 'GET',
206-
signal: AbortSignal.timeout(5000)
207-
});
208-
209-
if (!healthResponse.ok) {
210-
throw new Error('Backend unavailable');
211-
}
212-
213-
window.location.href = `${apiUrl}/auth/${provider}`;
214-
} catch (error) {
215-
setOauthLoading(null);
216-
setErrors({
217-
submit: `Unable to connect to ${provider.charAt(0).toUpperCase() + provider.slice(1)}. Please try again or use email/password.`
218-
});
219-
}
220-
};
221-
222203
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
223204
const { name, value } = e.target;
224205
setFormData({ ...formData, [name]: value });
@@ -300,7 +281,7 @@ export function LoginForm() {
300281
} else {
301282
setErrors({ submit: data.error || 'Failed to send magic link' });
302283
}
303-
} catch (error) {
284+
} catch {
304285
setErrors({ submit: 'Failed to send magic link. Please try again.' });
305286
} finally {
306287
setMagicLinkLoading(false);
@@ -696,7 +677,7 @@ export function LoginForm() {
696677
Quick access for testing. Please change these passwords in production!
697678
</p>
698679
<div className="space-y-2">
699-
{defaultAccounts.map((account: any) => (
680+
{defaultAccounts.map((account: { username: string; password: string; role: string; description: string }) => (
700681
<button
701682
key={account.username}
702683
onClick={() => fillDefaultCredentials(account.username, account.password)}

packages/web/src/pages/ResetPassword.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ export function ResetPassword() {
6363
} else {
6464
setError(data.error || 'Failed to reset password');
6565
}
66-
} catch (error) {
67-
console.error('Reset password error:', error);
66+
} catch {
6867
setError('Failed to reset password. Please try again.');
6968
} finally {
7069
setLoading(false);

packages/web/src/pages/Signup.tsx

Lines changed: 126 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { Link, useNavigate } from 'react-router-dom';
33
import { useMutation, gql } from '@apollo/client';
4-
import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github } from 'lucide-react';
5-
import { useAuth } from '../contexts/AuthContext';
4+
import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail } from 'lucide-react';
65
import { TlsStatusIndicator } from '../components/TlsStatusIndicator';
76
import { isValidEmail, getPasswordStrength } from '../utils/validation';
87

@@ -31,40 +30,61 @@ const CHECK_AVAILABILITY = gql`
3130
}
3231
`;
3332

33+
const RESEND_VERIFICATION_EMAIL = gql`
34+
mutation ResendVerificationEmail($email: String!) {
35+
resendVerificationEmail(email: $email) {
36+
success
37+
message
38+
}
39+
}
40+
`;
41+
3442
export function Signup() {
3543
const navigate = useNavigate();
36-
const { login: setAuthUser } = useAuth();
37-
44+
3845
const [formData, setFormData] = useState({
3946
email: '',
4047
username: '',
4148
password: '',
4249
confirmPassword: '',
4350
name: ''
4451
});
45-
52+
4653
const [showPassword, setShowPassword] = useState(false);
4754
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
4855
const [errors, setErrors] = useState<Record<string, string>>({});
4956
const [isChecking, setIsChecking] = useState<Record<string, boolean>>({});
5057
const [availability, setAvailability] = useState<Record<string, boolean>>({});
5158
const [emailValid, setEmailValid] = useState<boolean | null>(null);
5259
const [passwordsMatch, setPasswordsMatch] = useState<boolean | null>(null);
60+
const [signupComplete, setSignupComplete] = useState(false);
61+
const [resendLoading, setResendLoading] = useState(false);
62+
const [resendMessage, setResendMessage] = useState('');
5363

5464
const [signup, { loading }] = useMutation(SIGNUP_MUTATION, {
5565
onCompleted: (data) => {
56-
// Store token in localStorage
57-
localStorage.setItem('authToken', data.signup.token);
58-
localStorage.setItem('currentUser', JSON.stringify(data.signup.user));
59-
60-
// Navigate to main app
61-
navigate('/');
66+
setSignupComplete(true);
6267
},
6368
onError: (error) => {
6469
setErrors({ submit: error.message });
6570
}
6671
});
6772

73+
const [resendVerificationEmail] = useMutation(RESEND_VERIFICATION_EMAIL, {
74+
onCompleted: (data) => {
75+
setResendLoading(false);
76+
if (data.resendVerificationEmail.success) {
77+
setResendMessage('Verification email sent! Check your inbox.');
78+
} else {
79+
setResendMessage(data.resendVerificationEmail.message || 'Failed to send email. Please try again.');
80+
}
81+
},
82+
onError: () => {
83+
setResendLoading(false);
84+
setResendMessage('Failed to send email. Please try again.');
85+
}
86+
});
87+
6888
const checkAvailability = async (field: 'email' | 'username', value: string) => {
6989
if (!value) return;
7090

@@ -198,15 +218,27 @@ export function Signup() {
198218
}
199219
};
200220

221+
const handleResendVerificationEmail = async () => {
222+
setResendLoading(true);
223+
setResendMessage('');
224+
225+
await resendVerificationEmail({
226+
variables: {
227+
email: formData.email
228+
}
229+
});
230+
};
231+
201232
useEffect(() => {
202233
const handleKeyPress = (e: KeyboardEvent) => {
203234
if (e.key === 'Enter' && !loading && !Object.values(isChecking).some(checking => checking)) {
204-
handleSubmit(e as any);
235+
const submitEvent = e as unknown as React.FormEvent;
236+
void handleSubmit(submitEvent);
205237
}
206238
};
207239
window.addEventListener('keydown', handleKeyPress);
208240
return () => window.removeEventListener('keydown', handleKeyPress);
209-
}, [formData, loading, isChecking]);
241+
}, [formData, loading, isChecking, handleSubmit]);
210242

211243
const passwordStrength = getPasswordStrength(formData.password);
212244

@@ -225,8 +257,64 @@ export function Signup() {
225257
<p className="text-gray-400 text-lg">Join the decentralized project management revolution</p>
226258
</div>
227259

228-
{/* Signup Form */}
229-
<form onSubmit={handleSubmit} className="bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl p-8 space-y-5 shadow-2xl animate-in fade-in slide-in-from-bottom-4 duration-700">
260+
{/* Email Verification Screen or Signup Form */}
261+
{signupComplete ? (
262+
<div className="bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl p-8 space-y-5 shadow-2xl animate-in fade-in slide-in-from-bottom-4 duration-700">
263+
<div className="p-6 bg-teal-900/20 border border-teal-500/30 rounded-xl">
264+
<div className="flex items-center justify-center mb-4">
265+
<Mail className="h-16 w-16 text-teal-400" />
266+
</div>
267+
<h3 className="text-2xl font-semibold text-teal-300 text-center mb-3">Check Your Email!</h3>
268+
<p className="text-sm text-teal-200/80 text-center mb-4">
269+
We've sent a verification link to <strong>{formData.email}</strong>
270+
</p>
271+
<p className="text-xs text-teal-300/60 text-center mb-6">
272+
Click the link in the email to verify your account and complete registration. The link expires in 24 hours.
273+
</p>
274+
275+
<button
276+
type="button"
277+
onClick={handleResendVerificationEmail}
278+
disabled={resendLoading}
279+
className="w-full bg-gray-700/50 hover:bg-gray-600/80 backdrop-blur-sm border border-gray-600/50 hover:border-teal-500 text-teal-400 font-semibold py-3 px-6 rounded-xl transition-all duration-200 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center space-x-2"
280+
>
281+
{resendLoading ? (
282+
<>
283+
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-400"></div>
284+
<span>Sending...</span>
285+
</>
286+
) : (
287+
<>
288+
<Mail className="h-5 w-5" />
289+
<span>Resend Verification Email</span>
290+
</>
291+
)}
292+
</button>
293+
294+
{resendMessage && (
295+
<p className={`mt-3 text-xs text-center ${resendMessage.includes('sent') ? 'text-teal-400' : 'text-red-400'}`}>
296+
{resendMessage}
297+
</p>
298+
)}
299+
</div>
300+
301+
<div className="p-4 bg-blue-900/20 border border-blue-500/30 rounded-lg">
302+
<p className="text-xs text-blue-300 text-center">
303+
<strong>Didn't receive the email?</strong> Check your spam folder or click the button above to resend.
304+
</p>
305+
</div>
306+
307+
<div className="text-center">
308+
<Link
309+
to="/login"
310+
className="text-gray-300 hover:text-teal-400 transition-colors text-sm"
311+
>
312+
Back to login
313+
</Link>
314+
</div>
315+
</div>
316+
) : (
317+
<form onSubmit={handleSubmit} className="bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl p-8 space-y-5 shadow-2xl animate-in fade-in slide-in-from-bottom-4 duration-700">
230318
{/* Social Signup Buttons */}
231319
<div className="grid grid-cols-3 gap-3">
232320
<button
@@ -505,26 +593,30 @@ export function Signup() {
505593
and contribute to the collective intelligence.
506594
</p>
507595
</form>
596+
)}
597+
598+
{!signupComplete && (
599+
<>
600+
{/* Login Link */}
601+
<div className="mt-6 text-center">
602+
<p className="text-gray-300">
603+
Already have an account?{' '}
604+
<Link to="/login" className="text-teal-400 hover:text-teal-300 font-medium">
605+
Sign in
606+
</Link>
607+
</p>
608+
</div>
508609

509-
510-
{/* Login Link */}
511-
<div className="mt-6 text-center">
512-
<p className="text-gray-300">
513-
Already have an account?{' '}
514-
<Link to="/login" className="text-teal-400 hover:text-teal-300 font-medium">
515-
Sign in
516-
</Link>
517-
</p>
518-
</div>
519-
520-
{/* Role Information */}
521-
<div className="mt-8 p-4 bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-lg">
522-
<h3 className="text-sm font-semibold text-gray-100 mb-2">Your Journey Begins as a Viewer</h3>
523-
<p className="text-xs text-gray-400">
524-
All new members start with read-only access. As you contribute and demonstrate value,
525-
the community may elevate your role to User or even Admin.
526-
</p>
527-
</div>
610+
{/* Role Information */}
611+
<div className="mt-8 p-4 bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-lg">
612+
<h3 className="text-sm font-semibold text-gray-100 mb-2">Your Journey Begins as a Viewer</h3>
613+
<p className="text-xs text-gray-400">
614+
All new members start with read-only access. As you contribute and demonstrate value,
615+
the community may elevate your role to User or even Admin.
616+
</p>
617+
</div>
618+
</>
619+
)}
528620
</div>
529621

530622
{/* TLS/SSL Status Indicator */}

0 commit comments

Comments
 (0)