feat: Implement password reset and email verification with Mailgun
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

Backend changes:
- Add password reset token database migration (V011)
- Create email service with Mailgun integration (EU/US regions)
- Implement password reset flow with secure token generation
- Add email verification endpoints and logic
- Create beautiful HTML email templates for reset and verification
- Add password reset DTOs with validation
- Update User entity with email verification fields

Frontend changes:
- Create forgot password page with email submission
- Create reset password page with token validation
- Add email verification banner component
- Integrate verification banner into main dashboard
- Add password requirements and validation UI

Features:
- Mailgun API ready for EU and US regions
- Secure token expiration (1h for reset, 24h for verification)
- Rate limiting on resend (2min interval)
- Protection against email enumeration
- IP address and user agent tracking
- Token reuse prevention

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 19:17:48 +00:00
parent 7ee79adcea
commit aaa239121e
16 changed files with 1487 additions and 6 deletions

View File

@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Paper,
Alert,
CircularProgress,
Link as MuiLink,
} from '@mui/material';
import { Email, ArrowBack } from '@mui/icons-material';
import { motion } from 'framer-motion';
import Link from 'next/link';
import apiClient from '@/lib/api/client';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setError('Please enter your email address');
return;
}
setLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/password/forgot', { email });
setSuccess(true);
} catch (err: any) {
console.error('Forgot password error:', err);
setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
px: 2,
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
maxWidth: 480,
width: '100%',
p: 4,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
{!success ? (
<>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2,
}}
>
<Email sx={{ fontSize: 32, color: 'primary.main' }} />
</Box>
<Typography variant="h4" fontWeight="600" gutterBottom>
Forgot Password?
</Typography>
<Typography variant="body2" color="text.secondary">
No worries! Enter your email address and we'll send you a link to reset your password.
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
required
autoFocus
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Send Reset Link'}
</Button>
</form>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<MuiLink
component={Link}
href="/login"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
<ArrowBack sx={{ fontSize: 18 }} />
Back to Login
</MuiLink>
</Box>
</>
) : (
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'success.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<Email sx={{ fontSize: 32, color: 'success.main' }} />
</Box>
<Typography variant="h5" fontWeight="600" gutterBottom>
Check Your Email 📧
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
If an account with that email exists, we've sent you a password reset link. Please check your inbox and follow the instructions.
</Typography>
<Alert severity="info" sx={{ mb: 3, borderRadius: 2, textAlign: 'left' }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Didn't receive the email?
</Typography>
<Typography variant="body2">
• Check your spam or junk folder
<br />
• Make sure you entered the correct email
<br />
• The link expires in 1 hour
</Typography>
</Alert>
<Button
fullWidth
variant="outlined"
onClick={() => {
setSuccess(false);
setEmail('');
}}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
mb: 2,
}}
>
Try Another Email
</Button>
<MuiLink
component={Link}
href="/login"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
<ArrowBack sx={{ fontSize: 18 }} />
Back to Login
</MuiLink>
</Box>
)}
</Paper>
</motion.div>
</Box>
);
}

View File

@@ -0,0 +1,335 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import {
Box,
TextField,
Button,
Typography,
Paper,
Alert,
CircularProgress,
InputAdornment,
IconButton,
Link as MuiLink,
} from '@mui/material';
import { LockReset, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
import { motion } from 'framer-motion';
import Link from 'next/link';
import apiClient from '@/lib/api/client';
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [token, setToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
const tokenParam = searchParams.get('token');
if (tokenParam) {
setToken(tokenParam);
} else {
setError('Invalid or missing reset token');
}
}, [searchParams]);
const validatePassword = (password: string): string | null => {
if (password.length < 8) {
return 'Password must be at least 8 characters long';
}
if (!/[a-z]/.test(password)) {
return 'Password must contain at least one lowercase letter';
}
if (!/[A-Z]/.test(password)) {
return 'Password must contain at least one uppercase letter';
}
if (!/\d/.test(password)) {
return 'Password must contain at least one number';
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!newPassword.trim()) {
setError('Please enter a new password');
return;
}
const passwordError = validatePassword(newPassword);
if (passwordError) {
setError(passwordError);
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/password/reset', {
token,
newPassword,
});
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) {
console.error('Reset password error:', err);
setError(
err.response?.data?.message || 'Failed to reset password. The link may have expired.'
);
} finally {
setLoading(false);
}
};
if (!token && !error) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
);
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
px: 2,
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
maxWidth: 480,
width: '100%',
p: 4,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
{!success ? (
<>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2,
}}
>
<LockReset sx={{ fontSize: 32, color: 'primary.main' }} />
</Box>
<Typography variant="h4" fontWeight="600" gutterBottom>
Reset Password
</Typography>
<Typography variant="body2" color="text.secondary">
Enter your new password below
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="New Password"
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
required
autoFocus
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<TextField
fullWidth
label="Confirm New Password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Password Requirements:
</Typography>
<Typography variant="body2" component="div">
At least 8 characters long
<br />
One uppercase letter (A-Z)
<br />
One lowercase letter (a-z)
<br /> One number (0-9)
</Typography>
</Alert>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading || !token}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Reset Password'}
</Button>
</form>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<MuiLink
component={Link}
href="/login"
sx={{
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
Back to Login
</MuiLink>
</Box>
</>
) : (
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
bgcolor: 'success.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<CheckCircle sx={{ fontSize: 48, color: 'success.main' }} />
</Box>
<Typography variant="h5" fontWeight="600" gutterBottom>
Password Reset Successful! 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Your password has been reset successfully. You can now log in with your new password.
</Typography>
<Alert severity="success" sx={{ mb: 3, borderRadius: 2 }}>
Redirecting to login page...
</Alert>
<Button
fullWidth
variant="contained"
component={Link}
href="/login"
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
Go to Login
</Button>
</Box>
)}
</Paper>
</motion.div>
</Box>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/material';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { EmailVerificationBanner } from '@/components/common/EmailVerificationBanner';
import {
Restaurant,
Hotel,
@@ -84,6 +85,8 @@ export default function HomePage() {
<ProtectedRoute>
<AppShell>
<Box>
<EmailVerificationBanner />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { Alert, Button, Snackbar, Box } from '@mui/material';
import { Email } from '@mui/icons-material';
import { useAuth } from '@/lib/auth/AuthContext';
import apiClient from '@/lib/api/client';
export const EmailVerificationBanner: React.FC = () => {
const { user } = useAuth();
const [dismissed, setDismissed] = useState(false);
const [loading, setLoading] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
// Don't show if user is not logged in, email is verified, or banner was dismissed
if (!user || user.emailVerified || dismissed) {
return null;
}
const handleResendVerification = async () => {
setLoading(true);
try {
await apiClient.post('/api/v1/auth/email/send-verification');
setSnackbar({
open: true,
message: 'Verification email sent! Please check your inbox.',
severity: 'success',
});
} catch (error: any) {
console.error('Failed to resend verification email:', error);
setSnackbar({
open: true,
message: error.response?.data?.message || 'Failed to send verification email. Please try again.',
severity: 'error',
});
} finally {
setLoading(false);
}
};
const handleDismiss = () => {
setDismissed(true);
// Store dismissal in localStorage to persist across sessions
localStorage.setItem('emailVerificationBannerDismissed', 'true');
};
return (
<>
<Alert
severity="warning"
icon={<Email />}
onClose={handleDismiss}
sx={{
borderRadius: 2,
mb: 2,
'& .MuiAlert-message': {
width: '100%',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<strong>Verify your email address</strong>
<br />
Please check your inbox and click the verification link to access all features.
</Box>
<Button
variant="outlined"
size="small"
onClick={handleResendVerification}
disabled={loading}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
borderColor: 'warning.main',
color: 'warning.dark',
'&:hover': {
borderColor: 'warning.dark',
bgcolor: 'warning.light',
},
}}
>
{loading ? 'Sending...' : 'Resend Email'}
</Button>
</Box>
</Alert>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar({ ...snackbar, open: false })}
sx={{ borderRadius: 2 }}
>
{snackbar.message}
</Alert>
</Snackbar>
</>
);
};