password reset
This commit is contained in:
@@ -7,6 +7,7 @@ import AuthModal from './components/AuthModal';
|
||||
import Home from './pages/Home';
|
||||
import GoogleCallback from './pages/GoogleCallback';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import ItemList from './pages/ItemList';
|
||||
import ItemDetail from './pages/ItemDetail';
|
||||
import EditItem from './pages/EditItem';
|
||||
@@ -39,6 +40,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/items" element={<ItemList />} />
|
||||
<Route path="/items/:id" element={<ItemDetail />} />
|
||||
<Route path="/users/:id" element={<PublicProfile />} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
||||
import PasswordInput from "./PasswordInput";
|
||||
import ForgotPasswordModal from "./ForgotPasswordModal";
|
||||
|
||||
interface AuthModalProps {
|
||||
show: boolean;
|
||||
@@ -21,6 +22,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
|
||||
const { login, register } = useAuth();
|
||||
|
||||
@@ -83,17 +85,18 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
};
|
||||
|
||||
|
||||
if (!show) return null;
|
||||
if (!show && !showForgotPassword) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="modal show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
{!showForgotPassword && (
|
||||
<div
|
||||
className="modal show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header border-0 pb-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -168,6 +171,21 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "login" && (
|
||||
<div className="text-end mb-3" style={{ marginTop: '-0.5rem' }}>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary text-decoration-none small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowForgotPassword(true);
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-3 mb-1"
|
||||
@@ -230,7 +248,15 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
<ForgotPasswordModal
|
||||
show={showForgotPassword}
|
||||
onHide={() => setShowForgotPassword(false)}
|
||||
onBackToLogin={() => setShowForgotPassword(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface BetaPasswordProtectionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BetaPasswordProtection: React.FC<BetaPasswordProtectionProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user already has valid beta access
|
||||
const betaToken = localStorage.getItem("betaAccess");
|
||||
if (betaToken) {
|
||||
// Verify the stored token is still valid
|
||||
verifyBetaAccess(betaToken);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const verifyBetaAccess = async (token: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.REACT_APP_API_URL}/beta/verify`,
|
||||
{
|
||||
headers: {
|
||||
"X-Beta-Password": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
localStorage.removeItem("betaAccess");
|
||||
}
|
||||
} catch (error) {
|
||||
localStorage.removeItem("betaAccess");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!password) {
|
||||
setError("Please enter a password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.REACT_APP_API_URL}/beta/verify`,
|
||||
{
|
||||
headers: {
|
||||
"X-Beta-Password": password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem("betaAccess", password);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setError("Invalid beta password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("Failed to verify beta password");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div
|
||||
className="card shadow"
|
||||
style={{ maxWidth: "400px", width: "100%" }}
|
||||
>
|
||||
<div className="card-body p-5">
|
||||
<h2 className="text-center mb-4">Beta Access Required</h2>
|
||||
<p className="text-muted text-center mb-4">
|
||||
This site is currently in beta testing. Please enter the beta
|
||||
password to continue.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="betaPassword" className="form-label">
|
||||
Beta Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="betaPassword"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter beta password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
Access Beta
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default BetaPasswordProtection;
|
||||
175
frontend/src/components/ForgotPasswordModal.tsx
Normal file
175
frontend/src/components/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState } from "react";
|
||||
import { authAPI } from "../services/api";
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onBackToLogin?: () => void;
|
||||
}
|
||||
|
||||
const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
onBackToLogin,
|
||||
}) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const resetModal = () => {
|
||||
setEmail("");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await authAPI.forgotPassword(email);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
console.error('Forgot password error:', err);
|
||||
const errorData = err.response?.data;
|
||||
|
||||
// Check for rate limiting
|
||||
if (err.response?.status === 429) {
|
||||
const retryAfter = errorData?.retryAfter;
|
||||
if (retryAfter) {
|
||||
const minutes = Math.ceil(retryAfter / 60);
|
||||
setError(`Too many password reset requests. Please try again in ${minutes} minute${minutes > 1 ? 's' : ''}.`);
|
||||
} else {
|
||||
setError('Too many password reset requests. Please try again later.');
|
||||
}
|
||||
} else if (errorData?.details) {
|
||||
// Validation errors
|
||||
const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
|
||||
setError(validationErrors);
|
||||
} else {
|
||||
setError(errorData?.error || "Failed to send reset email. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="modal show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header border-0 pb-0">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={() => {
|
||||
resetModal();
|
||||
onHide();
|
||||
}}
|
||||
></button>
|
||||
</div>
|
||||
<div className="modal-body px-4 pb-4">
|
||||
{success ? (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<i
|
||||
className="bi bi-envelope-check text-success"
|
||||
style={{ fontSize: "3rem" }}
|
||||
></i>
|
||||
<h4 className="mt-3">Check Your Email</h4>
|
||||
<p className="text-muted">
|
||||
If an account exists with that email address, you will
|
||||
receive password reset instructions shortly.
|
||||
</p>
|
||||
<p className="text-muted small">
|
||||
Please check your spam folder if you don't see the email
|
||||
within a few minutes.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => {
|
||||
resetModal();
|
||||
onHide();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-center mb-2">Forgot Password?</h4>
|
||||
<p className="text-center text-muted mb-4">
|
||||
Enter your email address and we'll send you instructions to
|
||||
reset your password.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-3"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? "Sending..." : "Send Reset Instructions"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<small className="text-muted">
|
||||
Remember your password?{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary text-decoration-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetModal();
|
||||
if (onBackToLogin) {
|
||||
onBackToLogin();
|
||||
} else {
|
||||
onHide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Back to Login
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordModal;
|
||||
205
frontend/src/pages/ResetPassword.tsx
Normal file
205
frontend/src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authAPI } from '../services/api';
|
||||
import PasswordInput from '../components/PasswordInput';
|
||||
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { openAuthModal } = useAuth();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [validating, setValidating] = useState(true);
|
||||
const [tokenValid, setTokenValid] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const hasValidated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const validateToken = async () => {
|
||||
// Prevent double execution in React StrictMode
|
||||
if (hasValidated.current) {
|
||||
return;
|
||||
}
|
||||
hasValidated.current = true;
|
||||
|
||||
try {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
setError('No reset token provided.');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the token is valid
|
||||
await authAPI.verifyResetToken(token);
|
||||
|
||||
setTokenValid(true);
|
||||
setValidating(false);
|
||||
} catch (err: any) {
|
||||
console.error('Token validation error:', err);
|
||||
const errorData = err.response?.data;
|
||||
|
||||
if (errorData?.code === 'TOKEN_EXPIRED') {
|
||||
setError('Your password reset link has expired. Please request a new one.');
|
||||
} else if (errorData?.code === 'TOKEN_INVALID') {
|
||||
setError('Invalid password reset link. The link may have already been used or is incorrect.');
|
||||
} else {
|
||||
setError(errorData?.error || 'Failed to validate reset link. Please try again.');
|
||||
}
|
||||
|
||||
setValidating(false);
|
||||
setTokenValid(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateToken();
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validate passwords match
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const token = searchParams.get('token');
|
||||
if (!token) {
|
||||
setError('No reset token provided.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await authAPI.resetPassword(token, newPassword);
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
console.error('Password reset error:', err);
|
||||
const errorData = err.response?.data;
|
||||
|
||||
if (errorData?.code === 'TOKEN_EXPIRED') {
|
||||
setError('Your reset link has expired. Please request a new one.');
|
||||
} else if (errorData?.code === 'TOKEN_INVALID') {
|
||||
setError('Invalid reset link. Please request a new one.');
|
||||
} else if (errorData?.details) {
|
||||
// Validation errors
|
||||
const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
|
||||
setError(validationErrors);
|
||||
} else {
|
||||
setError(errorData?.error || 'Failed to reset password. Please try again.');
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center mt-5">
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-body py-5">
|
||||
{validating ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h5>Verifying Reset Link...</h5>
|
||||
<p className="text-muted">Please wait while we verify your password reset link.</p>
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="text-center">
|
||||
<i className="bi bi-check-circle text-success" style={{ fontSize: '3rem' }}></i>
|
||||
<h5 className="mt-3">Password Reset Successfully!</h5>
|
||||
<p className="text-muted">
|
||||
Your password has been reset. You can now log in with your new password.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => {
|
||||
navigate('/', { replace: true });
|
||||
openAuthModal('login');
|
||||
}}
|
||||
>
|
||||
Log In Now
|
||||
</button>
|
||||
</div>
|
||||
) : !tokenValid ? (
|
||||
<div className="text-center">
|
||||
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
|
||||
<h5 className="mt-3">Invalid Reset Link</h5>
|
||||
<p className="text-danger">{error}</p>
|
||||
<div className="mt-3">
|
||||
<Link to="/" className="btn btn-outline-secondary">
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<h4>Reset Your Password</h4>
|
||||
<p className="text-muted">Enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PasswordInput
|
||||
id="newPassword"
|
||||
label="New Password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div style={{ marginTop: '-0.75rem', marginBottom: '1rem' }}>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
id="confirmPassword"
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-3 mt-3"
|
||||
disabled={submitting || !newPassword || !confirmPassword}
|
||||
>
|
||||
{submitting ? 'Resetting Password...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<Link to="/" className="text-decoration-none">
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -168,6 +168,9 @@ export const authAPI = {
|
||||
getStatus: () => api.get("/auth/status"),
|
||||
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
||||
resendVerification: () => api.post("/auth/resend-verification"),
|
||||
forgotPassword: (email: string) => api.post("/auth/forgot-password", { email }),
|
||||
verifyResetToken: (token: string) => api.post("/auth/verify-reset-token", { token }),
|
||||
resetPassword: (token: string, newPassword: string) => api.post("/auth/reset-password", { token, newPassword }),
|
||||
};
|
||||
|
||||
export const userAPI = {
|
||||
|
||||
Reference in New Issue
Block a user