feat: Implement password reset and email verification with Mailgun
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:
245
maternal-web/app/(auth)/forgot-password/page.tsx
Normal file
245
maternal-web/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
maternal-web/app/(auth)/reset-password/page.tsx
Normal file
335
maternal-web/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
117
maternal-web/components/common/EmailVerificationBanner.tsx
Normal file
117
maternal-web/components/common/EmailVerificationBanner.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user