diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx index 74fd1970c..ee968c83a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx @@ -616,6 +616,28 @@ describe("ProjectOnboardingWizard", () => { expect(screen.getByText("Payments setup is not available in remote development environments.")).toBeTruthy(); }); + it("does not call setupPayments via Connect in a remote development environment, even if the disabled attribute is bypassed", async () => { + mockGetPublicEnvVar.mockImplementation((name: string) => + name === "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT" ? "true" : "false" + ); + const setupPayments = vi.fn(async () => ({ url: "https://example.com" })); + + renderPaymentsSetupStep({ setupPayments }); + + // Simulate the disabled attribute being bypassed — DOM manipulation, a future + // refactor dropping the `disabled` prop, or any other way the button could + // be engaged despite the visible-state guard. + const connectButton = screen.getByRole("button", { name: "Connect" }); + connectButton.removeAttribute("disabled"); + fireEvent.click(connectButton); + + // Flush microtasks so the async handler has a chance to run to completion. + await Promise.resolve(); + await Promise.resolve(); + + expect(setupPayments).not.toHaveBeenCalled(); + }); + it("does not call setupPayments via Do Later in a remote development environment, even for a US project", async () => { mockGetPublicEnvVar.mockImplementation((name: string) => name === "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT" ? "true" : "false" diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index e366b4a74..1439c7be2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -393,6 +393,12 @@ export function ProjectOnboardingWizard(props: { }, [isRemoteDevelopmentEnvironment, persistOnboardingState, props.project.app, runWithSaving, selectedPaymentsCountry, setStatus]); const connectPaymentsSetup = useCallback(async () => { + // Defense-in-depth: the Connect button is disabled in RDE (the primary guard + // at the JSX level). This early-return is the secondary guard so the handler + // is safe even if the button is ever engaged some other way — a future refactor + // dropping the `disabled` prop, DOM manipulation, programmatic invocation, etc. + // Mirrors the symmetric guard in deferPaymentsSetup. + if (isRemoteDevelopmentEnvironment) return; await runWithSaving(async () => { setPaymentsSetupAction("connect"); try { @@ -406,7 +412,7 @@ export function ProjectOnboardingWizard(props: { setPaymentsSetupAction(null); } }); - }, [props.project.app, runWithSaving]); + }, [isRemoteDevelopmentEnvironment, props.project.app, runWithSaving]); useEffect(() => { if (status !== "payments_setup" || stripeAccountInfo?.details_submitted !== true || paymentsAutoCompletingRef.current) {