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:
@@ -1,10 +1,18 @@
|
||||
const { prisma } = require('../utils/db');
|
||||
const { hashPassword, comparePassword, generateToken } = require('../utils/auth');
|
||||
const {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
generateVerificationToken,
|
||||
generateVerificationCode,
|
||||
getTokenExpiry
|
||||
} = require('../utils/auth');
|
||||
const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
|
||||
|
||||
// Register new user
|
||||
// Register new user (Phase 1.5 - with WSDC support and email verification)
|
||||
async function register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
const { username, email, password, firstName, lastName, wsdcId } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
@@ -12,6 +20,7 @@ async function register(req, res, next) {
|
||||
OR: [
|
||||
{ email },
|
||||
{ username },
|
||||
...(wsdcId ? [{ wsdcId }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -23,38 +32,80 @@ async function register(req, res, next) {
|
||||
error: 'Email already registered',
|
||||
});
|
||||
}
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username already taken',
|
||||
});
|
||||
if (existingUser.username === username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username already taken',
|
||||
});
|
||||
}
|
||||
if (wsdcId && existingUser.wsdcId === wsdcId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'WSDC ID already registered',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Generate verification token and code
|
||||
const verificationToken = generateVerificationToken();
|
||||
const verificationCode = generateVerificationCode();
|
||||
const verificationTokenExpiry = getTokenExpiry(24); // 24 hours
|
||||
|
||||
// Create display name for avatar
|
||||
const displayName = firstName && lastName
|
||||
? `${firstName} ${lastName}`
|
||||
: username;
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`,
|
||||
firstName,
|
||||
lastName,
|
||||
wsdcId,
|
||||
verificationToken,
|
||||
verificationCode,
|
||||
verificationTokenExpiry,
|
||||
emailVerified: false,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&background=6366f1&color=fff`,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
wsdcId: true,
|
||||
emailVerified: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate token
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
verificationToken,
|
||||
verificationCode
|
||||
);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// Continue even if email fails - user can request resend
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({ userId: user.id });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User registered successfully',
|
||||
message: 'User registered successfully. Please check your email to verify your account.',
|
||||
data: {
|
||||
user,
|
||||
token,
|
||||
@@ -111,7 +162,337 @@ async function login(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify email by token (link in email)
|
||||
async function verifyEmailByToken(req, res, next) {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification token is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by verification token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired verification token',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification token has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Update user - mark as verified and clear tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null,
|
||||
verificationCode: null,
|
||||
verificationTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.firstName || user.username);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email verified successfully!',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify email by code (6-digit PIN)
|
||||
async function verifyEmailByCode(req, res, next) {
|
||||
try {
|
||||
const { code, email } = req.body;
|
||||
|
||||
if (!code || !email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and verification code are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email and code
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
verificationCode: code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid verification code',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification code has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Update user - mark as verified and clear tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null,
|
||||
verificationCode: null,
|
||||
verificationTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.firstName || user.username);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email verified successfully!',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Resend verification email
|
||||
async function resendVerification(req, res, next) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new verification token and code
|
||||
const verificationToken = generateVerificationToken();
|
||||
const verificationCode = generateVerificationCode();
|
||||
const verificationTokenExpiry = getTokenExpiry(24); // 24 hours
|
||||
|
||||
// Update user with new tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
verificationToken,
|
||||
verificationCode,
|
||||
verificationTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
await sendVerificationEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
verificationToken,
|
||||
verificationCode
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Verification email sent successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Request password reset (send email with reset link)
|
||||
async function requestPasswordReset(req, res, next) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = generateVerificationToken();
|
||||
const resetTokenExpiry = getTokenExpiry(1); // 1 hour expiry
|
||||
|
||||
// Save reset token to database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken,
|
||||
resetTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
resetToken
|
||||
);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send password reset email:', emailError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send password reset email',
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset password using token
|
||||
async function resetPassword(req, res, next) {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token and new password are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Password must be at least 8 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by reset token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { resetToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired reset token',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.resetTokenExpiry && new Date() > user.resetTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Reset token has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
// Update user password and clear reset token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Password reset successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
verifyEmailByToken,
|
||||
verifyEmailByCode,
|
||||
resendVerification,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
};
|
||||
|
||||
76
backend/src/controllers/wsdc.js
Normal file
76
backend/src/controllers/wsdc.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* WSDC API Controller
|
||||
* Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup
|
||||
*/
|
||||
|
||||
const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find';
|
||||
|
||||
/**
|
||||
* Lookup dancer by WSDC ID
|
||||
* GET /api/wsdc/lookup?id=26997
|
||||
*/
|
||||
exports.lookupDancer = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.query;
|
||||
|
||||
// Validate WSDC ID
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'WSDC ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate WSDC ID format (numeric, max 10 digits)
|
||||
if (!/^\d{1,10}$/.test(id)) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid WSDC ID format. Must be numeric.'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from WSDC API
|
||||
const url = `${WSDC_API_BASE}?q=${id}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`WSDC API error: ${response.status} ${response.statusText}`);
|
||||
return res.status(502).json({
|
||||
error: 'Bad Gateway',
|
||||
message: 'Failed to fetch data from WSDC API'
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if dancer was found
|
||||
if (!data || !data.dancer_wsdcid) {
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'Dancer with this WSDC ID not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Extract relevant fields
|
||||
const dancerData = {
|
||||
wsdcId: data.dancer_wsdcid,
|
||||
firstName: data.dancer_first || '',
|
||||
lastName: data.dancer_last || '',
|
||||
// Optional: include competitive level info if needed
|
||||
recentYear: data.recent_year,
|
||||
dominateRole: data.dominate_data?.short_dominate_role || null
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
dancer: dancerData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in lookupDancer:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred while looking up dancer'
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user