password reset

This commit is contained in:
jackiettran
2025-10-10 22:54:45 -04:00
parent 462dbf6b7a
commit b9e6cfc54d
15 changed files with 1976 additions and 178 deletions

View File

@@ -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 />} />

View File

@@ -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)}
/>
</>
);
};

View File

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

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

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

View File

@@ -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 = {