diff --git a/packages/fxa-settings/src/components/DeviceInfoBlock/en.ftl b/packages/fxa-settings/src/components/DeviceInfoBlock/en.ftl index d05fd98233f..e820df9a611 100644 --- a/packages/fxa-settings/src/components/DeviceInfoBlock/en.ftl +++ b/packages/fxa-settings/src/components/DeviceInfoBlock/en.ftl @@ -19,6 +19,9 @@ device-info-block-location-unknown = Location unknown # Variable { $browserName } is the browser that created the request (e.g., Firefox) # Variable { $genericOSName } is the name of the operating system that created the request (e.g., MacOS, Windows, iOS) device-info-browser-os = { $browserName } on { $genericOSName } +# Variable { $browserName } is the browser that created the request (e.g., Firefox) +# Variable { $deviceName } is the user-chosen name of the device that created the request (e.g., Laurel's MacBook Pro) +device-info-browser-device = { $browserName } on { $deviceName } # Variable { $ipAddress } represents the IP address where the request originated # The IP address is a string of numbers separated by periods (e.g., 192.158.1.38) device-info-ip-address = IP address: { $ipAddress } diff --git a/packages/fxa-settings/src/components/DeviceInfoBlock/index.stories.tsx b/packages/fxa-settings/src/components/DeviceInfoBlock/index.stories.tsx index 1671cb0680e..8491e25626f 100644 --- a/packages/fxa-settings/src/components/DeviceInfoBlock/index.stories.tsx +++ b/packages/fxa-settings/src/components/DeviceInfoBlock/index.stories.tsx @@ -2,7 +2,7 @@ * 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 DeviceInfoBlock from '.'; +import DeviceInfoBlock, { DeviceNameDisplay } from '.'; import { RemoteMetadata } from '../../lib/types'; import AppLayout from '../AppLayout'; import { Meta } from '@storybook/react'; @@ -21,12 +21,19 @@ export default { decorators: [withLocalization], } as Meta; -const storyWithProps = (props?: Partial) => { +// `metadata` merges into the mock rather than spreading onto the component — +// spreading `Partial` as JSX props type-checks (TS skips +// excess-property checks on spreads) but silently renders nothing, which is +// what `WithDeviceName` used to do. +const storyWithProps = ( + metadata?: Partial, + deviceNameDisplay?: DeviceNameDisplay +) => { const story = () => ( ); @@ -56,3 +63,15 @@ export const WithFullLocation = storyWithProps({ region: MOCK_REGION, country: MOCK_COUNTRY, }); + +// How the Pair2 mobile cards render it: no heading, device name in the +// browser line. +export const WithInlineDeviceName = storyWithProps( + { deviceName: MOCK_DEVICE_NAME }, + 'inline' +); + +export const WithHiddenDeviceName = storyWithProps( + { deviceName: MOCK_DEVICE_NAME }, + 'hidden' +); diff --git a/packages/fxa-settings/src/components/DeviceInfoBlock/index.test.tsx b/packages/fxa-settings/src/components/DeviceInfoBlock/index.test.tsx index a287873a558..cfa30abfb26 100644 --- a/packages/fxa-settings/src/components/DeviceInfoBlock/index.test.tsx +++ b/packages/fxa-settings/src/components/DeviceInfoBlock/index.test.tsx @@ -39,4 +39,61 @@ describe('DeviceInfoBlock component', () => { screen.getByText('Vancouver, British Columbia, Canada (estimated)'); }); + + describe('deviceNameDisplay', () => { + it('folds the device name into the browser line when set to inline', () => { + renderWithLocalizationProvider( + + ); + + screen.getByText('Firefox on Ultron'); + expect( + screen.queryByRole('heading', { level: 2 }) + ).not.toBeInTheDocument(); + expect(screen.queryByText('Firefox on macOS')).not.toBeInTheDocument(); + }); + + it('omits the device name entirely when set to hidden', () => { + renderWithLocalizationProvider( + + ); + + screen.getByText('Firefox on macOS'); + expect( + screen.queryByRole('heading', { level: 2 }) + ).not.toBeInTheDocument(); + expect(screen.queryByText('Firefox on Ultron')).not.toBeInTheDocument(); + }); + + // `pairing-authority-integration` leaves `deviceName` unset rather than + // substituting a generic type, so `inline` has to degrade to the OS line. + it('falls back to the OS line under inline when there is no device name', () => { + renderWithLocalizationProvider( + + ); + + screen.getByText('Firefox on macOS'); + }); + }); + + it('replaces the default wrapper classes when className is given', () => { + const { container } = renderWithLocalizationProvider( + + ); + + expect(container.firstElementChild).toHaveClass('rounded-md', 'border'); + expect(container.firstElementChild).not.toHaveClass('mt-8', 'mb-4'); + }); }); diff --git a/packages/fxa-settings/src/components/DeviceInfoBlock/index.tsx b/packages/fxa-settings/src/components/DeviceInfoBlock/index.tsx index 774b7e2bc52..d9560c71261 100644 --- a/packages/fxa-settings/src/components/DeviceInfoBlock/index.tsx +++ b/packages/fxa-settings/src/components/DeviceInfoBlock/index.tsx @@ -5,11 +5,40 @@ import { FtlMsg } from 'fxa-react/lib/utils'; import { RemoteMetadata } from '../../lib/types'; +/** + * How the device name is presented, when the pairing payload carries one: + * + * - `heading` — its own `

` above the browser line, which names the OS. + * What the live `Pair/*` pages render. + * - `inline` — folded into the browser line ("Firefox on Laurel's MacBook + * Pro"), with no heading. What the Pair2 mobile cards render. + * - `hidden` — omitted entirely; the browser line names the OS. + * + * When the payload has no device name, all three render the OS line — see + * `pairing-authority-integration`, which deliberately leaves `deviceName` + * unset rather than substituting a generic type like "desktop". + */ +export type DeviceNameDisplay = 'heading' | 'inline' | 'hidden'; + // Remote metadata is obtained from pairing channel // Some of this data may align with the account.ts model but the keys are slightly different (e.g., `state` vs `region`) -type DeviceInfoBlockProps = { remoteMetadata: RemoteMetadata }; +type DeviceInfoBlockProps = { + remoteMetadata: RemoteMetadata; + /** + * Replaces the default wrapper classes, for callers that present the block + * differently — the Pair2 cards render it as a bordered box. The per-line + * `text-xs` stays put, so overriding this only moves the box, it does not + * resize the copy. + */ + className?: string; + deviceNameDisplay?: DeviceNameDisplay; +}; -export const DeviceInfoBlock = ({ remoteMetadata }: DeviceInfoBlockProps) => { +export const DeviceInfoBlock = ({ + remoteMetadata, + className = 'mt-8 mb-4', + deviceNameDisplay = 'heading', +}: DeviceInfoBlockProps) => { const { deviceName, deviceFamily, @@ -58,14 +87,25 @@ export const DeviceInfoBlock = ({ remoteMetadata }: DeviceInfoBlockProps) => { }; return ( -
- {deviceName &&

{deviceName}

} - -

{`${deviceFamily} on ${deviceOS}`}

-
+
+ {deviceNameDisplay === 'heading' && deviceName && ( +

{deviceName}

+ )} + {deviceNameDisplay === 'inline' && deviceName ? ( + +

{`${deviceFamily} on ${deviceName}`}

+
+ ) : ( + +

{`${deviceFamily} on ${deviceOS}`}

+
+ )}

diff --git a/packages/fxa-settings/src/components/images/en.ftl b/packages/fxa-settings/src/components/images/en.ftl index 4c8cc37f656..9c63a4b1779 100644 --- a/packages/fxa-settings/src/components/images/en.ftl +++ b/packages/fxa-settings/src/components/images/en.ftl @@ -39,3 +39,8 @@ confetti-falling-image-aria-label = # In this context, “VPN” is a VPN service built into the Firefox browser, and generally isn't localized differently than “VPN” vpn-welcome-image-aria-label = .aria-label = { -brand-firefox } window with a circular badge showing a green checkmark and “VPN,” showing the VPN is active. +sync-devices-image-aria-label = + .aria-label = A desktop browser window and a mobile phone, both syncing, with the { -brand-firefox } mascot alongside them +# Aria label for the Firefox logo and wordmark shown together as a brand lockup +firefox-wordmark-image-aria-label = + .aria-label = { -brand-firefox } diff --git a/packages/fxa-settings/src/components/images/graphic_firefox_wordmark.svg b/packages/fxa-settings/src/components/images/graphic_firefox_wordmark.svg new file mode 100644 index 00000000000..e168e7db553 --- /dev/null +++ b/packages/fxa-settings/src/components/images/graphic_firefox_wordmark.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/fxa-settings/src/components/images/graphic_sync_devices.svg b/packages/fxa-settings/src/components/images/graphic_sync_devices.svg new file mode 100644 index 00000000000..6a3f64bb414 --- /dev/null +++ b/packages/fxa-settings/src/components/images/graphic_sync_devices.svg @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/fxa-settings/src/components/images/index.stories.tsx b/packages/fxa-settings/src/components/images/index.stories.tsx index f955f631488..241e983755d 100644 --- a/packages/fxa-settings/src/components/images/index.stories.tsx +++ b/packages/fxa-settings/src/components/images/index.stories.tsx @@ -20,6 +20,8 @@ import { BackupRecoveryPhoneSmsImage, BackupAuthenticationCodesImage, SyncCloudsImage, + SyncDevicesImage, + FirefoxWordmarkImage, FallingConfettiImage, } from '.'; import { withLocalization } from 'fxa-react/lib/storybooks'; @@ -76,6 +78,14 @@ export const BackupAuthenticationCodes = () => ( ); export const SyncClouds = () => ; +export const SyncDevices = () => ; + +// The lockup's lettering is `currentColor`; switch the Storybook theme toolbar +// to dark to check it against `dark:text-grey-10`. +export const FirefoxWordmark = () => ( + +); + export const ConfettiFallingFullPage = () => ; const smartWindowTheme: IllustrationsTheme = { diff --git a/packages/fxa-settings/src/components/images/index.tsx b/packages/fxa-settings/src/components/images/index.tsx index b7c5f66b37b..e0606284f17 100644 --- a/packages/fxa-settings/src/components/images/index.tsx +++ b/packages/fxa-settings/src/components/images/index.tsx @@ -25,6 +25,8 @@ import { ReactComponent as BackupAuthenticationCodes } from './graphic_authentic import { ReactComponent as SyncClouds } from './graphic_sync_clouds.min.svg'; import { ReactComponent as FallingConfetti } from './graphic_celebrate_confetti.svg'; import { ReactComponent as VpnWelcome } from './graphic_vpn_welcome.svg'; +import { ReactComponent as SyncDevices } from './graphic_sync_devices.svg'; +import { ReactComponent as FirefoxWordmark } from './graphic_firefox_wordmark.svg'; function illustrationStyle( colors?: IllustrationsTheme @@ -225,3 +227,26 @@ export const VpnWelcomeImage = ({ className, ariaHidden }: ImageProps) => ( {...{ className, ariaHidden }} /> ); + +export const SyncDevicesImage = ({ className, ariaHidden }: ImageProps) => ( + +); + +/** + * The Firefox icon-plus-wordmark lockup. Its lettering is `currentColor`, so + * callers set the text colour — e.g. `text-grey-900 dark:text-grey-10` — while + * the fox glyph keeps its gradients. + */ +export const FirefoxWordmarkImage = ({ className, ariaHidden }: ImageProps) => ( + +); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/en.ftl b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/en.ftl new file mode 100644 index 00000000000..07304be0c8e --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/en.ftl @@ -0,0 +1,10 @@ +## ApproveSignIn page - Part of the desktop-to-mobile pairing flow +## Users see this on their mobile device after scanning the pairing QR code +## shown on their computer. It waits for them to approve the sign-in on the +## computer, and shows that computer's details so they can verify the request. + +# "sync" is a verb here, referring to syncing data between the user's devices +pair2-approve-sign-in-heading = One last step to sync +pair2-approve-sign-in-instruction = Approve the sign-in on your computer. +# Dismisses the pairing attempt +pair2-approve-sign-in-cancel-button = Cancel diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.stories.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.stories.tsx new file mode 100644 index 00000000000..288c8498fad --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.stories.tsx @@ -0,0 +1,58 @@ +/* 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 { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; +import ApproveSignIn from '.'; +import { + MOCK_METADATA_UNKNOWN_LOCATION, + MOCK_METADATA_WITH_DEVICE_NAME, + MOCK_METADATA_WITH_LOCATION, +} from '../../../../components/DeviceInfoBlock/mocks'; +import { RemoteMetadata } from '../../../../lib/types'; + +export default { + title: 'Pages/Pair2/Supplicant/ApproveSignIn', + component: ApproveSignIn, + decorators: [withLocalization], +} as Meta; + +// Cancel is wired up by the page that routes this card; stories only need the +// prop to satisfy the contract. +const noop = () => {}; + +export const WithDeviceName = () => ( + +); + +// No device name on the pairing payload, so the device line falls back to the OS. +export const WithoutDeviceName = () => ( + +); + +// Geo lookup resolved nothing, so the location line reads "Location unknown". +export const WithUnknownLocation = () => ( + +); + +// Device names are user-chosen and can be long — check the block wraps rather +// than overflowing the card. +const MOCK_METADATA_LONG_DEVICE_NAME: RemoteMetadata = { + ...MOCK_METADATA_WITH_DEVICE_NAME, + deviceName: 'Laurel’s-Extremely-Long-MacBook-Pro-16-inch-2023', +}; + +export const WithLongDeviceName = () => ( + +); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.test.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.test.tsx new file mode 100644 index 00000000000..7815916189f --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.test.tsx @@ -0,0 +1,126 @@ +/* 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FluentBundle } from '@fluent/bundle'; +import { getFtlBundle, testL10n } from 'fxa-react/lib/test-utils'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import ApproveSignIn from '.'; +import { + MOCK_CITY, + MOCK_COUNTRY, + MOCK_DEVICE_FAMILY, + MOCK_DEVICE_NAME, + MOCK_IP_ADDRESS, + MOCK_METADATA_WITH_DEVICE_NAME, + MOCK_REGION, +} from '../../../../components/DeviceInfoBlock/mocks'; + +/** + * Attribute-only messages belonging to the shared `components/images` + * wrappers. Jest stubs SVG imports with a component whose text content is the + * file name, so their rendered text can never match the Fluent value and + * `testAllL10n` always throws on them. Every page rendering a labelled + * `PreparedImage` hits this; the others give up and comment `testAllL10n` out + * wholesale (`Pair/Success`, `ConnectAnotherDevice`, `SigninTokenCode`, + * `Signup/ConfirmSignupCode`). Skipping just these ids keeps the check on + * every message the page actually owns; the ids themselves are asserted + * against the bundle separately below. + */ +const SHARED_IMAGE_FTL_IDS = ['firefox-wordmark-image-aria-label']; + +// Every variable referenced by the messages this page renders. One set of args +// is applied to all of them, and Fluent ignores the extras. +const FTL_ARGS = { + browserName: MOCK_DEVICE_FAMILY, + deviceName: MOCK_DEVICE_NAME, + city: MOCK_CITY, + region: MOCK_REGION, + country: MOCK_COUNTRY, + ipAddress: MOCK_IP_ADDRESS, +}; + +const renderCard = (onCancel = () => {}) => + renderWithLocalizationProvider( + + ); + +describe('Pair2/Supplicant/ApproveSignIn page', () => { + // Guards against drift between the fallback text in the component and the + // actual Fluent bundle — including a rename of the `device-info-*` ids that + // DeviceInfoBlock owns. + it('renders every message with text matching the Fluent bundle', async () => { + const bundle: FluentBundle = await getFtlBundle('settings'); + renderCard(); + + const messages = screen + .getAllByTestId('ftlmsg-mock') + .filter((el) => !SHARED_IMAGE_FTL_IDS.includes(el.id)); + + expect(messages.length).toBeGreaterThan(0); + messages.forEach((el) => testL10n(el, bundle, FTL_ARGS)); + }); + + it.each(SHARED_IMAGE_FTL_IDS)( + 'has %s in the Fluent bundle', + async (ftlId) => { + const bundle: FluentBundle = await getFtlBundle('settings'); + + expect(bundle.getMessage(ftlId)).toBeDefined(); + } + ); + + it('renders the heading and instruction', () => { + renderCard(); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'One last step to sync' + ); + screen.getByText('Approve the sign-in on your computer.'); + }); + + it('renders the Firefox logo with accessible alt text', () => { + renderCard(); + + expect(screen.getByRole('img', { name: 'Firefox' })).toBeInTheDocument(); + }); + + it('hides the sync illustration from assistive technology', () => { + renderCard(); + + // The Firefox wordmark is the only image this card contributes to the + // accessibility tree; the other is AppLayout's Mozilla header logo. + expect( + screen + .getAllByRole('img') + .map((img) => img.getAttribute('alt') ?? img.getAttribute('aria-label')) + ).toEqual(['Mozilla logo', 'Firefox']); + }); + + // The location/device-name permutations belong to DeviceInfoBlock and are + // covered in its own suite; this only asserts the card wires it up in the + // mode the design calls for — device name inline, no heading. + it('renders the device info block with the device name inline', () => { + renderCard(); + + screen.getByText('Firefox on Ultron'); + screen.getByText('Vancouver, British Columbia, Canada (estimated)'); + screen.getByText('IP address: XX.XX.XXX.XXX'); + expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); + }); + + it('calls onCancel when the cancel button is clicked', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + renderCard(onCancel); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.tsx new file mode 100644 index 00000000000..3bb78101651 --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/index.tsx @@ -0,0 +1,74 @@ +/* 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 { FtlMsg } from 'fxa-react/lib/utils'; +import AppLayout from '../../../../components/AppLayout'; +import DeviceInfoBlock from '../../../../components/DeviceInfoBlock'; +import { + FirefoxWordmarkImage, + SyncDevicesImage, +} from '../../../../components/images'; +import { RemoteMetadata } from '../../../../lib/types'; + +export type ApproveSignInProps = { + /** + * Details of the device that started pairing, shown so the user can confirm + * the request came from their own computer. Supplied by the caller — this + * component does not read the pairing channel itself. + */ + remoteMetadata: RemoteMetadata; + /** + * Aborts pairing. Required so that routing this card cannot leave the only + * available action inert — the flow logic itself lands with the route. + */ + onCancel: () => void; +}; + +/** + * The mobile waiting screen shown after the user scans the pairing QR code. It + * tells them to finish approving the sign-in on their computer and shows the + * requesting device's details for verification. Cancel is the only action. + * + * Presentational only: routing and the page-view/Glean metrics that sibling + * pairing pages emit land with the flow wiring. The card surface comes from + * `AppLayout`, matching the sibling Pair2 screens — `card` only paints a + * backdrop from `mobileLandscape` up, so on a phone this still reads as the + * full-bleed design. + */ +const ApproveSignIn = ({ remoteMetadata, onCancel }: ApproveSignInProps) => ( + +
+ + + {/* Decorative: the heading and instruction already carry the meaning. */} + + + +

One last step to sync

+
+ +

Approve the sign-in on your computer.

+
+ + + + + + +
+
+); + +export default ApproveSignIn;