diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx
index 6a7306a51a4..9bd1db219b9 100644
--- a/packages/fxa-settings/src/components/App/index.tsx
+++ b/packages/fxa-settings/src/components/App/index.tsx
@@ -212,7 +212,7 @@ const PairSupplicantSyncSuccess = lazy(
() => import('../../pages/Pair2/Supplicant/SyncSuccess')
);
const PairSupplicantTimeoutAndCancel = lazy(
- () => import('../../pages/Pair2/Supplicant/TimeoutAndCancel')
+ () => import('../../pages/Pair2/Supplicant/TimeoutAndCancel/container')
);
diff --git a/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts b/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts
new file mode 100644
index 00000000000..1fb1d9a6253
--- /dev/null
+++ b/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { pairingFlow } from './pairing-flow';
+import { PairingChannelClient } from './pairing-channel';
+
+jest.mock('./pairing-channel');
+
+// FXA-13869: on a cancel, timeout, or disconnect the flow must truly close the
+// channel WebSocket, not just navigate away.
+describe('PairingFlowController.reset channel teardown', () => {
+ beforeEach(async () => {
+ // Clear the module singleton so each test starts without a client.
+ await pairingFlow.reset();
+ jest.clearAllMocks();
+ });
+
+ it('closes the channel and clears connection state', async () => {
+ const close = jest.fn().mockResolvedValue(undefined);
+ (PairingChannelClient as jest.Mock).mockImplementation(() => ({
+ open: jest.fn().mockResolvedValue(undefined),
+ close,
+ isConnected: true,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+
+ await pairingFlow.joinChannel('wss://channel.example', 'cid', 'ckey');
+ expect(pairingFlow.isConnected).toBe(true);
+
+ await pairingFlow.reset();
+
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(pairingFlow.isConnected).toBe(false);
+ });
+
+ it('clears the held handshake state', async () => {
+ (PairingChannelClient as jest.Mock).mockImplementation(() => ({
+ open: jest.fn().mockResolvedValue(undefined),
+ close: jest.fn().mockResolvedValue(undefined),
+ isConnected: true,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+
+ await pairingFlow.joinChannel('wss://channel.example', 'cid', 'ckey');
+ pairingFlow.supplicantOAuth = {
+ state: 's',
+ scope: 'profile',
+ code_challenge: 'c',
+ };
+
+ await pairingFlow.reset();
+
+ expect(pairingFlow.channelId).toBeUndefined();
+ expect(pairingFlow.channelKey).toBeUndefined();
+ expect(pairingFlow.supplicantOAuth).toBeUndefined();
+ expect(pairingFlow.completing).toBe(false);
+ });
+});
diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx
index 6655ed95558..18060fe3fde 100644
--- a/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx
@@ -27,7 +27,7 @@ const ContinueOnMobileContainer = ({
if (integration instanceof PairingAuthorityIntegration) {
integration.destroy();
}
- navigateWithQuery('/pair/authority/timeout_and_cancel');
+ navigateWithQuery('/pair/authority/timeout_and_cancel?reason=canceled');
};
return ;
diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx
index de3b4dc797d..60a60240a27 100644
--- a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx
@@ -168,14 +168,14 @@ describe('Pair2/Authority/ScanQR container', () => {
expect(mockNavigate).toHaveBeenCalledWith('/pair/authority/approve_signin');
});
- it('navigates to the cancel screen when pairing fails', async () => {
+ it('navigates to the cancel screen with reason=timeout when pairing fails', async () => {
renderContainer();
await waitFor(() => expect(integration.onStateChange).toBeTruthy());
emitState(integration, AuthorityState.Failed);
expect(mockNavigate).toHaveBeenCalledWith(
- '/pair/authority/timeout_and_cancel'
+ '/pair/authority/timeout_and_cancel?reason=timeout'
);
});
diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx
index b7b9923f35d..f9300c76fd1 100644
--- a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx
@@ -13,6 +13,9 @@ import {
PairingAuthorityIntegration,
} from '../../../../models';
+// How long to show the QR before giving up if no device scans it.
+const SCAN_QR_TIMEOUT_MS = 2 * 60 * 1000;
+
/**
* Owns the pairing channel for the authority. Mints a channel on mount so the
* QR always scans to one that exists on the channel server, and closes it on
@@ -37,13 +40,18 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => {
if (!authority) {
return;
}
+ let inactivityTimer: ReturnType | undefined;
+
authority.onStateChange = (state: AuthorityState) => {
switch (state) {
case AuthorityState.WaitingForAuthorizations:
+ // A device scanned, so the no-scan timeout no longer applies.
+ clearTimeout(inactivityTimer);
navigate('/pair/authority/approve_signin');
break;
case AuthorityState.Failed:
- navigate('/pair/authority/timeout_and_cancel');
+ clearTimeout(inactivityTimer);
+ navigate('/pair/authority/timeout_and_cancel?reason=timeout');
break;
default:
// Connecting and WaitingForMetadata both resolve on this page.
@@ -57,6 +65,10 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => {
const pairUrl = authority.getPairUrl('2');
plog('auth QR minted', pairUrl.split('#')[1] ?? '');
setQrCodeValue(pairUrl);
+ inactivityTimer = setTimeout(
+ () => navigate('/pair/authority/timeout_and_cancel?reason=timeout'),
+ SCAN_QR_TIMEOUT_MS
+ );
} catch (err) {
setQrCodeValue('');
Sentry.captureException(err);
@@ -64,6 +76,7 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => {
})();
return () => {
+ clearTimeout(inactivityTimer);
// Deliberately no `destroy()` here. The channel has to outlive this page
// for the rest of the v2 flow, and tearing down on unmount also loses the
// channel under StrictMode's double-invoked effect: cleanup's async
diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx
new file mode 100644
index 00000000000..984b542f131
--- /dev/null
+++ b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from 'react';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router';
+import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
+import { navigateWithQuery } from '../../../../lib/utilities';
+import {
+ MockAuthorityIntegration,
+ mockAuthorityIntegration,
+} from '../ScanQR/mocks';
+import Container from './container';
+
+jest.mock('../../../../lib/utilities', () => ({
+ ...jest.requireActual('../../../../lib/utilities'),
+ navigateWithQuery: jest.fn(),
+}));
+
+const mockNavigate = navigateWithQuery as jest.Mock;
+let integration: MockAuthorityIntegration;
+
+const TIMEOUT_HEADING = 'Still want to connect a device?';
+const CANCELED_HEADING = 'Canceled';
+
+const renderAt = (search: string) =>
+ renderWithLocalizationProvider(
+
+
+
+ );
+
+describe('Pair2/Authority/TimeoutAndCancel container', () => {
+ beforeEach(() => {
+ integration = mockAuthorityIntegration();
+ });
+ afterEach(() => jest.clearAllMocks());
+
+ it('renders the canceled variant when reason=canceled', () => {
+ renderAt('?reason=canceled');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ CANCELED_HEADING
+ );
+ });
+
+ it('renders the timeout variant when reason is missing', () => {
+ renderAt('');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ TIMEOUT_HEADING
+ );
+ });
+
+ it('tears down the channel and returns to scan_qr on Try again', async () => {
+ const user = userEvent.setup();
+ renderAt('?reason=timeout');
+
+ await user.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(integration.destroy).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('/pair/authority/scan_qr');
+ });
+
+ it('goes to settings on Cancel', async () => {
+ const user = userEvent.setup();
+ renderAt('?reason=timeout');
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/settings');
+ });
+});
diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx
index f130742dde6..d49f0b8a931 100644
--- a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx
@@ -3,23 +3,26 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
-import {
- Integration,
- PairingAuthorityIntegration,
-} from '../../../../models';
+import { useLocation } from 'react-router';
+import { Integration, PairingAuthorityIntegration } from '../../../../models';
import { navigateWithQuery } from '../../../../lib/utilities';
-import TimeoutAndCancel from '.';
+import TimeoutAndCancel, { TimeoutAndCancelReason } from '.';
/**
* Authority timeout/cancel container (FXA-13869). The dead-end screen after a
- * pairing attempt ends without connecting. "Try again" resets the flow and
- * re-mints a channel from scan_qr.
+ * pairing attempt ends without connecting. The caller sets `?reason=timeout` on
+ * a disconnect and `?reason=canceled` on cancel; anything but an explicit cancel
+ * falls back to `timeout`. "Try again" resets the flow and re-mints from scan_qr.
*/
const TimeoutAndCancelContainer = ({
integration,
}: {
integration: Integration;
}) => {
+ const raw = new URLSearchParams(useLocation().search).get('reason');
+ const reason: TimeoutAndCancelReason =
+ raw === 'canceled' ? 'canceled' : 'timeout';
+
const onTryAgain = () => {
if (integration instanceof PairingAuthorityIntegration) {
integration.destroy();
@@ -29,7 +32,9 @@ const TimeoutAndCancelContainer = ({
const onSyncSettings = () => navigateWithQuery('/settings');
const onCancel = () => navigateWithQuery('/settings');
- return ;
+ return (
+
+ );
};
export default TimeoutAndCancelContainer;
diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx
index 8b8552574a2..29baef63e02 100644
--- a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx
@@ -104,7 +104,9 @@ const ApproveSignInContainer = () => {
// An unexpected channel close (authority cancelled or connection
// dropped) sends the supplicant to the timeout screen.
pairingFlow.wireAbort(() =>
- navigateWithQuery('/pair/supplicant/timeout_and_cancel')
+ navigateWithQuery(
+ '/pair/supplicant/timeout_and_cancel?reason=timeout'
+ )
);
// Firefox mobile (app-services) already ran OAuth-start and passed the
@@ -181,7 +183,7 @@ const ApproveSignInContainer = () => {
const onCancel = () => {
// Closing the channel signals the authority to abort too.
pairingFlow.reset();
- navigateWithQuery('/pair/supplicant/timeout_and_cancel');
+ navigateWithQuery('/pair/supplicant/timeout_and_cancel?reason=canceled');
};
return (
diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx
index 006cf4f08ac..af8274b37ee 100644
--- a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx
+++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx
@@ -37,7 +37,9 @@ const ConnectThisDeviceContainer = () => {
plog('supp state mismatch; abort');
off();
pairingFlow.reset();
- navigateWithQuery('/pair/supplicant/timeout_and_cancel');
+ navigateWithQuery(
+ '/pair/supplicant/timeout_and_cancel?reason=canceled'
+ );
return;
}
@@ -74,7 +76,7 @@ const ConnectThisDeviceContainer = () => {
const onCancel = () => {
// Closing the channel signals the authority to abort too.
pairingFlow.reset();
- navigateWithQuery('/pair/supplicant/timeout_and_cancel');
+ navigateWithQuery('/pair/supplicant/timeout_and_cancel?reason=canceled');
};
return (
diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx
new file mode 100644
index 00000000000..5edb095b06a
--- /dev/null
+++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
+import Container from './container';
+
+// The supplicant dead-end card has no actions, so the container's only job is to
+// read `?reason` and render the matching variant (FXA-13869).
+const TIMEOUT_HEADING = 'Looks like we timed out';
+const CANCELED_HEADING = 'Canceled';
+
+const renderAt = (search: string) =>
+ renderWithLocalizationProvider(
+
+
+
+ );
+
+describe('Pair2/Supplicant/TimeoutAndCancel container', () => {
+ it('renders the canceled variant when reason=canceled', () => {
+ renderAt('?reason=canceled');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ CANCELED_HEADING
+ );
+ });
+
+ it('renders the timeout variant when reason=timeout', () => {
+ renderAt('?reason=timeout');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ TIMEOUT_HEADING
+ );
+ });
+
+ it('falls back to timeout when reason is missing', () => {
+ renderAt('');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ TIMEOUT_HEADING
+ );
+ });
+
+ it('falls back to timeout when reason is unknown', () => {
+ renderAt('?reason=bogus');
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ TIMEOUT_HEADING
+ );
+ });
+});
diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx
new file mode 100644
index 00000000000..9319a8cd282
--- /dev/null
+++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from 'react';
+import { useLocation } from 'react-router';
+import TimeoutAndCancel, { PairingInterruptionReason } from '.';
+
+/**
+ * Supplicant timeout/cancel container (FXA-13869). The mobile dead-end screen
+ * has no actions, so the container only reads why the flow ended (the caller
+ * sets `?reason=timeout` on a disconnect and `?reason=canceled` on cancel) and
+ * passes it to the card. Anything but an explicit cancel falls back to `timeout`.
+ */
+const TimeoutAndCancelContainer = () => {
+ const raw = new URLSearchParams(useLocation().search).get('reason');
+ const reason: PairingInterruptionReason =
+ raw === 'canceled' ? 'canceled' : 'timeout';
+
+ return ;
+};
+
+export default TimeoutAndCancelContainer;