MFA
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
166
frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx
Normal file
166
frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx
Normal 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;
|
||||
240
frontend/src/components/TwoFactor/TwoFactorManagement.tsx
Normal file
240
frontend/src/components/TwoFactor/TwoFactorManagement.tsx
Normal 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;
|
||||
384
frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx
Normal file
384
frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx
Normal 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;
|
||||
334
frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx
Normal file
334
frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx
Normal 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;
|
||||
4
frontend/src/components/TwoFactor/index.ts
Normal file
4
frontend/src/components/TwoFactor/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
34
frontend/src/utils/passwordValidation.ts
Normal file
34
frontend/src/utils/passwordValidation.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user