feat: add email verification, password reset, and WSDC integration (Phase 1.5)
Backend features: - AWS SES email service with HTML templates - Email verification with dual method (link + 6-digit PIN code) - Password reset workflow with secure tokens - WSDC API proxy for dancer lookup and auto-fill registration - Extended User model with verification and WSDC fields - Email verification middleware for protected routes Frontend features: - Two-step registration with WSDC ID lookup - Password strength indicator component - Email verification page with code input - Password reset flow (request + reset pages) - Verification banner for unverified users - Updated authentication context and API service Testing: - 65 unit tests with 100% coverage of new features - Tests for auth utils, email service, WSDC controller, and middleware - Integration tests for full authentication flows - Comprehensive mocking of AWS SES and external APIs Database: - Migration: add WSDC fields (firstName, lastName, wsdcId) - Migration: add email verification fields (token, code, expiry) - Migration: add password reset fields (token, expiry) Documentation: - Complete Phase 1.5 documentation - Test suite documentation and best practices - Updated session context with new features
This commit is contained in:
134
frontend/src/pages/ForgotPasswordPage.jsx
Normal file
134
frontend/src/pages/ForgotPasswordPage.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { authAPI } from '../services/api';
|
||||
import { Video, Mail, ArrowLeft, CheckCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authAPI.requestPasswordReset(email);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Failed to send reset email. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Check Your Email
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
If an account exists with {email}, you will receive a password reset link shortly.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Didn't receive the email? Check your spam folder or try again.
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Reset Password</h1>
|
||||
<p className="text-gray-600 mt-2 text-center">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-500 inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
@@ -53,9 +53,17 @@ const LoginPage = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
|
||||
@@ -1,46 +1,274 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Video, Mail, Lock, User } from 'lucide-react';
|
||||
import { wsdcAPI } from '../services/api';
|
||||
import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
// Step management
|
||||
const [step, setStep] = useState(1); // 1 = WSDC check, 2 = Registration form
|
||||
|
||||
// WSDC lookup state
|
||||
const [hasWsdcId, setHasWsdcId] = useState(null);
|
||||
const [wsdcId, setWsdcId] = useState('');
|
||||
const [wsdcData, setWsdcData] = useState(null);
|
||||
const [wsdcLoading, setWsdcLoading] = useState(false);
|
||||
const [wsdcError, setWsdcError] = useState('');
|
||||
|
||||
// Registration form state
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
alert('Passwords do not match');
|
||||
// Handle WSDC ID lookup
|
||||
const handleWsdcLookup = async () => {
|
||||
if (!wsdcId || wsdcId.trim() === '') {
|
||||
setWsdcError('Please enter your WSDC ID');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
setWsdcLoading(true);
|
||||
setWsdcError('');
|
||||
|
||||
try {
|
||||
await register(username, email, password);
|
||||
const response = await wsdcAPI.lookupDancer(wsdcId);
|
||||
|
||||
if (response.success && response.dancer) {
|
||||
setWsdcData(response.dancer);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
firstName: response.dancer.firstName,
|
||||
lastName: response.dancer.lastName,
|
||||
}));
|
||||
setStep(2);
|
||||
} else {
|
||||
setWsdcError('WSDC ID not found. Please check and try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setWsdcError(err.data?.message || 'Failed to lookup WSDC ID. Please try again.');
|
||||
} finally {
|
||||
setWsdcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "No WSDC ID" option
|
||||
const handleNoWsdcId = () => {
|
||||
setHasWsdcId(false);
|
||||
setWsdcData(null);
|
||||
setWsdcId('');
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
// Handle form input changes
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle registration submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.firstName || null,
|
||||
formData.lastName || null,
|
||||
wsdcData?.wsdcId || null
|
||||
);
|
||||
navigate('/events');
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: WSDC ID Check
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">spotlight.cam</h1>
|
||||
<p className="text-gray-600 mt-2">Join the dance community</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Do you have a WSDC ID?</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
If you're registered with the World Swing Dance Council, we can automatically fill in your details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasWsdcId === null ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setHasWsdcId(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 px-4 border-2 border-primary-600 rounded-md text-primary-600 hover:bg-primary-50 font-medium transition"
|
||||
>
|
||||
<Hash className="w-5 h-5" />
|
||||
Yes, I have a WSDC ID
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNoWsdcId}
|
||||
className="w-full py-3 px-4 border-2 border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium transition"
|
||||
>
|
||||
No, I don't have one
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Enter your WSDC ID
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Hash className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={wsdcId}
|
||||
onChange={(e) => setWsdcId(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="26997"
|
||||
disabled={wsdcLoading}
|
||||
/>
|
||||
</div>
|
||||
{wsdcError && (
|
||||
<p className="mt-2 text-sm text-red-600">{wsdcError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleWsdcLookup}
|
||||
disabled={wsdcLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{wsdcLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Looking up...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setHasWsdcId(null)}
|
||||
disabled={wsdcLoading}
|
||||
className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 inline mr-2" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Registration Form
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4 py-8">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">spotlight.cam</h1>
|
||||
<p className="text-gray-600 mt-2">Create a new account</p>
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<Video className="w-12 h-12 text-primary-600 mb-3" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">Complete your registration</h1>
|
||||
{wsdcData && (
|
||||
<div className="mt-2 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
||||
✓ WSDC ID: {wsdcData.wsdcId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -49,17 +277,19 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your_username"
|
||||
placeholder="john_doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -68,17 +298,19 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -87,18 +319,21 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={formData.password} />
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm password
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
@@ -106,8 +341,9 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
@@ -115,16 +351,35 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
</button>
|
||||
<div className="space-y-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 inline mr-2" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
|
||||
140
frontend/src/pages/RegisterPage_old.jsx
Normal file
140
frontend/src/pages/RegisterPage_old.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Video, Mail, Lock, User } from 'lucide-react';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(username, email, password);
|
||||
navigate('/events');
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">spotlight.cam</h1>
|
||||
<p className="text-gray-600 mt-2">Create a new account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your_username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
196
frontend/src/pages/ResetPasswordPage.jsx
Normal file
196
frontend/src/pages/ResetPasswordPage.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { authAPI } from '../services/api';
|
||||
import { Video, Lock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
||||
|
||||
const ResetPasswordPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!token) {
|
||||
setError('Invalid or missing reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await authAPI.resetPassword(token, newPassword);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Failed to reset password. The link may have expired.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Password Reset Successfully! 🎉
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your password has been updated. You can now log in with your new password.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid token state
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<XCircle className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Invalid Reset Link
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
This password reset link is invalid or has expired. Please request a new one.
|
||||
</p>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium text-center"
|
||||
>
|
||||
Request New Link
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset password form
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Set New Password</h1>
|
||||
<p className="text-gray-600 mt-2 text-center">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md flex items-start gap-2">
|
||||
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthIndicator password={newPassword} />
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || newPassword !== confirmPassword}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link to="/login" className="text-sm font-medium text-primary-600 hover:text-primary-500">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
||||
236
frontend/src/pages/VerifyEmailPage.jsx
Normal file
236
frontend/src/pages/VerifyEmailPage.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { authAPI } from '../services/api';
|
||||
import { Video, Mail, CheckCircle, XCircle, Loader2, ArrowRight } from 'lucide-react';
|
||||
|
||||
const VerifyEmailPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [verificationMode, setVerificationMode] = useState(token ? 'token' : 'code');
|
||||
const [loading, setLoading] = useState(!!token); // Auto-loading if token exists
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Code verification state
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
// Auto-verify if token is in URL
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
verifyByToken(token);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const verifyByToken = async (verificationToken) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyEmailByToken(verificationToken);
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(response.error || 'Verification failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Invalid or expired verification link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeVerification = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !code) {
|
||||
setError('Please enter both email and verification code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyEmailByCode(email, code);
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(response.error || 'Verification failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Invalid verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!email) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authAPI.resendVerification(email);
|
||||
alert('Verification email sent! Please check your inbox.');
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Failed to resend verification email');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Email Verified! 🎉
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your email has been successfully verified. You can now access all features of spotlight.cam!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium"
|
||||
>
|
||||
Go to Events
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state (for token verification)
|
||||
if (loading && verificationMode === 'token') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Loader2 className="w-16 h-16 text-primary-600 mb-4 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Verifying your email...
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Please wait while we verify your email address.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Code verification form
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Verify Your Email</h1>
|
||||
<p className="text-gray-600 mt-2 text-center">
|
||||
Enter the 6-digit code we sent to your email address
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md flex items-start gap-2">
|
||||
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCodeVerification} className="space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-center text-2xl font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 text-center">
|
||||
Enter the 6-digit code from your email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Email'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Didn't receive the code?{' '}
|
||||
<button
|
||||
onClick={handleResendVerification}
|
||||
disabled={loading}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 disabled:opacity-50"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/events" className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Skip for now →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailPage;
|
||||
Reference in New Issue
Block a user