From f88ca40d9cd32778c63aa0fb6a3d50e4da76305c Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Thu, 18 Dec 2025 12:44:10 -0800 Subject: [PATCH] feat(payments-next): Add redirects on mismatched cart uids Because: * Users can currently view carts associated with another account's uid when provided with the URL This commit: * Adds in a frontend intercept that redirects the user to the start of the checkout process if the request cart's UID does not match the signed in user's UID Closes #PAY-3155 --- .../checkout/[cartId]/error/page.tsx | 39 +++++++++++++++---- .../checkout/[cartId]/needs_input/page.tsx | 29 +++++++++++++- .../checkout/[cartId]/success/page.tsx | 22 ++++++++++- .../[cartId]/(mainLayout)/error/page.tsx | 37 +++++++++++++++--- .../(mainLayout)/needs_input/page.tsx | 27 ++++++++++++- .../[cartId]/(mainLayout)/success/page.tsx | 22 ++++++++++- 6 files changed, 159 insertions(+), 17 deletions(-) diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx index 492783adae..5d8aa33284 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx @@ -5,7 +5,7 @@ import { headers } from 'next/headers'; import Image from 'next/image'; import Link from 'next/link'; - +import { auth } from 'apps/payments/next/auth'; import errorIcon from '@fxa/shared/assets/images/error.svg'; import checkIcon from '@fxa/shared/assets/images/check.svg'; import { @@ -15,12 +15,12 @@ import { getErrorFtlInfo, buildPageMetadata, } from '@fxa/payments/ui/server'; -import { - getCartOrRedirectAction, -} from '@fxa/payments/ui/actions'; +import { getCartOrRedirectAction } from '@fxa/payments/ui/actions'; import { config } from 'apps/payments/next/config'; import type { Metadata } from 'next'; import { CartErrorReasonId } from '@fxa/shared/db/mysql/account'; +import { buildRedirectUrl } from '@fxa/payments/ui'; +import { redirect } from 'next/navigation'; // forces dynamic rendering // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config @@ -53,15 +53,40 @@ export default async function CheckoutError({ const { locale } = params; const acceptLanguage = headers().get('accept-language'); + const sessionPromise = auth(); const cartPromise = getCartOrRedirectAction( params.cartId, SupportedPages.ERROR, searchParams ); const l10n = getApp().getL10n(acceptLanguage, locale); - const [cart] = await Promise.all([cartPromise]); + const [cart, session] = await Promise.all([cartPromise, sessionPromise]); - const errorReason = getErrorFtlInfo(cart.errorReasonId, params, config, searchParams); + const errorReason = getErrorFtlInfo( + cart.errorReasonId, + params, + config, + searchParams + ); + + if (cart.id && cart.uid !== session?.user?.id) { + const redirectSearchParams: Record = + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } return ( <> @@ -72,7 +97,7 @@ export default async function CheckoutError({ { // Once more conditionals are added, move this to a separate component cart.errorReasonId === - CartErrorReasonId.CART_ELIGIBILITY_STATUS_SAME ? ( + CartErrorReasonId.CART_ELIGIBILITY_STATUS_SAME ? ( = + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } + return (
= + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } + const { successActionButtonUrl, successActionButtonLabel } = cms.commonContent.localizations.at(0) || cms.commonContent; diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx index 305069eef7..4b3e2a147d 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx @@ -5,7 +5,7 @@ import { headers } from 'next/headers'; import Image from 'next/image'; import Link from 'next/link'; - +import { auth } from 'apps/payments/next/auth'; import errorIcon from '@fxa/shared/assets/images/error.svg'; import { getApp, @@ -14,11 +14,11 @@ import { getErrorFtlInfo, buildPageMetadata, } from '@fxa/payments/ui/server'; -import { - getCartOrRedirectAction, -} from '@fxa/payments/ui/actions'; +import { getCartOrRedirectAction } from '@fxa/payments/ui/actions'; import { config } from 'apps/payments/next/config'; import { Metadata } from 'next'; +import { buildRedirectUrl } from '@fxa/payments/ui'; +import { redirect } from 'next/navigation'; // forces dynamic rendering // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config @@ -51,15 +51,40 @@ export default async function UpgradeError({ const { locale } = params; const acceptLanguage = headers().get('accept-language'); + const sessionPromise = auth(); const cartPromise = getCartOrRedirectAction( params.cartId, SupportedPages.ERROR, searchParams ); const l10n = getApp().getL10n(acceptLanguage, locale); - const [cart] = await Promise.all([cartPromise]); + const [cart, session] = await Promise.all([cartPromise, sessionPromise]); - const errorReason = getErrorFtlInfo(cart.errorReasonId, params, config, searchParams); + const errorReason = getErrorFtlInfo( + cart.errorReasonId, + params, + config, + searchParams + ); + + if (cart.id && cart.uid !== session?.user?.id) { + const redirectSearchParams: Record = + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } return ( <> diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/needs_input/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/needs_input/page.tsx index 39681d5452..9a599437bf 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/needs_input/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/needs_input/page.tsx @@ -7,6 +7,7 @@ import { LoadingSpinner, StripeWrapper, PaymentInputHandler, + buildRedirectUrl, } from '@fxa/payments/ui'; import { getApp, @@ -17,6 +18,8 @@ import { headers } from 'next/headers'; import { getCartOrRedirectAction } from '@fxa/payments/ui/actions'; import { Metadata } from 'next'; import { config } from 'apps/payments/next/config'; +import { auth } from 'apps/payments/next/auth'; +import { redirect } from 'next/navigation'; export async function generateMetadata({ params, @@ -45,14 +48,36 @@ export default async function NeedsInputPage({ const { locale } = params; const acceptLanguage = headers().get('accept-language'); const l10n = getApp().getL10n(acceptLanguage, locale); - const cart = await getCartOrRedirectAction( + const cartPromise = getCartOrRedirectAction( params.cartId, SupportedPages.NEEDS_INPUT, searchParams ); + const sessionPromise = auth(); + const [session, cart] = await Promise.all([sessionPromise, cartPromise]); if (!cart.currency) { throw new Error('Currency is missing from the cart'); } + + if (!session?.user?.id || cart.uid !== session.user.id) { + const redirectSearchParams: Record = + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } + return (
= + searchParams || {}; + delete redirectSearchParams.cartId; + delete redirectSearchParams.cartVersion; + const redirectTo = buildRedirectUrl( + params.offeringId, + params.interval, + 'new', + 'checkout', + { + baseUrl: config.paymentsNextHostedUrl, + locale, + searchParams: redirectSearchParams, + } + ); + redirect(redirectTo); + } + const { successActionButtonUrl, successActionButtonLabel } = cms.commonContent.localizations.at(0) || cms.commonContent;