This commit is contained in:
jackiettran
2026-01-16 18:04:39 -05:00
parent 63385e049c
commit cf97dffbfb
31 changed files with 4405 additions and 56 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { SocketProvider } from './contexts/SocketContext';
@@ -7,6 +7,7 @@ import Footer from './components/Footer';
import AuthModal from './components/AuthModal';
import AlphaGate from './components/AlphaGate';
import FeedbackButton from './components/FeedbackButton';
import { TwoFactorVerifyModal } from './components/TwoFactor';
import Home from './pages/Home';
import GoogleCallback from './pages/GoogleCallback';
import VerifyEmail from './pages/VerifyEmail';
@@ -40,6 +41,39 @@ const AppContent: React.FC = () => {
const [hasAlphaAccess, setHasAlphaAccess] = useState<boolean | null>(null);
const [checkingAccess, setCheckingAccess] = useState(true);
// Step-up authentication state
const [showStepUpModal, setShowStepUpModal] = useState(false);
const [stepUpAction, setStepUpAction] = useState<string | undefined>();
const [stepUpMethods, setStepUpMethods] = useState<("totp" | "email" | "recovery")[]>([]);
// Listen for step-up authentication required events
useEffect(() => {
const handleStepUpRequired = (event: CustomEvent) => {
const { action, methods } = event.detail;
setStepUpAction(action);
setStepUpMethods(methods || ["totp", "email", "recovery"]);
setShowStepUpModal(true);
};
window.addEventListener("stepUpRequired", handleStepUpRequired as EventListener);
return () => {
window.removeEventListener("stepUpRequired", handleStepUpRequired as EventListener);
};
}, []);
const handleStepUpSuccess = useCallback(() => {
setShowStepUpModal(false);
setStepUpAction(undefined);
// Dispatch event so pending actions can auto-retry
window.dispatchEvent(new CustomEvent("stepUpSuccess"));
}, []);
const handleStepUpClose = useCallback(() => {
setShowStepUpModal(false);
setStepUpAction(undefined);
}, []);
useEffect(() => {
const checkAlphaAccess = async () => {
// Bypass alpha access check if feature is disabled
@@ -209,6 +243,15 @@ const AppContent: React.FC = () => {
{/* Show feedback button for authenticated users */}
{user && <FeedbackButton />}
{/* Global Step-Up Authentication Modal */}
<TwoFactorVerifyModal
show={showStepUpModal}
onHide={handleStepUpClose}
onSuccess={handleStepUpSuccess}
action={stepUpAction}
methods={stepUpMethods}
/>
</>
);
};

View File

@@ -4,6 +4,7 @@ import PasswordStrengthMeter from "./PasswordStrengthMeter";
import PasswordInput from "./PasswordInput";
import ForgotPasswordModal from "./ForgotPasswordModal";
import VerificationCodeModal from "./VerificationCodeModal";
import { validatePassword } from "../utils/passwordValidation";
interface AuthModalProps {
show: boolean;
@@ -143,6 +144,15 @@ const AuthModal: React.FC<AuthModalProps> = ({
return;
}
// Password validation for signup
if (mode === "signup") {
const passwordError = validatePassword(password);
if (passwordError) {
setError(passwordError);
return;
}
}
setLoading(true);
try {

View File

@@ -1,47 +1,19 @@
import React from "react";
import { PASSWORD_REQUIREMENTS, COMMON_PASSWORDS } from "../utils/passwordValidation";
interface PasswordStrengthMeterProps {
password: string;
showRequirements?: boolean;
}
interface PasswordRequirement {
regex: RegExp;
text: string;
met: boolean;
}
const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
password,
showRequirements = true,
}) => {
const requirements: PasswordRequirement[] = [
{
regex: /.{8,}/,
text: "At least 8 characters",
met: /.{8,}/.test(password),
},
{
regex: /[a-z]/,
text: "One lowercase letter",
met: /[a-z]/.test(password),
},
{
regex: /[A-Z]/,
text: "One uppercase letter",
met: /[A-Z]/.test(password),
},
{
regex: /\d/,
text: "One number",
met: /\d/.test(password),
},
{
regex: /[-@$!%*?&#^]/,
text: "One special character (-@$!%*?&#^)",
met: /[-@$!%*?&#^]/.test(password),
},
];
const requirements = PASSWORD_REQUIREMENTS.map((req) => ({
text: req.text,
met: req.regex.test(password),
}));
const getPasswordStrength = (): {
score: number;
@@ -51,16 +23,8 @@ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
if (!password) return { score: 0, label: "", color: "" };
const metRequirements = requirements.filter((req) => req.met).length;
const hasCommonPassword = [
"password",
"123456",
"123456789",
"qwerty",
"abc123",
"password123",
].includes(password.toLowerCase());
if (hasCommonPassword) {
if (COMMON_PASSWORDS.includes(password.toLowerCase())) {
return { score: 0, label: "Too Common", color: "danger" };
}

View File

@@ -0,0 +1,166 @@
import React, { useState } from "react";
interface RecoveryCodesDisplayProps {
codes: string[];
onAcknowledge?: () => void;
showAcknowledgeButton?: boolean;
}
const RecoveryCodesDisplay: React.FC<RecoveryCodesDisplayProps> = ({
codes,
onAcknowledge,
showAcknowledgeButton = true,
}) => {
const [acknowledged, setAcknowledged] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopyAll = async () => {
const codesText = codes.join("\n");
try {
await navigator.clipboard.writeText(codesText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy codes:", err);
}
};
const handleDownload = () => {
if (
!confirm(
"Warning: This will create an unencrypted file on your device. " +
"Consider using a password manager instead. Continue?"
)
) {
return;
}
const codesText = `Village Share Recovery Codes\n${"=".repeat(30)}\n\nSave these codes in a secure location.\nEach code can only be used once.\n\n${codes.join("\n")}\n\nGenerated: ${new Date().toLocaleString()}`;
const blob = new Blob([codesText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "village-share-recovery-codes.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handlePrint = () => {
if (
!confirm(
"Warning: Printed documents can be easily compromised. " +
"Consider using a password manager instead. Continue?"
)
) {
return;
}
const printWindow = window.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>Recovery Codes - Village Share</title>
<style>
body { font-family: monospace; padding: 20px; }
h1 { font-size: 18px; }
.codes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 20px; }
.code { background: #f0f0f0; padding: 10px; border-radius: 4px; text-align: center; }
.warning { color: #856404; background: #fff3cd; padding: 15px; border-radius: 4px; margin-top: 20px; }
</style>
</head>
<body>
<h1>Village Share Recovery Codes</h1>
<p>Save these codes in a secure location. Each code can only be used once.</p>
<div class="codes">
${codes.map((code) => `<div class="code">${code}</div>`).join("")}
</div>
<div class="warning">
<strong>Warning:</strong> These codes will not be shown again. Store them securely.
</div>
<p style="margin-top: 20px; font-size: 12px; color: #666;">Generated: ${new Date().toLocaleString()}</p>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
};
return (
<div>
<div className="alert alert-warning mb-3">
<i className="bi bi-exclamation-triangle me-2"></i>
<strong>Important:</strong> Save these recovery codes in a secure
location. You will not be able to see them again.
</div>
<div className="row row-cols-2 g-2 mb-3">
{codes.map((code, index) => (
<div key={index} className="col">
<div
className="bg-light border rounded p-2 text-center font-monospace"
style={{ fontSize: "0.9rem", letterSpacing: "1px" }}
>
{code}
</div>
</div>
))}
</div>
<div className="d-flex gap-2 mb-3">
<button
className="btn btn-outline-secondary btn-sm flex-fill"
onClick={handleCopyAll}
>
<i className={`bi ${copied ? "bi-check" : "bi-clipboard"} me-1`}></i>
{copied ? "Copied!" : "Copy All"}
</button>
<button
className="btn btn-outline-secondary btn-sm flex-fill"
onClick={handleDownload}
>
<i className="bi bi-download me-1"></i>
Download
</button>
<button
className="btn btn-outline-secondary btn-sm flex-fill"
onClick={handlePrint}
>
<i className="bi bi-printer me-1"></i>
Print
</button>
</div>
{showAcknowledgeButton && (
<>
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
id="acknowledgeRecoveryCodes"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
<label
className="form-check-label"
htmlFor="acknowledgeRecoveryCodes"
>
I have saved my recovery codes in a secure location
</label>
</div>
<button
className="btn btn-success w-100"
disabled={!acknowledged}
onClick={onAcknowledge}
>
Continue
</button>
</>
)}
</div>
);
};
export default RecoveryCodesDisplay;

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect, useCallback } from "react";
import { twoFactorAPI } from "../../services/api";
import { TwoFactorStatus } from "../../types";
import TwoFactorSetupModal from "./TwoFactorSetupModal";
import TwoFactorVerifyModal from "./TwoFactorVerifyModal";
import RecoveryCodesDisplay from "./RecoveryCodesDisplay";
const TwoFactorManagement: React.FC = () => {
const [status, setStatus] = useState<TwoFactorStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal states
const [showSetupModal, setShowSetupModal] = useState(false);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [showRecoveryCodes, setShowRecoveryCodes] = useState(false);
const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[]>([]);
const [pendingAction, setPendingAction] = useState<
"disable" | "regenerate" | null
>(null);
const fetchStatus = useCallback(async () => {
try {
const response = await twoFactorAPI.getStatus();
setStatus(response.data);
} catch (err: any) {
setError(err.response?.data?.error || "Failed to load 2FA status");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const handleSetupSuccess = () => {
fetchStatus();
};
const handleDisable = () => {
setPendingAction("disable");
setShowVerifyModal(true);
};
const handleRegenerateRecoveryCodes = () => {
setPendingAction("regenerate");
setShowVerifyModal(true);
};
const handleVerifySuccess = async () => {
setShowVerifyModal(false);
if (pendingAction === "disable") {
try {
await twoFactorAPI.disable();
fetchStatus();
} catch (err: any) {
setError(err.response?.data?.error || "Failed to disable 2FA");
}
} else if (pendingAction === "regenerate") {
try {
const response = await twoFactorAPI.regenerateRecoveryCodes();
setNewRecoveryCodes(response.data.recoveryCodes);
setShowRecoveryCodes(true);
fetchStatus();
} catch (err: any) {
setError(
err.response?.data?.error || "Failed to regenerate recovery codes"
);
}
}
setPendingAction(null);
};
if (loading) {
return (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
return (
<div>
<h5 className="mb-3">
<i className="bi bi-shield-lock me-2"></i>
Multi-Factor Authentication
</h5>
{error && (
<div className="alert alert-danger mb-3">
<i className="bi bi-exclamation-circle me-2"></i>
{error}
<button
type="button"
className="btn-close float-end"
onClick={() => setError(null)}
></button>
</div>
)}
{status?.enabled ? (
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<div className="flex-shrink-0">
<span className="badge bg-success fs-6">
<i className="bi bi-check-circle me-1"></i>
Enabled
</span>
</div>
<div className="flex-grow-1 ms-3">
<strong>
{status.method === "totp"
? "Authenticator App"
: "Email Verification"}
</strong>
</div>
</div>
<hr />
<div className="mb-3">
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>Recovery Codes</strong>
<div className="text-muted small">
{status.hasRecoveryCodes
? status.lowRecoveryCodes
? "Running low"
: "Available"
: "None remaining"}
</div>
</div>
<button
className="btn btn-outline-secondary btn-sm"
onClick={handleRegenerateRecoveryCodes}
>
<i className="bi bi-arrow-clockwise me-1"></i>
Regenerate
</button>
</div>
{status.lowRecoveryCodes && (
<div className="alert alert-warning mt-2 mb-0">
<i className="bi bi-exclamation-triangle me-2"></i>
You're running low on recovery codes. Consider regenerating
new ones.
</div>
)}
</div>
<hr />
<button className="btn btn-outline-danger" onClick={handleDisable}>
<i className="bi bi-shield-x me-1"></i>
Disable MFA
</button>
</div>
</div>
) : (
<div className="card">
<div className="card-body">
<p className="text-muted mb-3">
Multi-Factor Authentication (MFA) adds an extra layer of security
to your account. When enabled, you'll need to do an additional
verficiation step when performing sensitive actions like changing
your password.
</p>
<button
className="btn btn-primary"
onClick={() => setShowSetupModal(true)}
>
Set Up MFA
</button>
</div>
</div>
)}
{/* Setup Modal */}
<TwoFactorSetupModal
show={showSetupModal}
onHide={() => setShowSetupModal(false)}
onSuccess={handleSetupSuccess}
/>
{/* Verify Modal for disable/regenerate */}
<TwoFactorVerifyModal
show={showVerifyModal}
onHide={() => {
setShowVerifyModal(false);
setPendingAction(null);
}}
onSuccess={handleVerifySuccess}
action={
pendingAction === "disable" ? "2fa_disable" : "recovery_regenerate"
}
methods={
status?.method === "totp"
? ["totp", "email", "recovery"]
: ["email", "recovery"]
}
/>
{/* Recovery Codes Display Modal */}
{showRecoveryCodes && (
<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">
<h5 className="modal-title">New Recovery Codes</h5>
</div>
<div className="modal-body">
<RecoveryCodesDisplay
codes={newRecoveryCodes}
onAcknowledge={() => {
setShowRecoveryCodes(false);
setNewRecoveryCodes([]);
}}
showAcknowledgeButton={true}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default TwoFactorManagement;

View File

@@ -0,0 +1,384 @@
import React, { useState } from "react";
import { twoFactorAPI } from "../../services/api";
import RecoveryCodesDisplay from "./RecoveryCodesDisplay";
interface TwoFactorSetupModalProps {
show: boolean;
onHide: () => void;
onSuccess: () => void;
}
type SetupStep =
| "choose"
| "totp-qr"
| "totp-verify"
| "email-verify"
| "recovery-codes";
const TwoFactorSetupModal: React.FC<TwoFactorSetupModalProps> = ({
show,
onHide,
onSuccess,
}) => {
const [step, setStep] = useState<SetupStep>("choose");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// TOTP setup state
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>("");
const [verificationCode, setVerificationCode] = useState("");
// Recovery codes
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const resetState = () => {
setStep("choose");
setLoading(false);
setError(null);
setQrCodeDataUrl("");
setVerificationCode("");
setRecoveryCodes([]);
};
const handleClose = () => {
resetState();
onHide();
};
const handleInitTotp = async () => {
setLoading(true);
setError(null);
try {
const response = await twoFactorAPI.initTotpSetup();
setQrCodeDataUrl(response.data.qrCodeDataUrl);
setStep("totp-qr");
} catch (err: any) {
setError(err.response?.data?.error || "Failed to initialize TOTP setup");
} finally {
setLoading(false);
}
};
const handleVerifyTotp = async () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setLoading(true);
setError(null);
try {
const response = await twoFactorAPI.verifyTotpSetup(verificationCode);
setRecoveryCodes(response.data.recoveryCodes);
setStep("recovery-codes");
} catch (err: any) {
setError(err.response?.data?.error || "Invalid verification code");
} finally {
setLoading(false);
}
};
const handleInitEmailSetup = async () => {
setLoading(true);
setError(null);
try {
await twoFactorAPI.initEmailSetup();
setStep("email-verify");
} catch (err: any) {
setError(
err.response?.data?.error || "Failed to send verification email"
);
} finally {
setLoading(false);
}
};
const handleVerifyEmail = async () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setLoading(true);
setError(null);
try {
const response = await twoFactorAPI.verifyEmailSetup(verificationCode);
setRecoveryCodes(response.data.recoveryCodes);
setStep("recovery-codes");
} catch (err: any) {
setError(err.response?.data?.error || "Invalid verification code");
} finally {
setLoading(false);
}
};
const handleComplete = () => {
handleClose();
onSuccess();
};
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setVerificationCode(value);
setError(null);
};
if (!show) return null;
const renderChooseMethod = () => (
<>
<div className="modal-header">
<h5 className="modal-title">Set Up MFA</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
></button>
</div>
<div className="modal-body">
<div className="d-grid gap-3">
<button
className="btn btn-outline-primary p-3 text-start"
onClick={handleInitTotp}
disabled={loading}
>
<div className="d-flex align-items-center">
<i className="bi bi-phone fs-3 me-3"></i>
<div>
<strong>Authenticator App</strong>
<span className="badge bg-success ms-2">Recommended</span>
<br />
<small className="text-muted">
Use Google Authenticator, Authy, or similar app
</small>
</div>
</div>
</button>
<button
className="btn btn-outline-secondary p-3 text-start"
onClick={handleInitEmailSetup}
disabled={loading}
>
<div className="d-flex align-items-center">
<i className="bi bi-envelope fs-3 me-3"></i>
<div>
<strong>Email Verification</strong>
<br />
<small className="text-muted">
Receive codes via email when verification is needed
</small>
</div>
</div>
</button>
</div>
{error && (
<div className="alert alert-danger mt-3 mb-0">
<i className="bi bi-exclamation-circle me-2"></i>
{error}
</div>
)}
</div>
</>
);
const renderTotpQR = () => (
<>
<div className="modal-header">
<h5 className="modal-title">Scan QR Code</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
></button>
</div>
<div className="modal-body">
<p className="text-muted mb-3">
Scan this QR code with your authenticator app to add Village Share.
</p>
<div className="text-center mb-3">
{qrCodeDataUrl && (
<img
src={qrCodeDataUrl}
alt="QR Code for authenticator app"
className="img-fluid border rounded"
style={{ maxWidth: "200px" }}
/>
)}
</div>
<button
className="btn btn-primary w-100"
onClick={() => setStep("totp-verify")}
>
Continue
</button>
</div>
</>
);
const renderTotpVerify = () => (
<>
<div className="modal-header">
<h5 className="modal-title">Enter Verification Code</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
></button>
</div>
<div className="modal-body">
<p className="text-muted mb-3">
Enter the 6-digit code from your authenticator app to verify the
setup.
</p>
<div className="mb-3">
<input
type="text"
className={`form-control form-control-lg text-center font-monospace ${
error ? "is-invalid" : ""
}`}
placeholder="000000"
value={verificationCode}
onChange={handleCodeChange}
maxLength={6}
autoFocus
style={{ letterSpacing: "0.5em" }}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-secondary flex-fill"
onClick={() => {
setStep("totp-qr");
setVerificationCode("");
setError(null);
}}
>
Back
</button>
<button
className="btn btn-primary flex-fill"
onClick={handleVerifyTotp}
disabled={loading || verificationCode.length !== 6}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Verifying...
</>
) : (
"Verify"
)}
</button>
</div>
</div>
</>
);
const renderEmailVerify = () => (
<>
<div className="modal-header">
<h5 className="modal-title">Check Your Email</h5>
<button
type="button"
className="btn-close"
onClick={handleClose}
></button>
</div>
<div className="modal-body">
<div className="text-center mb-3">
<i className="bi bi-envelope-check fs-1 text-primary"></i>
</div>
<p className="text-muted mb-3 text-center">
We've sent a 6-digit verification code to your email address. Enter it
below to complete setup.
</p>
<div className="mb-3">
<input
type="text"
className={`form-control form-control-lg text-center font-monospace ${
error ? "is-invalid" : ""
}`}
placeholder="000000"
value={verificationCode}
onChange={handleCodeChange}
maxLength={6}
autoFocus
style={{ letterSpacing: "0.5em" }}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-secondary flex-fill"
onClick={() => {
setStep("choose");
setVerificationCode("");
setError(null);
}}
>
Back
</button>
<button
className="btn btn-primary flex-fill"
onClick={handleVerifyEmail}
disabled={loading || verificationCode.length !== 6}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Verifying...
</>
) : (
"Verify"
)}
</button>
</div>
</div>
</>
);
const renderRecoveryCodes = () => (
<>
<div className="modal-header">
<h5 className="modal-title">Save Your Recovery Codes</h5>
</div>
<div className="modal-body">
<RecoveryCodesDisplay
codes={recoveryCodes}
onAcknowledge={handleComplete}
showAcknowledgeButton={true}
/>
</div>
</>
);
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">
{step === "choose" && renderChooseMethod()}
{step === "totp-qr" && renderTotpQR()}
{step === "totp-verify" && renderTotpVerify()}
{step === "email-verify" && renderEmailVerify()}
{step === "recovery-codes" && renderRecoveryCodes()}
</div>
</div>
</div>
);
};
export default TwoFactorSetupModal;

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect } from "react";
import { twoFactorAPI } from "../../services/api";
interface TwoFactorVerifyModalProps {
show: boolean;
onHide: () => void;
onSuccess: () => void;
action?: string;
methods?: ("totp" | "email" | "recovery")[];
}
const TwoFactorVerifyModal: React.FC<TwoFactorVerifyModalProps> = ({
show,
onHide,
onSuccess,
action,
methods = ["totp", "email", "recovery"],
}) => {
const [activeTab, setActiveTab] = useState<"totp" | "email" | "recovery">(
methods[0] || "totp"
);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState(false);
// Reset state when modal opens/closes
useEffect(() => {
if (show) {
setCode("");
setError(null);
setEmailSent(false);
setActiveTab(methods[0] || "totp");
}
}, [show, methods]);
const getActionDescription = (actionType?: string) => {
switch (actionType) {
case "password_change":
return "change your password";
case "email_change":
return "change your email address";
case "payout":
return "request a payout";
case "account_delete":
return "delete your account";
case "2fa_disable":
return "disable multi-factor authentication";
case "recovery_regenerate":
return "regenerate recovery codes";
default:
return "complete this action";
}
};
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (activeTab === "recovery") {
// Format recovery code: XXXX-XXXX
value = value.toUpperCase().replace(/[^A-Z0-9-]/g, "");
if (value.length > 4 && !value.includes("-")) {
value = value.slice(0, 4) + "-" + value.slice(4);
}
value = value.slice(0, 9);
} else {
// TOTP and email: 6 digits only
value = value.replace(/\D/g, "").slice(0, 6);
}
setCode(value);
setError(null);
};
const handleSendEmailOtp = async () => {
setLoading(true);
setError(null);
try {
await twoFactorAPI.requestEmailOtp();
setEmailSent(true);
} catch (err: any) {
setError(err.response?.data?.error || "Failed to send verification code");
} finally {
setLoading(false);
}
};
const handleVerify = async () => {
setLoading(true);
setError(null);
try {
let response;
switch (activeTab) {
case "totp":
response = await twoFactorAPI.verifyTotp(code);
break;
case "email":
response = await twoFactorAPI.verifyEmailOtp(code);
break;
case "recovery":
response = await twoFactorAPI.verifyRecoveryCode(code);
break;
}
onSuccess();
onHide();
} catch (err: any) {
setError(err.response?.data?.error || "Verification failed");
} finally {
setLoading(false);
}
};
const isCodeValid = () => {
if (activeTab === "recovery") {
return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code);
}
return code.length === 6;
};
if (!show) return null;
const renderTotpTab = () => (
<div className="p-3">
<p className="text-muted mb-3">
Enter the 6-digit code from your authenticator app.
</p>
<input
type="text"
className={`form-control form-control-lg text-center font-monospace mb-3 ${
error && activeTab === "totp" ? "is-invalid" : ""
}`}
placeholder="000000"
value={code}
onChange={handleCodeChange}
maxLength={6}
autoFocus
style={{ letterSpacing: "0.5em" }}
/>
</div>
);
const renderEmailTab = () => (
<div className="p-3">
{!emailSent ? (
<>
<p className="text-muted mb-3">
We'll send a verification code to your email address.
</p>
<button
className="btn btn-primary w-100"
onClick={handleSendEmailOtp}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Sending...
</>
) : (
<>
<i className="bi bi-envelope me-2"></i>
Send Verification Code
</>
)}
</button>
</>
) : (
<>
<div className="alert alert-success mb-3">
<i className="bi bi-check-circle me-2"></i>
Verification code sent to your email.
</div>
<input
type="text"
className={`form-control form-control-lg text-center font-monospace mb-3 ${
error && activeTab === "email" ? "is-invalid" : ""
}`}
placeholder="000000"
value={code}
onChange={handleCodeChange}
maxLength={6}
autoFocus
style={{ letterSpacing: "0.5em" }}
/>
<button
className="btn btn-link btn-sm p-0"
onClick={handleSendEmailOtp}
disabled={loading}
>
Resend code
</button>
</>
)}
</div>
);
const renderRecoveryTab = () => (
<div className="p-3">
<p className="text-muted mb-3">
Enter one of your recovery codes. Each code can only be used once.
</p>
<input
type="text"
className={`form-control form-control-lg text-center font-monospace mb-3 ${
error && activeTab === "recovery" ? "is-invalid" : ""
}`}
placeholder="XXXX-XXXX"
value={code}
onChange={handleCodeChange}
maxLength={9}
autoFocus
style={{ letterSpacing: "0.2em" }}
/>
</div>
);
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">
<h5 className="modal-title">
<i className="bi bi-shield-lock me-2"></i>
Verify Your Identity
</h5>
<button type="button" className="btn-close" onClick={onHide}></button>
</div>
<div className="modal-body p-0">
<div className="p-3 pb-0">
<p className="text-muted mb-0">
Multi-factor authentication is required to {getActionDescription(action)}.
</p>
</div>
<ul className="nav nav-tabs px-3 pt-3">
{methods.includes("totp") && (
<li className="nav-item">
<button
className={`nav-link ${activeTab === "totp" ? "active" : ""}`}
onClick={() => {
setActiveTab("totp");
setCode("");
setError(null);
}}
>
<i className="bi bi-phone me-1"></i>
App
</button>
</li>
)}
{methods.includes("email") && (
<li className="nav-item">
<button
className={`nav-link ${activeTab === "email" ? "active" : ""}`}
onClick={() => {
setActiveTab("email");
setCode("");
setError(null);
}}
>
<i className="bi bi-envelope me-1"></i>
Email
</button>
</li>
)}
{methods.includes("recovery") && (
<li className="nav-item">
<button
className={`nav-link ${activeTab === "recovery" ? "active" : ""}`}
onClick={() => {
setActiveTab("recovery");
setCode("");
setError(null);
}}
>
<i className="bi bi-key me-1"></i>
Recovery
</button>
</li>
)}
</ul>
{activeTab === "totp" && renderTotpTab()}
{activeTab === "email" && renderEmailTab()}
{activeTab === "recovery" && renderRecoveryTab()}
{error && (
<div className="px-3 pb-3">
<div className="alert alert-danger mb-0">
<i className="bi bi-exclamation-circle me-2"></i>
{error}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={onHide}>
Cancel
</button>
{(activeTab !== "email" || emailSent) && (
<button
className="btn btn-primary"
onClick={handleVerify}
disabled={loading || !isCodeValid()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Verifying...
</>
) : (
"Verify"
)}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TwoFactorVerifyModal;

View File

@@ -0,0 +1,4 @@
export { default as TwoFactorSetupModal } from "./TwoFactorSetupModal";
export { default as TwoFactorVerifyModal } from "./TwoFactorVerifyModal";
export { default as TwoFactorManagement } from "./TwoFactorManagement";
export { default as RecoveryCodesDisplay } from "./RecoveryCodesDisplay";

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api";
@@ -10,6 +10,7 @@ import ReviewRenterModal from "../components/ReviewRenterModal";
import ReviewDetailsModal from "../components/ReviewDetailsModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
import Avatar from "../components/Avatar";
import PasswordStrengthMeter from "../components/PasswordStrengthMeter";
import {
geocodingService,
AddressComponents,
@@ -20,6 +21,8 @@ import {
useAddressAutocomplete,
usStates,
} from "../hooks/useAddressAutocomplete";
import { TwoFactorManagement } from "../components/TwoFactor";
import { validatePassword } from "../utils/passwordValidation";
const Profile: React.FC = () => {
const { user, updateUser, logout } = useAuth();
@@ -120,6 +123,56 @@ const Profile: React.FC = () => {
const [showConditionCheckViewer, setShowConditionCheckViewer] = useState(false);
const [selectedConditionCheck, setSelectedConditionCheck] = useState<ConditionCheck | null>(null);
// Password change state
const [passwordFormData, setPasswordFormData] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [pendingPasswordChange, setPendingPasswordChange] = useState(false);
// Listen for step-up auth success to retry pending password change
useEffect(() => {
const handleStepUpSuccess = () => {
if (pendingPasswordChange) {
setPendingPasswordChange(false);
// Retry the password change after successful step-up auth
const retryPasswordChange = async () => {
setPasswordLoading(true);
try {
await userAPI.changePassword(passwordFormData);
setPasswordSuccess("Password changed successfully");
setPasswordFormData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (err: any) {
const errorMessage =
err.response?.data?.error ||
err.response?.data?.message ||
"Failed to change password";
setPasswordError(errorMessage);
} finally {
setPasswordLoading(false);
}
};
retryPasswordChange();
}
};
window.addEventListener("stepUpSuccess", handleStepUpSuccess);
return () => {
window.removeEventListener("stepUpSuccess", handleStepUpSuccess);
};
}, [pendingPasswordChange, passwordFormData]);
useEffect(() => {
fetchProfile();
fetchStats();
@@ -690,6 +743,82 @@ const Profile: React.FC = () => {
// Use address autocomplete hook
const { parsePlace } = useAddressAutocomplete();
// Password change handlers
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setPasswordFormData((prev) => ({ ...prev, [name]: value }));
setPasswordError(null);
};
const isPasswordValid = useMemo(() => {
const { currentPassword, newPassword, confirmPassword } = passwordFormData;
const commonPasswords = ["password", "123456", "123456789", "qwerty", "abc123", "password123"];
return (
currentPassword.length > 0 &&
newPassword.length >= 8 &&
/[a-z]/.test(newPassword) &&
/[A-Z]/.test(newPassword) &&
/\d/.test(newPassword) &&
/[-@$!%*?&#^]/.test(newPassword) &&
newPassword === confirmPassword &&
newPassword !== currentPassword &&
!commonPasswords.includes(newPassword.toLowerCase())
);
}, [passwordFormData]);
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
setPasswordSuccess(null);
const newPassword = passwordFormData.newPassword;
// Validate passwords match
if (newPassword !== passwordFormData.confirmPassword) {
setPasswordError("New passwords do not match");
return;
}
// Validate new password is different from current
if (newPassword === passwordFormData.currentPassword) {
setPasswordError("New password must be different from current password");
return;
}
// Validate password strength
const passwordError = validatePassword(newPassword);
if (passwordError) {
setPasswordError(passwordError);
return;
}
setPasswordLoading(true);
try {
await userAPI.changePassword(passwordFormData);
setPasswordSuccess("Password changed successfully");
setPasswordFormData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (err: any) {
// If step-up authentication is required, mark as pending and let modal handle it
if (err.response?.data?.code === "STEP_UP_REQUIRED") {
setPendingPasswordChange(true);
return;
}
const errorMessage =
err.response?.data?.error ||
err.response?.data?.message ||
"Failed to change password";
setPasswordError(errorMessage);
} finally {
setPasswordLoading(false);
}
};
// Handle place selection from autocomplete
const handlePlaceSelect = useCallback(
(place: PlaceDetails) => {
@@ -775,6 +904,15 @@ const Profile: React.FC = () => {
<i className="bi bi-clock-history me-2"></i>
Rental History
</button>
<button
className={`list-group-item list-group-item-action ${
activeSection === "security" ? "active" : ""
}`}
onClick={() => setActiveSection("security")}
>
<i className="bi bi-shield-lock me-2"></i>
Security
</button>
<button
className="list-group-item list-group-item-action text-danger"
onClick={logout}
@@ -1673,6 +1811,151 @@ const Profile: React.FC = () => {
</div>
</div>
)}
{/* Security Section */}
{activeSection === "security" && (
<div>
<h4 className="mb-4">Security</h4>
{/* Password Change Card */}
<div className="card mb-4">
<div className="card-body">
<h5 className="mb-3">
<i className="bi bi-key me-2"></i>
Change Password
</h5>
{passwordError && (
<div className="alert alert-danger mb-3">
<i className="bi bi-exclamation-circle me-2"></i>
{passwordError}
<button
type="button"
className="btn-close float-end"
onClick={() => setPasswordError(null)}
></button>
</div>
)}
{passwordSuccess && (
<div className="alert alert-success mb-3">
<i className="bi bi-check-circle me-2"></i>
{passwordSuccess}
<button
type="button"
className="btn-close float-end"
onClick={() => setPasswordSuccess(null)}
></button>
</div>
)}
<form onSubmit={handlePasswordSubmit}>
<div className="mb-3">
<label htmlFor="currentPassword" className="form-label">
Current Password
</label>
<div className="position-relative">
<input
type={showCurrentPassword ? "text" : "password"}
className="form-control"
id="currentPassword"
name="currentPassword"
value={passwordFormData.currentPassword}
onChange={handlePasswordChange}
required
/>
<button
type="button"
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
style={{ zIndex: 10, textDecoration: "none" }}
tabIndex={-1}
>
<i className={`bi ${showCurrentPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
</button>
</div>
</div>
<div className="mb-3">
<label htmlFor="newPassword" className="form-label">
New Password
</label>
<div className="position-relative">
<input
type={showNewPassword ? "text" : "password"}
className="form-control"
id="newPassword"
name="newPassword"
value={passwordFormData.newPassword}
onChange={handlePasswordChange}
minLength={8}
required
/>
<button
type="button"
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
onClick={() => setShowNewPassword(!showNewPassword)}
style={{ zIndex: 10, textDecoration: "none" }}
tabIndex={-1}
>
<i className={`bi ${showNewPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
</button>
</div>
<PasswordStrengthMeter password={passwordFormData.newPassword} />
</div>
<div className="mb-3">
<label htmlFor="confirmPassword" className="form-label">
Confirm New Password
</label>
<div className="position-relative">
<input
type={showConfirmPassword ? "text" : "password"}
className="form-control"
id="confirmPassword"
name="confirmPassword"
value={passwordFormData.confirmPassword}
onChange={handlePasswordChange}
required
/>
<button
type="button"
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
style={{ zIndex: 10, textDecoration: "none" }}
tabIndex={-1}
>
<i className={`bi ${showConfirmPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
</button>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={passwordLoading || !isPasswordValid}
>
{passwordLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Changing Password...
</>
) : (
"Change Password"
)}
</button>
</form>
</div>
</div>
{/* Multi-Factor Authentication Card */}
<div className="card">
<div className="card-body">
<TwoFactorManagement />
</div>
</div>
</div>
)}
</div>
</div>

View File

@@ -81,9 +81,25 @@ api.interceptors.response.use(
_csrfRetry?: boolean;
};
// Handle CSRF token errors
// Handle CSRF token errors and step-up authentication
if (error.response?.status === 403) {
const errorData = error.response?.data as any;
// Handle step-up authentication required
if (errorData?.code === "STEP_UP_REQUIRED") {
// Emit custom event for step-up auth modal
window.dispatchEvent(
new CustomEvent("stepUpRequired", {
detail: {
action: errorData.action,
methods: errorData.methods,
originalRequest,
},
})
);
return Promise.reject(error);
}
if (
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
!originalRequest._csrfRetry
@@ -184,12 +200,40 @@ export const userAPI = {
getPublicProfile: (id: string) => api.get(`/users/${id}`),
getAvailability: () => api.get("/users/availability"),
updateAvailability: (data: any) => api.put("/users/availability", data),
changePassword: (data: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) => api.put("/users/password", data),
// Admin endpoints
adminBanUser: (id: string, reason: string) =>
api.post(`/users/admin/${id}/ban`, { reason }),
adminUnbanUser: (id: string) => api.post(`/users/admin/${id}/unban`),
};
export const twoFactorAPI = {
// Setup endpoints
initTotpSetup: () => api.post("/2fa/setup/totp/init"),
verifyTotpSetup: (code: string) =>
api.post("/2fa/setup/totp/verify", { code }),
initEmailSetup: () => api.post("/2fa/setup/email/init"),
verifyEmailSetup: (code: string) =>
api.post("/2fa/setup/email/verify", { code }),
// Verification endpoints (step-up auth)
verifyTotp: (code: string) => api.post("/2fa/verify/totp", { code }),
requestEmailOtp: () => api.post("/2fa/verify/email/send"),
verifyEmailOtp: (code: string) => api.post("/2fa/verify/email", { code }),
verifyRecoveryCode: (code: string) =>
api.post("/2fa/verify/recovery", { code }),
// Management endpoints
getStatus: () => api.get("/2fa/status"),
disable: () => api.post("/2fa/disable"),
regenerateRecoveryCodes: () => api.post("/2fa/recovery/regenerate"),
getRemainingRecoveryCodes: () => api.get("/2fa/recovery/remaining"),
};
export const addressAPI = {
getAddresses: () => api.get("/users/addresses"),
createAddress: (data: any) => api.post("/users/addresses", data),

View File

@@ -38,6 +38,34 @@ export interface User {
bannedAt?: string;
bannedBy?: string;
banReason?: string;
// Two-Factor Authentication fields
twoFactorEnabled?: boolean;
twoFactorMethod?: "totp" | "email";
}
export interface TwoFactorStatus {
enabled: boolean;
method?: "totp" | "email";
hasRecoveryCodes: boolean;
lowRecoveryCodes: boolean;
}
export interface TwoFactorSetupResponse {
qrCodeDataUrl: string;
message: string;
}
export interface TwoFactorVerifyResponse {
message: string;
recoveryCodes: string[];
warning: string;
}
export interface StepUpRequiredError {
error: string;
code: "STEP_UP_REQUIRED";
action: string;
methods: ("totp" | "email" | "recovery")[];
}
export interface Message {

View File

@@ -0,0 +1,34 @@
export const PASSWORD_REQUIREMENTS = [
{ regex: /.{8,}/, text: "At least 8 characters", message: "Password must be at least 8 characters long" },
{ regex: /[a-z]/, text: "One lowercase letter", message: "Password must contain at least one lowercase letter" },
{ regex: /[A-Z]/, text: "One uppercase letter", message: "Password must contain at least one uppercase letter" },
{ regex: /\d/, text: "One number", message: "Password must contain at least one number" },
{ regex: /[-@$!%*?&#^]/, text: "One special character (-@$!%*?&#^)", message: "Password must contain at least one special character (-@$!%*?&#^)" },
];
export const COMMON_PASSWORDS = [
"password",
"123456",
"123456789",
"qwerty",
"abc123",
"password123",
];
/**
* Validates password against all requirements
* @returns error message if invalid, null if valid
*/
export function validatePassword(password: string): string | null {
for (const req of PASSWORD_REQUIREMENTS) {
if (!req.regex.test(password)) {
return req.message;
}
}
if (COMMON_PASSWORDS.includes(password.toLowerCase())) {
return "This password is too common. Please choose a stronger password";
}
return null;
}