diff --git a/packages/functional-tests/tests/react-conversion/oauthSignup.spec.ts b/packages/functional-tests/tests/react-conversion/oauthSignup.spec.ts index 6729c8065ce..eb6e03b697d 100644 --- a/packages/functional-tests/tests/react-conversion/oauthSignup.spec.ts +++ b/packages/functional-tests/tests/react-conversion/oauthSignup.spec.ts @@ -130,8 +130,9 @@ test.describe('severity-1 #smoke', () => { await confirmSignupCode.fillOutCodeForm(code); await expect(page).toHaveURL(/pair/); - await expect(page).toHaveURL(/signupSuccess=true/); - await expect(page).toHaveURL(/showSuccessMessage=true/); + await expect( + page.getByText('Account created. You’re now syncing.') + ).toBeVisible(); await signup.checkWebChannelMessage(FirefoxCommand.OAuthLogin); }); }); diff --git a/packages/fxa-content-server/app/scripts/lib/constants.js b/packages/fxa-content-server/app/scripts/lib/constants.js index 7f05b506b77..b9426cb7726 100644 --- a/packages/fxa-content-server/app/scripts/lib/constants.js +++ b/packages/fxa-content-server/app/scripts/lib/constants.js @@ -174,6 +174,18 @@ module.exports = { FIREFOX_TABS_SIDEBAR_ENTRYPOINT: 'tabs-sidebar', FIREFOX_FX_VIEW_ENTRYPOINT: 'fx-view', + // Keep in sync with packages/fxa-settings/src/constants/index.tsx SEND_TAB_ENTRYPOINTS + // We're removing all this code soon enough ;) + SEND_TAB_ENTRYPOINTS: [ + 'send-tab-tab-context-menu', + 'send-tab-account-menu', + 'send-tab-app-menu', + 'send-tab-firefox-view-three-dots', + 'send-tab-link-context-menu', + 'send-tab-page-context-menu', + 'send-tab-toolbar-icon', + ], + // This is compared against all secondary email // records, both verified and unverified MAX_SECONDARY_EMAILS: 3, diff --git a/packages/fxa-content-server/app/scripts/templates/pair/auth_complete.mustache b/packages/fxa-content-server/app/scripts/templates/pair/auth_complete.mustache index c8081ea49d1..48495fb9b56 100644 --- a/packages/fxa-content-server/app/scripts/templates/pair/auth_complete.mustache +++ b/packages/fxa-content-server/app/scripts/templates/pair/auth_complete.mustache @@ -1,28 +1,50 @@
-
-

{{#t}}Device connected{{/t}}

-
- -
-
- - - -

- {{#unsafeTranslate}} - You are now syncing with: %(deviceFamily)s on %(deviceOS)s - {{/unsafeTranslate}} -

- -

{{#t}}Now you can access your open tabs, passwords, and bookmarks on all your devices.{{/t}}

- -
- {{#hasFirefoxViewSupport}} - - {{/hasFirefoxViewSupport}} - {{^hasFirefoxViewSupport}} - - {{/hasFirefoxViewSupport}} -
-
+ {{#isSendTab}} +
+

{{#t}}You’re ready to send some tabs{{/t}}

+
+ +
+
+ + + +

+ {{#unsafeTranslate}} + %(deviceFamily)s for %(deviceOS)s is connected. + {{/unsafeTranslate}} +

+ +

{{#t}}You’re free to instantly send open tabs, passwords, and bookmarks between devices.{{/t}}

+
+ {{/isSendTab}} + + {{^isSendTab}} +
+

{{#t}}Device connected{{/t}}

+
+ +
+
+ + + +

+ {{#unsafeTranslate}} + You are now syncing with: %(deviceFamily)s on %(deviceOS)s + {{/unsafeTranslate}} +

+ +

{{#t}}Now you can access your open tabs, passwords, and bookmarks on all your devices.{{/t}}

+ +
+ {{#hasFirefoxViewSupport}} + + {{/hasFirefoxViewSupport}} + {{^hasFirefoxViewSupport}} + + {{/hasFirefoxViewSupport}} +
+
+ {{/isSendTab}}
diff --git a/packages/fxa-content-server/app/scripts/templates/pair/index.mustache b/packages/fxa-content-server/app/scripts/templates/pair/index.mustache index a4336355a3f..9f535437dc5 100644 --- a/packages/fxa-content-server/app/scripts/templates/pair/index.mustache +++ b/packages/fxa-content-server/app/scripts/templates/pair/index.mustache @@ -28,8 +28,13 @@ {{/needsMobileConfirmed}} {{^needsMobileConfirmed}} -

{{#t}}Connect another device{{/t}}

-

{{#t}}Sync your Firefox experience{{/t}}

+ {{#isSendTab}} +

{{#t}}Download or open Firefox on the device where you want to send tabs{{/t}}

+ {{/isSendTab}} + {{^isSendTab}} +

{{#t}}Connect another device{{/t}}

+

{{#t}}Sync your Firefox experience{{/t}}

+ {{/isSendTab}} {{/needsMobileConfirmed}} {{#needsMobileConfirmed}} @@ -41,7 +46,9 @@
{{^needsMobileConfirmed}} -

{{#t}}View your saved passwords, tabs, browsing history and more — across all your devices.{{/t}}

+ {{^isSendTab}} +

{{#t}}View your saved passwords, tabs, browsing history and more — across all your devices.{{/t}}

+ {{/isSendTab}}
diff --git a/packages/fxa-content-server/app/scripts/views/pair/auth_complete.js b/packages/fxa-content-server/app/scripts/views/pair/auth_complete.js index 676842816ee..480914cbe93 100644 --- a/packages/fxa-content-server/app/scripts/views/pair/auth_complete.js +++ b/packages/fxa-content-server/app/scripts/views/pair/auth_complete.js @@ -43,12 +43,18 @@ class PairAuthCompleteView extends FormView { setInitialContext(context) { const deviceContext = assign({}, this.broker.get('remoteMetaData')); const graphicId = this.getGraphicsId(); + const entrypoint = + (this.relier && this.relier.get && this.relier.get('entrypoint')) || + this.getSearchParam('entrypoint'); + const isSendTab = + !!entrypoint && Constants.SEND_TAB_ENTRYPOINTS.indexOf(entrypoint) !== -1; context.set({ graphicId, deviceFamily: deviceContext.family, deviceOS: deviceContext.OS, hasFirefoxViewSupport: this._hasFirefoxViewSupport(), + isSendTab, }); } diff --git a/packages/fxa-content-server/app/scripts/views/pair/index.js b/packages/fxa-content-server/app/scripts/views/pair/index.js index 0d85e74ed40..276d3e6321f 100644 --- a/packages/fxa-content-server/app/scripts/views/pair/index.js +++ b/packages/fxa-content-server/app/scripts/views/pair/index.js @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Cocktail from 'cocktail'; +import Constants from '../../lib/constants'; import { MARKETING_ID_AUTUMN_2016, SYNC_SERVICE } from '../../lib/constants'; import GleanMetrics from '../../lib/glean'; import UserAgentMixin from '../../lib/user-agent-mixin'; @@ -187,6 +188,10 @@ class PairIndexView extends FormView { } } + const entrypoint = this.getSearchParam('entrypoint'); + const isSendTab = + !!entrypoint && Constants.SEND_TAB_ENTRYPOINTS.indexOf(entrypoint) !== -1; + context.set({ graphicId, needsMobileConfirmed, @@ -195,6 +200,7 @@ class PairIndexView extends FormView { showPasswordCreatedMessage: this.showPasswordCreatedMessage(), buttonTextShadowClass, tabletBackArrowColor, + isSendTab, }); } diff --git a/packages/fxa-content-server/app/tests/spec/views/pair/auth_complete.js b/packages/fxa-content-server/app/tests/spec/views/pair/auth_complete.js index b8487add9e4..90d0cf7d5f6 100644 --- a/packages/fxa-content-server/app/tests/spec/views/pair/auth_complete.js +++ b/packages/fxa-content-server/app/tests/spec/views/pair/auth_complete.js @@ -62,6 +62,7 @@ describe('views/pair/auth_complete', () => { view = new View({ broker, notifier, + relier, user, viewName: 'pairAuthComplete', window: windowMock, @@ -93,5 +94,32 @@ describe('views/pair/auth_complete', () => { assert.ok(view.$el.find('.bg-image-triple-device-hearts').length); }); }); + + it('renders the Send Tab variant when the entrypoint is a send-tab value', () => { + relier.set('entrypoint', 'send-tab-toolbar-icon'); + return view.render().then(() => { + assert.equal( + view.$el.find('#pair-auth-complete-header').text(), + 'You’re ready to send some tabs' + ); + assert.include(view.$el.text(), 'Firefox for Windows'); + assert.include(view.$el.text(), 'is connected.'); + // No CTA buttons in the Send Tab variant + assert.lengthOf(view.$el.find('#open-firefox-view'), 0); + assert.lengthOf(view.$el.find('#open-connected-services'), 0); + // The "syncing with" heading is suppressed + assert.lengthOf(view.$el.find('#device-os'), 0); + }); + }); + + it('renders the default variant when entrypoint is not a send-tab value', () => { + relier.set('entrypoint', 'preferences'); + return view.render().then(() => { + assert.equal( + view.$el.find('#pair-auth-complete-header').text(), + 'Device connected' + ); + }); + }); }); }); diff --git a/packages/fxa-content-server/app/tests/spec/views/pair/index.js b/packages/fxa-content-server/app/tests/spec/views/pair/index.js index 26716b1bca5..3af2aacccab 100644 --- a/packages/fxa-content-server/app/tests/spec/views/pair/index.js +++ b/packages/fxa-content-server/app/tests/spec/views/pair/index.js @@ -187,6 +187,8 @@ describe('views/pair/index', () => { const subheading = view.$('#pair-header'); assert.strictEqual(subheading.text(), 'Sync your Firefox experience'); assert.equal(view.$('#pair-header-mobile').length, 0); + // Description paragraph should be present in the non-Send-Tab flow. + assert.include(view.$el.text(), 'View your saved passwords'); assert.strictEqual(view.$('#form-ask-mobile-status').length, 1); @@ -235,6 +237,30 @@ describe('views/pair/index', () => { sinon.assert.calledOnce(viewChoiceEventStub); }); + describe('Send Tab variant', () => { + beforeEach(() => { + view.render.restore && view.render.restore(); + const origGetSearchParam = view.getSearchParam.bind(view); + sinon.stub(view, 'getSearchParam').callsFake((name) => { + if (name === 'entrypoint') { + return 'send-tab-toolbar-icon'; + } + return origGetSearchParam(name); + }); + return view.render(); + }); + + it('renders the send-tab heading without the grey "Connect another device" text', () => { + assert.strictEqual(view.$('#cad-header').length, 0); + assert.strictEqual( + view.$('#pair-header').text(), + 'Download or open Firefox on the device where you want to send tabs' + ); + // Description paragraph is suppressed in Send-Tab flow. + assert.notInclude(view.$el.text(), 'View your saved passwords'); + }); + }); + describe('handleRadioEngage', () => { let engageChoiceEventStub; beforeEach(() => { diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index b73b715554c..b9d50749a85 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -804,7 +804,7 @@ const AuthAndAccountSetupRoutes = ({ {/* Pairing */} - + { it('recursively merges multiple objects', () => { @@ -349,3 +351,22 @@ describe('isValidCmsUrl', () => { expect(isValidCmsUrl(value as string | null | undefined)).toBe(false); }); }); + +describe('isSendTabEntrypoint', () => { + it('returns true for all send-tab entrypoints', () => { + for (const entrypoint of SEND_TAB_ENTRYPOINTS) { + expect(isSendTabEntrypoint(entrypoint)).toBe(true); + } + }); + + it('returns false for other entrypoints', () => { + expect(isSendTabEntrypoint('preferences')).toBe(false); + expect(isSendTabEntrypoint('fxa_app_menu')).toBe(false); + }); + + it('returns false for nullish values', () => { + expect(isSendTabEntrypoint(undefined)).toBe(false); + expect(isSendTabEntrypoint(null)).toBe(false); + expect(isSendTabEntrypoint('')).toBe(false); + }); +}); diff --git a/packages/fxa-settings/src/lib/utilities.ts b/packages/fxa-settings/src/lib/utilities.ts index b70af21a521..c043aba9bac 100644 --- a/packages/fxa-settings/src/lib/utilities.ts +++ b/packages/fxa-settings/src/lib/utilities.ts @@ -5,6 +5,7 @@ import base32Encode from 'base32-encode'; import { AttachedClient } from '../models/Account'; import { navigate, NavigateFn, NavigateOptions } from '@reach/router'; +import { SEND_TAB_ENTRYPOINTS } from '../constants'; // Various utilities that don't fit in a standalone lib @@ -283,3 +284,10 @@ export const DEFAULT_PAIRING_ERROR = 'An error occurred during pairing'; export function getPairingErrorMessage(err: unknown): string { return err instanceof Error ? err.message : DEFAULT_PAIRING_ERROR; } + +/** Whether the given entrypoint originated from a Firefox "Send Tab" UI. */ +export function isSendTabEntrypoint( + entrypoint: string | null | undefined +): boolean { + return !!entrypoint && SEND_TAB_ENTRYPOINTS.has(entrypoint); +} diff --git a/packages/fxa-settings/src/models/mocks.tsx b/packages/fxa-settings/src/models/mocks.tsx index fcccb39e8a2..a87a1bfcccb 100644 --- a/packages/fxa-settings/src/models/mocks.tsx +++ b/packages/fxa-settings/src/models/mocks.tsx @@ -31,8 +31,17 @@ export const MOCK_ACCOUNT: AccountData = export const MOCK_SESSION: Session = DEFAULT_APP_CONTEXT.session as unknown as Session; -export function createHistoryWithQuery(path: string, queryParams?: string) { +export function createHistoryWithQuery( + path: string, + queryParams?: string, + state?: object +) { const history = createHistory(createMemorySource(path)); + // navigate first (it resets search), then apply queryParams last so both + // state and search co-exist on the final location. + if (state !== undefined) { + history.navigate(path, { state }); + } if (queryParams != null) { history.location.search = queryParams; } diff --git a/packages/fxa-settings/src/pages/Pair/AuthComplete/en.ftl b/packages/fxa-settings/src/pages/Pair/AuthComplete/en.ftl index 724f2b6bf84..29804359ceb 100644 --- a/packages/fxa-settings/src/pages/Pair/AuthComplete/en.ftl +++ b/packages/fxa-settings/src/pages/Pair/AuthComplete/en.ftl @@ -9,3 +9,12 @@ pair-auth-complete-now-syncing-device-text = You are now syncing with: { $device pair-auth-complete-sync-benefits-text = Now you can access your open tabs, passwords, and bookmarks on all your devices. pair-auth-complete-see-tabs-button = See tabs from synced devices pair-auth-complete-manage-devices-link = Manage devices + +## Alternate "Send Tab" variant — shown when the pair was initiated from a Send Tab entrypoint (toolbar icon, app menu, etc.) + +# Heading +pair-auth-complete-send-tab-heading = You’re ready to send some tabs +# Variable { $deviceFamily } is generally a browser name, for example "Firefox" +# Variable { $deviceOS } is an operating system short name, for example "iOS", "Android" +pair-auth-complete-send-tab-device-connected = { $deviceFamily } for { $deviceOS } is connected. +pair-auth-complete-send-tab-benefits = You’re free to instantly send open tabs, passwords, and bookmarks between devices. diff --git a/packages/fxa-settings/src/pages/Pair/AuthComplete/index.stories.tsx b/packages/fxa-settings/src/pages/Pair/AuthComplete/index.stories.tsx index 937b0828eb0..3640bbe5cc0 100644 --- a/packages/fxa-settings/src/pages/Pair/AuthComplete/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Pair/AuthComplete/index.stories.tsx @@ -8,6 +8,7 @@ import { Meta } from '@storybook/react'; import { MOCK_ERROR } from './mocks'; import { MOCK_METADATA_UNKNOWN_LOCATION } from '../../../components/DeviceInfoBlock/mocks'; import { withLocalization } from 'fxa-react/lib/storybooks'; +import { Integration } from '../../../models/integrations/integration'; export default { title: 'Pages/Pair/AuthComplete', @@ -15,6 +16,12 @@ export default { decorators: [withLocalization], } as Meta; +// Minimal integration stub carrying an entrypoint — just enough to drive the +// isSendTab branch in AuthComplete. The real PairingAuthorityIntegration is +// exercised in tests. +const integrationWithEntrypoint = (entrypoint: string) => + ({ data: { entrypoint } }) as unknown as Integration; + // Any metadata mock from DeviceInfoBlock will do, location is not displayed on this page export const Default = () => ( @@ -27,6 +34,13 @@ export const SupportsFirefoxView = () => ( /> ); +export const SendTabVariant = () => ( + +); + export const WithErrorMessage = () => ( { getSupplicantMetadata: jest.Mock; complete: jest.Mock; destroy: jest.Mock; + data: { entrypoint?: string }; }; beforeEach(() => { @@ -84,6 +85,7 @@ describe('AuthComplete page', () => { '../../../models/integrations/pairing-authority-integration' ); mockIntegration = new PAI(); + mockIntegration.data = { entrypoint: undefined }; }); it('calls complete() on mount and destroy() on unmount', () => { @@ -95,4 +97,57 @@ describe('AuthComplete page', () => { expect(mockIntegration.destroy).toHaveBeenCalled(); }); }); + + describe('Send Tab variant', () => { + it('renders the send-tab layout when entrypoint is a Send Tab entrypoint', () => { + const mockIntegration = { + getSupplicantMetadata: jest.fn().mockResolvedValue(null), + complete: jest.fn(), + destroy: jest.fn(), + data: { entrypoint: 'send-tab-toolbar-icon' }, + }; + renderWithLocalizationProvider( + + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'You’re ready to send some tabs' + ); + expect( + screen.getByText(/Firefox for macOS is connected\./) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'You’re free to instantly send open tabs, passwords, and bookmarks between devices.' + ) + ).toBeInTheDocument(); + // No CTA buttons or links in the Send Tab variant + expect( + screen.queryByRole('button', { name: 'See tabs from synced devices' }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Manage devices' }) + ).not.toBeInTheDocument(); + }); + + it('renders the default layout for non-Send Tab entrypoints', () => { + const mockIntegration = { + getSupplicantMetadata: jest.fn().mockResolvedValue(null), + complete: jest.fn(), + destroy: jest.fn(), + data: { entrypoint: 'preferences' }, + }; + renderWithLocalizationProvider( + + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Device connected' + ); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/Pair/AuthComplete/index.tsx b/packages/fxa-settings/src/pages/Pair/AuthComplete/index.tsx index 4dd8307a052..26d730f36d4 100644 --- a/packages/fxa-settings/src/pages/Pair/AuthComplete/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/AuthComplete/index.tsx @@ -12,9 +12,10 @@ import { RemoteMetadata } from '../../../lib/types'; import AppLayout from '../../../components/AppLayout'; import { REACT_ENTRYPOINT } from '../../../constants'; import Banner from '../../../components/Banner'; -import { Integration } from '../../../models'; +import { Integration, useFtlMsgResolver } from '../../../models'; import { PairingAuthorityIntegration } from '../../../models/integrations/pairing-authority-integration'; import { firefox, FirefoxCommand } from '../../../lib/channels/firefox'; +import { isSendTabEntrypoint } from '../../../lib/utilities'; export const viewName = 'pair.auth.complete'; @@ -34,6 +35,7 @@ const AuthComplete = ({ integration, }: AuthCompleteProps & RouteComponentProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); + const ftlMsgResolver = useFtlMsgResolver(); const [deviceInfo, setDeviceInfo] = useState( suppDeviceInfoProp ); @@ -45,6 +47,7 @@ const AuthComplete = ({ const deviceFamily = deviceInfo?.deviceFamily || 'Unknown'; const deviceOS = deviceInfo?.deviceOS || 'Unknown'; + const isSendTab = isSendTabEntrypoint(integration?.data.entrypoint); // Fetch supplicant metadata if not provided via props useEffect(() => { @@ -71,6 +74,31 @@ const AuthComplete = ({ firefox.send(FirefoxCommand.SyncPreferences, { entryPoint: 'preferences' }); }, []); + if (isSendTab) { + const deviceConnectedText = ftlMsgResolver.getMsg( + 'pair-auth-complete-send-tab-device-connected', + `${deviceFamily} for ${deviceOS} is connected.`, + { deviceFamily, deviceOS } + ); + return ( + + + {error && } + +

{deviceConnectedText}

+ +

+ You’re free to instantly send open tabs, passwords, and bookmarks + between devices. +

+
+
+ ); + } + return ( ( ); -export const ChoiceScreenWithSuccessMessage = () => { - // Simulate ?showSuccessMessage=true in the URL - const source = `/?showSuccessMessage=true`; - return ( - - - - ); -}; +export const ChoiceScreenWithSigninBanner = () => ( + + + +); + +export const ChoiceScreenWithSignupBanner = () => ( + + + +); + +export const ChoiceScreenWithPasswordCreatedBanner = () => ( + + + +); + +export const SendTabChoiceScreen = () => ( + + + +); + +export const SendTabChoiceScreenWithSigninBanner = () => ( + + + +); export const WithError = () => ( diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx index 575697944d1..33a0437b847 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx @@ -17,6 +17,16 @@ jest.mock('../../../lib/metrics', () => ({ usePageViewEvent: jest.fn(), })); +let mockLocationState: unknown = null; +jest.mock('@reach/router', () => ({ + ...jest.requireActual('@reach/router'), + useLocation: () => ({ + pathname: '/pair', + search: '', + state: mockLocationState, + }), +})); + jest.mock('../../../lib/channels/firefox', () => ({ __esModule: true, default: { @@ -45,6 +55,7 @@ jest.mock('../../../lib/glean', () => ({ describe('Pair', () => { afterEach(() => { jest.clearAllMocks(); + mockLocationState = null; }); describe('choice screen', () => { @@ -60,7 +71,7 @@ describe('Pair', () => { screen.getByLabelText(/I already have Firefox for mobile/) ).toBeInTheDocument(); expect( - screen.getByLabelText(/I don.t have Firefox for mobile/) + screen.getByLabelText(/I don’t have Firefox for mobile/) ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); expect( @@ -98,7 +109,7 @@ describe('Pair', () => { it('fires choiceEngage with "does not have mobile" reason', () => { renderWithRouter(); - fireEvent.click(screen.getByLabelText(/I don.t have Firefox for mobile/)); + fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); expect(GleanMetrics.cadFireFox.choiceEngage).toHaveBeenCalledWith({ event: { reason: 'does not have mobile' }, }); @@ -121,7 +132,7 @@ describe('Pair', () => { it('transitions to download screen when "needs mobile" is selected and Continue is clicked', () => { renderWithRouter(); - fireEvent.click(screen.getByLabelText(/I don.t have Firefox for mobile/)); + fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); expect(GleanMetrics.cadFireFox.choiceSubmit).toHaveBeenCalledWith({ event: { reason: 'does not have mobile' }, @@ -144,7 +155,7 @@ describe('Pair', () => { describe('download screen', () => { function renderAndNavigateToDownload() { renderWithRouter(); - fireEvent.click(screen.getByLabelText(/I don.t have Firefox for mobile/)); + fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); } @@ -207,6 +218,77 @@ describe('Pair', () => { }); }); + describe('success banner from location state', () => { + it('renders the signed-in banner for origin=signin', () => { + mockLocationState = { origin: 'signin' }; + renderWithRouter(); + expect(screen.getByText('Signed in successfully!')).toBeInTheDocument(); + }); + + it('renders the signup banner for origin=signup', () => { + mockLocationState = { origin: 'signup' }; + renderWithRouter(); + expect( + screen.getByText('Account created. You’re now syncing.') + ).toBeInTheDocument(); + }); + + it('renders the password-created banner for origin=post-verify-set-password', () => { + mockLocationState = { origin: 'post-verify-set-password' }; + renderWithRouter(); + expect( + screen.getByText('Password created. You’re now syncing.') + ).toBeInTheDocument(); + }); + + it('does not render a banner when origin is absent', () => { + mockLocationState = null; + renderWithRouter(); + expect( + screen.queryByText('Signed in successfully!') + ).not.toBeInTheDocument(); + }); + + it('does not render the banner on the download screen', () => { + mockLocationState = { origin: 'signin' }; + renderWithRouter(); + fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect( + screen.queryByText('Signed in successfully!') + ).not.toBeInTheDocument(); + }); + }); + + describe('Send Tab variant', () => { + const sendTabIntegration = { + data: { entrypoint: 'send-tab-toolbar-icon' }, + } as unknown as import('../../../models').Integration; + + it('renders the send-tab heading without the grey "Connect another device" text', () => { + renderWithRouter(); + expect( + screen.getByRole('heading', { + level: 1, + name: /Download or open Firefox on the device where you want to send tabs/, + }) + ).toBeInTheDocument(); + expect( + screen.queryByText('Connect another device') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Sync your Firefox experience') + ).not.toBeInTheDocument(); + }); + + it('omits the "View your saved passwords…" description', () => { + renderWithRouter(); + expect( + screen.queryByText(/View your saved passwords/) + ).not.toBeInTheDocument(); + }); + }); + describe('CMS theming', () => { it('renders the choice screen Continue button with CMS button color', () => { renderWithRouter(); @@ -221,7 +303,7 @@ describe('Pair', () => { it('renders the download screen Continue to sync button with CMS button color', () => { renderWithRouter(); // Navigate from choice → download by selecting "needs mobile" + Continue - fireEvent.click(screen.getByLabelText(/I don.t have Firefox for mobile/)); + fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); const continueToSyncBtn = screen.getByRole('button', { name: 'Continue to sync', diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.tsx index 22ff54aaaa3..38d84395056 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.tsx @@ -22,6 +22,27 @@ import firefox, { FirefoxCommand } from '../../../lib/channels/firefox'; import qrCodeFirefoxMobile from '../../../components/images/qr_code_firefox_mobile.svg'; import mobileFirefoxIcon from './mobile-ff.svg'; import mobileDownloadIcon from './mobile-download.svg'; +import { isSendTabEntrypoint } from '../../../lib/utilities'; +import type { PairOrigin } from '../../Signin/utils'; +import type { SigninLocationState } from '../../Signin/interfaces'; +import type { Integration } from '../../../models'; + +// Maps the reach-router location.state `origin` set by getSyncNavigate to the +// banner copy shown at the top of the choice screen. +const PAIR_BANNER_FTL: Record = { + signin: { + id: 'pair-signed-in-successfully', + fallback: 'Signed in successfully!', + }, + signup: { + id: 'pair-account-created-now-syncing', + fallback: 'Account created. You’re now syncing.', + }, + 'post-verify-set-password': { + id: 'pair-password-created-now-syncing', + fallback: 'Password created. You’re now syncing.', + }, +}; type MobileChoice = 'has-mobile' | 'needs-mobile'; @@ -35,12 +56,14 @@ type PairView = 'choice' | 'download'; type PairProps = { error?: string; cmsInfo?: RelierCmsInfo; + integration?: Integration; }; export const viewName = 'pair'; const Pair = ({ error, cmsInfo: cmsInfoProp, + integration, }: PairProps & RouteComponentProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); const ftlMsgResolver = useFtlMsgResolver(); @@ -111,10 +134,17 @@ const Pair = ({ GleanMetrics.cadFireFox.view(); }, [currentView]); - // Show success message only on choice screen (matches Backbone) - const showSuccessMessage = - currentView === 'choice' && - new URLSearchParams(location.search).get('showSuccessMessage') === 'true'; + // Show success banner only on the choice screen (matches Backbone). Drive + // the banner variant from reach-router location state set by getSyncNavigate + // — the Backbone-era query params are only read by Backbone /pair now. + const { origin: pairOrigin } = (location.state ?? {}) as Pick< + SigninLocationState, + 'origin' + >; + const bannerCopy = + currentView === 'choice' && pairOrigin ? PAIR_BANNER_FTL[pairOrigin] : null; + + const isSendTab = isSendTabEntrypoint(integration?.data.entrypoint); // Send the pair_preferences WebChannel command to Firefox. // This tells Firefox to open about:preferences#sync and start the pairing flow. @@ -165,7 +195,7 @@ const Pair = ({ id="cad-header" className="text-grey-400 mb-0 tablet:mb-5 text-base inline-block align-top tablet:mt-0" > - Connect another device + Connect another device @@ -254,48 +284,63 @@ const Pair = ({ return ( -
-

- Connect another device -

- -

+ )} + {error && } + {isSendTab ? ( + +

- Sync your Firefox experience -

+ Download or open Firefox on the device where you want to send tabs +

-
+ ) : ( +
+

+ Connect another device +

+ +

+ Sync your Firefox experience +

+
+
+ )}
- {showSuccessMessage && ( - + {!isSendTab && ( + +

+ View your saved passwords, tabs, browsing history and more — + across all your devices. +

+
)} - {error && } - - -

- View your saved passwords, tabs, browsing history and more — across - all your devices. -

-
- + Select an option to continue: diff --git a/packages/fxa-settings/src/pages/Signin/utils.test.ts b/packages/fxa-settings/src/pages/Signin/utils.test.ts index 79e9e8598e8..75079a948d0 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.test.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.test.ts @@ -21,12 +21,11 @@ import { handleNavigation, ensureCanLinkAcountOrRedirect, getSyncNavigate, - isSendTabEntrypoint, } from './utils'; -import { SEND_TAB_ENTRYPOINTS } from '../../constants'; import * as ReachRouter from '@reach/router'; import * as ReactUtils from 'fxa-react/lib/utils'; import firefox from '../../lib/channels/firefox'; +import config from '../../lib/config'; import { OAuthNativeServices } from '@fxa/accounts/oauth'; jest.mock('@reach/router', () => ({ @@ -43,6 +42,18 @@ jest.mock('../../lib/channels/firefox', () => ({ }, })); +jest.mock('../../lib/config', () => { + const actual = jest.requireActual('../../lib/config'); + return { + ...actual, + __esModule: true, + default: { + ...actual.default, + showReactApp: { ...actual.default.showReactApp, pairRoutes: true }, + }, + }; +}); + const navigateSpy = jest.spyOn(ReachRouter, 'navigate'); const hardNavigateSpy = jest.spyOn(ReactUtils, 'hardNavigate'); const fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin'); @@ -374,27 +385,35 @@ describe('Signin utils', () => { ...overrides, }) as NavigationOptions; - it('clears showInlineRecoveryKeySetup for send-tab sign-in and navigates to /pair', async () => { + it('clears showInlineRecoveryKeySetup for send-tab sign-in and soft-navs to /pair', async () => { + const integration = createMockSigninOAuthNativeSyncIntegration(); + integration.data.entrypoint = 'send-tab-toolbar-icon'; const navigationOptions = createSendTabNavigationOptions({ - integration: createMockSigninOAuthNativeSyncIntegration(), - queryParams: '?service=sync&entrypoint=send-tab-toolbar-icon', + integration, + queryParams: '?service=sync', showInlineRecoveryKeySetup: true, handleFxaLogin: true, }); await handleNavigation(navigationOptions); - expect(hardNavigateSpy).toHaveBeenCalled(); - const navigatedUrl = hardNavigateSpy.mock.calls[0][0] as string; + expect(hardNavigateSpy).not.toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalled(); + const [navigatedUrl, options] = navigateSpy.mock.calls[0]; expect(navigatedUrl).toContain('/pair?'); - expect(navigatedUrl).toContain('showSuccessMessage=true'); expect(navigatedUrl).not.toContain('inline_recovery_key'); + expect(navigatedUrl).not.toContain('showSuccessMessage'); + expect( + (options as unknown as { state: { origin: string } }).state.origin + ).toBe('signin'); }); - it('clears showSignupConfirmedSync for send-tab post-verify and navigates to /pair with passwordCreated', async () => { + it('clears showSignupConfirmedSync for send-tab post-verify and soft-navs with origin=post-verify-set-password', async () => { + const integration = createMockSigninOAuthNativeSyncIntegration(); + integration.data.entrypoint = 'send-tab-app-menu'; const navigationOptions = createSendTabNavigationOptions({ - integration: createMockSigninOAuthNativeSyncIntegration(), - queryParams: '?service=sync&entrypoint=send-tab-app-menu', + integration, + queryParams: '?service=sync', showSignupConfirmedSync: true, origin: 'post-verify-set-password', handleFxaLogin: true, @@ -402,11 +421,14 @@ describe('Signin utils', () => { await handleNavigation(navigationOptions); - expect(hardNavigateSpy).toHaveBeenCalled(); - const navigatedUrl = hardNavigateSpy.mock.calls[0][0] as string; + expect(hardNavigateSpy).not.toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalled(); + const [navigatedUrl, options] = navigateSpy.mock.calls[0]; expect(navigatedUrl).toContain('/pair?'); - expect(navigatedUrl).toContain('showSuccessMessage=true'); - expect(navigatedUrl).toContain('passwordCreated=true'); + expect(navigatedUrl).not.toContain('passwordCreated'); + expect( + (options as unknown as { state: { origin: string } }).state.origin + ).toBe('post-verify-set-password'); }); }); }); @@ -466,21 +488,6 @@ describe('Signin utils', () => { }); }); - describe('isSendTabEntrypoint', () => { - it('returns true for all send-tab entrypoints', () => { - for (const entrypoint of SEND_TAB_ENTRYPOINTS) { - expect(isSendTabEntrypoint(`?entrypoint=${entrypoint}`)).toBe(true); - } - }); - - it('returns false for non-send-tab entrypoints', () => { - expect(isSendTabEntrypoint('?entrypoint=preferences')).toBe(false); - expect(isSendTabEntrypoint('?entrypoint=fxa_app_menu')).toBe(false); - expect(isSendTabEntrypoint('')).toBe(false); - expect(isSendTabEntrypoint('?service=sync')).toBe(false); - }); - }); - describe('getSyncNavigate', () => { it('returns /inline_recovery_key_setup when showInlineRecoveryKeySetup is true', () => { const result = getSyncNavigate('?service=sync', { @@ -496,12 +503,20 @@ describe('Signin utils', () => { expect(result.to).toContain('/signup_confirmed_sync?'); }); - describe('/pair redirect', () => { + describe('/pair redirect (Backbone path, pairRoutes=false)', () => { + beforeEach(() => { + config.showReactApp.pairRoutes = false; + }); + afterEach(() => { + config.showReactApp.pairRoutes = true; + }); + it('returns /pair with showSuccessMessage by default', () => { const result = getSyncNavigate('?service=sync'); expect(result.to).toContain('/pair?'); expect(result.to).toContain('showSuccessMessage=true'); expect(result.shouldHardNavigate).toBe(true); + expect(result.locationState).toBeUndefined(); }); it('includes signupSuccess param when signupSuccess is true', () => { @@ -529,5 +544,43 @@ describe('Signin utils', () => { expect(result.to).not.toContain('passwordCreated'); }); }); + + describe('/pair redirect (React path, pairRoutes=true)', () => { + // pairRoutes=true is the test-file default; no toggling needed. + + it('soft-navs to /pair with origin=signin by default', () => { + const result = getSyncNavigate('?service=sync'); + expect(result.to).toContain('/pair?'); + expect(result.to).not.toContain('showSuccessMessage'); + expect(result.shouldHardNavigate).toBe(false); + expect(result.locationState).toEqual({ origin: 'signin' }); + }); + + it('soft-navs with origin=signup when signupSuccess', () => { + const result = getSyncNavigate('?service=sync', { + signupSuccess: true, + }); + expect(result.to).not.toContain('signupSuccess'); + expect(result.shouldHardNavigate).toBe(false); + expect(result.locationState).toEqual({ origin: 'signup' }); + }); + + it('soft-navs with origin=post-verify-set-password when set-password flow', () => { + const result = getSyncNavigate('?service=sync', { + origin: 'post-verify-set-password', + }); + expect(result.to).not.toContain('passwordCreated'); + expect(result.shouldHardNavigate).toBe(false); + expect(result.locationState).toEqual({ + origin: 'post-verify-set-password', + }); + }); + + it('omits the trailing "?" when queryParams is empty', () => { + const result = getSyncNavigate(''); + expect(result.to).toBe('/pair'); + expect(result.shouldHardNavigate).toBe(false); + }); + }); }); }); diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index d5f43bea2b9..b642068fea7 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -13,7 +13,7 @@ import { useAuthClient, isOAuthWebIntegration, } from '../../models'; -import { SEND_TAB_ENTRYPOINTS } from '../../constants'; +import { isSendTabEntrypoint } from '../../lib/utilities'; import { FtlMsgResolver } from 'fxa-react/lib/utils'; import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery'; import { navigate } from '@reach/router'; @@ -24,6 +24,7 @@ import { AuthError } from '../../lib/oauth'; import GleanMetrics from '../../lib/glean'; import { OAuthData } from '../../lib/oauth/hooks'; import AuthenticationMethods from '../../constants/authentication-methods'; +import config from '../../lib/config'; interface NavigationTarget { to: string; @@ -41,13 +42,15 @@ interface NavigationTargetError { error: AuthError; } +export type PairOrigin = NonNullable; + interface SyncNavigateOptions { showInlineRecoveryKeySetup?: boolean; isSignInWithThirdPartyAuth?: boolean; showSignupConfirmedSync?: boolean; syncHidePromoAfterLogin?: boolean; signupSuccess?: boolean; - origin?: string; + origin?: PairOrigin; } export function getSyncNavigate( @@ -60,7 +63,11 @@ export function getSyncNavigate( signupSuccess, origin, }: SyncNavigateOptions = {} -) { +): { + to: string; + shouldHardNavigate: boolean; + locationState?: Pick; +} { const searchParams = new URLSearchParams(queryParams); if (isSignInWithThirdPartyAuth) { @@ -91,6 +98,24 @@ export function getSyncNavigate( }; } + // Backbone-era callers may pass `signupSuccess: true` instead of + // `origin: 'signup'` — honor both. Otherwise fall back to 'signin'. + const pairOrigin: PairOrigin = signupSuccess + ? 'signup' + : (origin ?? 'signin'); + + // TODO: adjust this once `pairRoutes` rollout is 100% and Backbone /pair is retired. + // At that point /pair is always React and we can always soft-nav with + // location state — no config check, no query params needed. + if (config.showReactApp?.pairRoutes) { + const to = searchParams.toString() ? `/pair?${searchParams}` : '/pair'; + return { + to, + shouldHardNavigate: false, + locationState: { origin: pairOrigin }, + }; + } + searchParams.set('showSuccessMessage', 'true'); if (signupSuccess) { searchParams.set('signupSuccess', 'true'); @@ -100,7 +125,6 @@ export function getSyncNavigate( } return { to: `/pair?${searchParams}`, - // TODO: don't hard navigate once Pair is converted to React shouldHardNavigate: true, }; } @@ -221,7 +245,7 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { // Send Tab entrypoints skip intermediate pages (inline recovery key, signup // confirmed sync) and go directly to the /pair choice screen. if ( - isSendTabEntrypoint(navigationOptions.queryParams) && + isSendTabEntrypoint(integration.data?.entrypoint) && integration.isSync() ) { navigationOptions.showInlineRecoveryKeySetup = false; @@ -491,13 +515,16 @@ const getNonOAuthNavigationTarget = async ( origin, } = navigationOptions; if (integration.isSync()) { + const syncNav = getSyncNavigate(queryParams, { + showInlineRecoveryKeySetup, + isSignInWithThirdPartyAuth, + showSignupConfirmedSync, + origin, + }); + const locationState = createSigninLocationState(navigationOptions); return { - ...getSyncNavigate(queryParams, { - showInlineRecoveryKeySetup, - isSignInWithThirdPartyAuth, - showSignupConfirmedSync, - origin, - }), + ...syncNav, + locationState: { ...locationState, ...(syncNav.locationState ?? {}) }, }; } // We don't want a hard navigate to `/settings` as it @@ -517,16 +544,16 @@ const getOAuthNavigationTarget = async ( navigationOptions.isSignInWithThirdPartyAuth && navigationOptions.integration.isSync() ) { + const syncNav = getSyncNavigate(navigationOptions.queryParams, { + showInlineRecoveryKeySetup: locationState.showInlineRecoveryKeySetup, + isSignInWithThirdPartyAuth: navigationOptions.isSignInWithThirdPartyAuth, + showSignupConfirmedSync: navigationOptions.showSignupConfirmedSync, + syncHidePromoAfterLogin: navigationOptions.syncHidePromoAfterLogin, + origin: navigationOptions.origin, + }); return { - ...getSyncNavigate(navigationOptions.queryParams, { - showInlineRecoveryKeySetup: locationState.showInlineRecoveryKeySetup, - isSignInWithThirdPartyAuth: - navigationOptions.isSignInWithThirdPartyAuth, - showSignupConfirmedSync: navigationOptions.showSignupConfirmedSync, - syncHidePromoAfterLogin: navigationOptions.syncHidePromoAfterLogin, - origin: navigationOptions.origin, - }), - locationState, + ...syncNav, + locationState: { ...locationState, ...(syncNav.locationState ?? {}) }, }; } @@ -573,21 +600,21 @@ const getOAuthNavigationTarget = async ( } if (navigationOptions.integration.isSync()) { + const syncNav = getSyncNavigate(navigationOptions.queryParams, { + showInlineRecoveryKeySetup: locationState.showInlineRecoveryKeySetup, + isSignInWithThirdPartyAuth: navigationOptions.isSignInWithThirdPartyAuth, + showSignupConfirmedSync: navigationOptions.showSignupConfirmedSync, + syncHidePromoAfterLogin: navigationOptions.syncHidePromoAfterLogin, + origin: navigationOptions.origin, + }); return { - ...getSyncNavigate(navigationOptions.queryParams, { - showInlineRecoveryKeySetup: locationState.showInlineRecoveryKeySetup, - isSignInWithThirdPartyAuth: - navigationOptions.isSignInWithThirdPartyAuth, - showSignupConfirmedSync: navigationOptions.showSignupConfirmedSync, - syncHidePromoAfterLogin: navigationOptions.syncHidePromoAfterLogin, - origin: navigationOptions.origin, - }), + ...syncNav, oauthData: { code, redirect, state, }, - locationState, + locationState: { ...locationState, ...(syncNav.locationState ?? {}) }, }; } else if (navigationOptions.integration.isFirefoxNonSync()) { return { @@ -669,8 +696,3 @@ export async function ensureCanLinkAcountOrRedirect({ } return ok; } - -export function isSendTabEntrypoint(queryParams: string): boolean { - const entrypoint = new URLSearchParams(queryParams).get('entrypoint'); - return entrypoint !== null && SEND_TAB_ENTRYPOINTS.has(entrypoint); -} diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx index 249820f6074..1cbc3051147 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import { REACT_ENTRYPOINT } from '../../../constants'; -import { isSendTabEntrypoint } from '../../Signin/utils'; +import { isSendTabEntrypoint } from '../../../lib/utilities'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { ERRNO } from '@fxa/accounts/errors'; import { logViewEvent, usePageViewEvent } from '../../../lib/metrics'; @@ -257,8 +257,10 @@ const ConfirmSignupCode = ({ }); // Mobile sync will close the web view, OAuth Desktop mimics DesktopV3 behavior if (integration.isFirefoxDesktopClient()) { - const isSendTab = isSendTabEntrypoint(location.search); - const { to, shouldHardNavigate } = getSyncNavigate( + const isSendTab = isSendTabEntrypoint( + integration.data.entrypoint + ); + const { to, shouldHardNavigate, locationState } = getSyncNavigate( location.search, { showSignupConfirmedSync: !isSendTab, @@ -267,6 +269,8 @@ const ConfirmSignupCode = ({ ); if (shouldHardNavigate) { hardNavigate(to); + } else if (locationState) { + navigate(to, { state: locationState }); } else { navigate(to); }