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:
@@ -2,13 +2,17 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import EventChatPage from './pages/EventChatPage';
|
||||
import MatchChatPage from './pages/MatchChatPage';
|
||||
import RatePartnerPage from './pages/RatePartnerPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import VerificationBanner from './components/common/VerificationBanner';
|
||||
|
||||
// Protected Route Component
|
||||
// Protected Route Component with Verification Banner
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
@@ -24,7 +28,12 @@ const ProtectedRoute = ({ children }) => {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
<VerificationBanner />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Public Route Component (redirect to events if already logged in)
|
||||
@@ -68,6 +77,9 @@ function App() {
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
|
||||
70
frontend/src/components/common/PasswordStrengthIndicator.jsx
Normal file
70
frontend/src/components/common/PasswordStrengthIndicator.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Password Strength Indicator Component
|
||||
* Calculates and displays password strength with visual feedback
|
||||
*/
|
||||
const PasswordStrengthIndicator = ({ password }) => {
|
||||
const strength = useMemo(() => {
|
||||
if (!password) return { score: 0, label: '', color: '' };
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Length check
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
|
||||
// Character variety checks
|
||||
if (/[a-z]/.test(password)) score++; // lowercase
|
||||
if (/[A-Z]/.test(password)) score++; // uppercase
|
||||
if (/[0-9]/.test(password)) score++; // numbers
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++; // special chars
|
||||
|
||||
// Determine label and color
|
||||
if (score <= 2) {
|
||||
return { score, label: 'Weak', color: 'bg-red-500' };
|
||||
} else if (score <= 4) {
|
||||
return { score, label: 'Medium', color: 'bg-yellow-500' };
|
||||
} else {
|
||||
return { score, label: 'Strong', color: 'bg-green-500' };
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
const widthPercentage = (strength.score / 6) * 100;
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-600">Password strength:</span>
|
||||
<span className={`text-xs font-medium ${
|
||||
strength.label === 'Weak' ? 'text-red-600' :
|
||||
strength.label === 'Medium' ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${strength.color} transition-all duration-300 ease-out`}
|
||||
style={{ width: `${widthPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<ul className="mt-2 text-xs text-gray-600 space-y-1">
|
||||
<li className={password.length >= 8 ? 'text-green-600' : ''}>
|
||||
✓ At least 8 characters
|
||||
</li>
|
||||
<li className={/[A-Z]/.test(password) && /[a-z]/.test(password) ? 'text-green-600' : ''}>
|
||||
✓ Upper and lowercase letters
|
||||
</li>
|
||||
<li className={/[0-9]/.test(password) ? 'text-green-600' : ''}>
|
||||
✓ At least one number
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordStrengthIndicator;
|
||||
90
frontend/src/components/common/VerificationBanner.jsx
Normal file
90
frontend/src/components/common/VerificationBanner.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { authAPI } from '../../services/api';
|
||||
import { AlertCircle, X, Mail, Loader2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Verification Banner Component
|
||||
* Displays a banner for unverified users with option to resend verification email
|
||||
*/
|
||||
const VerificationBanner = () => {
|
||||
const { user } = useAuth();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
// Don't show if user is verified or banner is dismissed
|
||||
if (!user || user.emailVerified || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
await authAPI.resendVerification(user.email);
|
||||
setMessage('Verification email sent! Please check your inbox.');
|
||||
} catch (error) {
|
||||
setMessage('Failed to send email. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border-b border-yellow-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-3 flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-800">
|
||||
Please verify your email address to access all features
|
||||
</p>
|
||||
{message && (
|
||||
<p className="text-xs text-yellow-700 mt-1">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/verify-email"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-yellow-800 bg-yellow-100 hover:bg-yellow-200 rounded-md transition"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Verify Now
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-yellow-800 hover:text-yellow-900 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Resend Email'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="p-1 text-yellow-600 hover:text-yellow-800 transition"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationBanner;
|
||||
@@ -48,9 +48,9 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username, email, password) => {
|
||||
const register = async (username, email, password, firstName = null, lastName = null, wsdcId = null) => {
|
||||
try {
|
||||
const { user: userData } = await authAPI.register(username, email, password);
|
||||
const { user: userData } = await authAPI.register(username, email, password, firstName, lastName, wsdcId);
|
||||
setUser(userData);
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
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;
|
||||
@@ -49,10 +49,10 @@ async function fetchAPI(endpoint, options = {}) {
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
async register(username, email, password) {
|
||||
async register(username, email, password, firstName = null, lastName = null, wsdcId = null) {
|
||||
const data = await fetchAPI('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
body: JSON.stringify({ username, email, password, firstName, lastName, wsdcId }),
|
||||
});
|
||||
|
||||
// Save token
|
||||
@@ -82,12 +82,57 @@ export const authAPI = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
async verifyEmailByToken(token) {
|
||||
const data = await fetchAPI(`/auth/verify-email?token=${token}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async verifyEmailByCode(email, code) {
|
||||
const data = await fetchAPI('/auth/verify-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async resendVerification(email) {
|
||||
const data = await fetchAPI('/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async requestPasswordReset(email) {
|
||||
const data = await fetchAPI('/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async resetPassword(token, newPassword) {
|
||||
const data = await fetchAPI('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
},
|
||||
};
|
||||
|
||||
// WSDC API (Phase 1.5)
|
||||
export const wsdcAPI = {
|
||||
async lookupDancer(wsdcId) {
|
||||
const data = await fetchAPI(`/wsdc/lookup?id=${wsdcId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// Events API
|
||||
export const eventsAPI = {
|
||||
async getAll() {
|
||||
|
||||
Reference in New Issue
Block a user