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:
Radosław Gierwiało
2025-11-13 15:47:54 +01:00
parent 4d7f814538
commit 7a2f6d07ec
31 changed files with 5586 additions and 87 deletions

View 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;

View 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;