3D Secure handling

This commit is contained in:
jackiettran
2026-01-08 12:44:57 -05:00
parent 8b9b92d848
commit bcb917c959
14 changed files with 1093 additions and 40 deletions

View File

@@ -26,6 +26,7 @@ import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost';
import MyPosts from './pages/MyPosts';
import EarningsDashboard from './pages/EarningsDashboard';
import CompletePayment from './pages/CompletePayment';
import FAQ from './pages/FAQ';
import NotFound from './pages/NotFound';
import PrivateRoute from './components/PrivateRoute';
@@ -126,6 +127,14 @@ const AppContent: React.FC = () => {
</PrivateRoute>
}
/>
<Route
path="/complete-payment/:rentalId"
element={
<PrivateRoute>
<CompletePayment />
</PrivateRoute>
}
/>
<Route
path="/owning"
element={

View File

@@ -0,0 +1,73 @@
import React from "react";
interface AuthenticationRequiredModalProps {
rental: {
renter?: { firstName?: string; lastName?: string; email?: string };
item?: { name?: string };
};
onClose: () => void;
}
const AuthenticationRequiredModal: React.FC<
AuthenticationRequiredModalProps
> = ({ rental, onClose }) => {
const renterName =
`${rental.renter?.firstName || ""} ${rental.renter?.lastName || ""}`.trim() ||
"The renter";
const renterEmail = rental.renter?.email || "their email";
const itemName = rental.item?.name || "the item";
return (
<div
className="modal fade show d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
tabIndex={-1}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<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 text-warning me-2"></i>
Authentication Required
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<p>
<strong>{renterName}</strong>'s bank requires additional
authentication to complete the payment for{" "}
<strong>{itemName}</strong>.
</p>
<div className="alert alert-info mb-3">
<i className="bi bi-envelope me-2"></i>
We've sent an email to <strong>{renterEmail}</strong> with
instructions to complete the authentication.
</div>
<p className="text-muted mb-0">
Once they complete the authentication, the rental will be
automatically confirmed and you'll receive a notification.
</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
onClick={onClose}
>
Got it
</button>
</div>
</div>
</div>
</div>
);
};
export default AuthenticationRequiredModal;

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { loadStripe } from "@stripe/stripe-js";
import { rentalAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
const CompletePayment: React.FC = () => {
const { rentalId } = useParams<{ rentalId: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [status, setStatus] = useState<
"loading" | "authenticating" | "success" | "error"
>("loading");
const [error, setError] = useState<string | null>(null);
const hasProcessed = useRef(false);
const handleAuthentication = useCallback(async () => {
if (!rentalId) {
setError("Invalid rental ID");
setStatus("error");
return;
}
try {
// Get client secret
const secretResponse = await rentalAPI.getPaymentClientSecret(rentalId);
const { clientSecret, status: piStatus } = secretResponse.data;
if (piStatus === "succeeded") {
// Already succeeded, just complete on backend
await rentalAPI.completePayment(rentalId);
setStatus("success");
return;
}
setStatus("authenticating");
// Initialize Stripe and confirm payment
const stripe = await stripePromise;
if (!stripe) {
throw new Error("Stripe failed to load");
}
const { error: stripeError, paymentIntent } =
await stripe.confirmCardPayment(clientSecret);
if (stripeError) {
setError(stripeError.message || "Authentication failed");
setStatus("error");
return;
}
if (paymentIntent?.status === "succeeded") {
await rentalAPI.completePayment(rentalId);
setStatus("success");
} else {
setError("Payment could not be completed. Please try again.");
setStatus("error");
}
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.response?.data?.error ||
err.message ||
"An error occurred";
// Handle specific error cases
if (err.response?.status === 400) {
if (
err.response?.data?.message?.includes("not awaiting payment")
) {
// Rental is not in requires_action state - redirect to rentals
navigate("/renting", { replace: true });
return;
}
}
setError(errorMessage);
setStatus("error");
}
}, [rentalId, navigate]);
useEffect(() => {
// Wait for auth to finish loading
if (authLoading) return;
// If not logged in, redirect to login
if (!user) {
const returnUrl = `/complete-payment/${rentalId}`;
navigate(`/?login=true&redirect=${encodeURIComponent(returnUrl)}`, {
replace: true,
});
return;
}
// Prevent double execution in React StrictMode
if (hasProcessed.current) return;
hasProcessed.current = true;
if (rentalId) {
handleAuthentication();
}
}, [rentalId, handleAuthentication, user, authLoading, navigate]);
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6 text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Loading...</h5>
</div>
</div>
</div>
);
}
if (status === "loading" || status === "authenticating") {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h4>
{status === "loading"
? "Loading payment details..."
: "Completing authentication..."}
</h4>
<p className="text-muted">
{status === "authenticating"
? "Please complete the authentication when prompted by your bank."
: "Please wait while we retrieve your payment information."}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
if (status === "success") {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-4">
<i
className="bi bi-check-circle-fill text-success"
style={{ fontSize: "4rem" }}
></i>
</div>
<h3>Payment Complete!</h3>
<p className="text-muted mb-4">
Your rental has been confirmed. You'll receive a confirmation
email shortly.
</p>
<Link to="/renting" className="btn btn-primary">
View My Rentals
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
// Error state
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-4">
<i
className="bi bi-x-circle-fill text-danger"
style={{ fontSize: "4rem" }}
></i>
</div>
<h3>Payment Failed</h3>
<p className="text-muted mb-4">{error}</p>
<div className="d-flex gap-2 justify-content-center">
<button
className="btn btn-primary"
onClick={() => {
hasProcessed.current = false;
setStatus("loading");
setError(null);
handleAuthentication();
}}
>
Try Again
</button>
<Link to="/renting" className="btn btn-outline-secondary">
View My Rentals
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompletePayment;

View File

@@ -12,6 +12,7 @@ import ConditionCheckModal from "../components/ConditionCheckModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
import ReturnStatusModal from "../components/ReturnStatusModal";
import PaymentFailedModal from "../components/PaymentFailedModal";
import AuthenticationRequiredModal from "../components/AuthenticationRequiredModal";
const Owning: React.FC = () => {
// Helper function to format time
@@ -77,6 +78,8 @@ const Owning: React.FC = () => {
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
const [paymentFailedError, setPaymentFailedError] = useState<any>(null);
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null);
const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(null);
useEffect(() => {
fetchListings();
@@ -220,8 +223,17 @@ const Owning: React.FC = () => {
} catch (err: any) {
console.error("Failed to accept rental request:", err);
// Check if 3DS authentication is required
if (err.response?.data?.error === "authentication_required") {
// Find the rental to show in the modal
const rental = ownerRentals.find((r) => r.id === rentalId);
setAuthRequiredRental(rental || null);
setShowAuthRequiredModal(true);
// Refresh rentals to update status
fetchOwnerRentals();
}
// Check if it's a payment failure (HTTP 402 or payment_failed error)
if (
else if (
err.response?.status === 402 ||
err.response?.data?.error === "payment_failed"
) {
@@ -867,6 +879,18 @@ const Owning: React.FC = () => {
/>
)}
{/* Authentication Required Modal (3DS) */}
{showAuthRequiredModal && authRequiredRental && (
<AuthenticationRequiredModal
rental={authRequiredRental}
onClose={() => {
setShowAuthRequiredModal(false);
setAuthRequiredRental(null);
fetchOwnerRentals();
}}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div

View File

@@ -401,6 +401,30 @@ const Renting: React.FC = () => {
</div>
)}
{/* 3DS Authentication Required Alert */}
{rental.paymentStatus === "requires_action" && (
<div className="alert alert-warning py-2 mb-3">
<div className="d-flex align-items-center justify-content-between">
<div>
<i className="bi bi-shield-lock me-2"></i>
<strong>Payment authentication required</strong>
<p className="mb-0 small text-muted mt-1">
Your bank requires additional verification to
complete this payment.
</p>
</div>
<button
className="btn btn-warning btn-sm ms-3"
onClick={() =>
navigate(`/complete-payment/${rental.id}`)
}
>
Complete Payment
</button>
</div>
</div>
)}
<div className="d-flex flex-column gap-2 mt-3">
<div className="d-flex gap-2">
{((rental.displayStatus || rental.status) === "pending" ||

View File

@@ -246,6 +246,11 @@ export const rentalAPI = {
}) => api.post("/rentals/cost-preview", data),
updatePaymentMethod: (id: string, stripePaymentMethodId: string) =>
api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }),
// 3DS authentication endpoints
getPaymentClientSecret: (rentalId: string) =>
api.get(`/rentals/${rentalId}/payment-client-secret`),
completePayment: (rentalId: string) =>
api.post(`/rentals/${rentalId}/complete-payment`),
};
export const messageAPI = {

View File

@@ -137,7 +137,7 @@ export interface Rental {
status: RentalStatus;
// Computed status (includes "active" when confirmed + start time passed)
displayStatus?: RentalStatus;
paymentStatus: "pending" | "paid" | "refunded";
paymentStatus: "pending" | "paid" | "refunded" | "requires_action";
// Refund tracking fields
refundAmount?: number;
refundProcessedAt?: string;