diff --git a/packages/fxa-auth-client/lib/client.ts b/packages/fxa-auth-client/lib/client.ts index 0f14e61ce66..870fdb98a7b 100644 --- a/packages/fxa-auth-client/lib/client.ts +++ b/packages/fxa-auth-client/lib/client.ts @@ -1538,14 +1538,28 @@ export default class AuthClient { email: string, newPassword: string, headers?: Headers - ): Promise { - const newCredentials = await crypto.getCredentials(email, newPassword); + ): Promise<{ passwordCreated: number; authPW: string; unwrapBKey: string }> { + const { authPW, unwrapBKey } = await crypto.getCredentials( + email, + newPassword + ); const payload = { - authPW: newCredentials.authPW, + authPW, }; - return this.sessionPost('/password/create', sessionToken, payload, headers); + const passwordCreated = await this.sessionPost( + '/password/create', + sessionToken, + payload, + headers + ); + + return { + passwordCreated, + authPW, + unwrapBKey, + }; } async getRandomBytes(headers?: Headers) { diff --git a/packages/fxa-content-server/server/lib/routes/react-app/index.js b/packages/fxa-content-server/server/lib/routes/react-app/index.js index b6e8bc9b1a3..94304670f2a 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/index.js @@ -128,7 +128,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => { 'post_verify/third_party_auth/callback', 'post_verify/third_party_auth/set_password', ]), - fullProdRollout: false, + fullProdRollout: true, }, webChannelExampleRoutes: { diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 036ca21b40d..dc8d3d3507f 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -79,6 +79,7 @@ import SignupConfirmed from '../../pages/Signup/SignupConfirmed'; import WebChannelExample from '../../pages/WebChannelExample'; import SignoutSync from '../Settings/SignoutSync'; import InlineRecoveryKeySetupContainer from '../../pages/InlineRecoveryKeySetup/container'; +import SetPasswordContainer from '../../pages/PostVerify/SetPassword/container'; const Settings = lazy(() => import('../Settings')); @@ -318,6 +319,10 @@ const AuthAndAccountSetupRoutes = ({ path="/post_verify/third_party_auth/callback/*" {...{ flowQueryParams }} /> + {/* Reset password */} { @@ -75,6 +76,7 @@ export const FormPasswordWithBalloons = ({ loading, children, disableButtonUntilValid = false, + submitButtonGleanId, }: FormPasswordWithBalloonsProps) => { const passwordValidator = new PasswordValidator(email); const [passwordMatchErrorText, setPasswordMatchErrorText] = @@ -394,6 +396,7 @@ export const FormPasswordWithBalloons = ({ disabled={ loading || (!formState.isValid && disableButtonUntilValid) } + data-glean-id={submitButtonGleanId && submitButtonGleanId} > {templateValues.buttonText} diff --git a/packages/fxa-settings/src/components/FormSetupAccount/en.ftl b/packages/fxa-settings/src/components/FormSetupAccount/en.ftl new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/fxa-settings/src/components/FormSetupAccount/index.tsx b/packages/fxa-settings/src/components/FormSetupAccount/index.tsx new file mode 100644 index 00000000000..4e67483b29e --- /dev/null +++ b/packages/fxa-settings/src/components/FormSetupAccount/index.tsx @@ -0,0 +1,139 @@ +/* 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 FormPasswordWithBalloons from '../FormPasswordWithBalloons'; +import InputText from '../InputText'; +import LinkExternal from 'fxa-react/components/LinkExternal'; +import GleanMetrics from '../../lib/glean'; +import ChooseNewsletters from '../ChooseNewsletters'; +import ChooseWhatToSync from '../ChooseWhatToSync'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { FormSetupAccountProps } from './interfaces'; +import { newsletters } from '../ChooseNewsletters/newsletters'; + +export const FormSetupAccount = ({ + formState, + errors, + trigger, + register, + getValues, + onFocus, + email, + onFocusMetricsEvent, + onSubmit, + loading, + isSync, + offeredSyncEngineConfigs, + setDeclinedSyncEngines, + isDesktopRelay, + setSelectedNewsletterSlugs, + ageCheckErrorText, + setAgeCheckErrorText, + onFocusAgeInput, + onBlurAgeInput, + submitButtonGleanId +}: FormSetupAccountProps) => { + const showCWTS = () => { + if (isSync) { + if (offeredSyncEngineConfigs) { + return ( + + ); + } else { + // Waiting to receive webchannel message from browser + return ; + } + } else { + // Display nothing if Sync flow that does not support webchannels + // or if CWTS is disabled + return <>; + } + }; + + return ( + + {setAgeCheckErrorText && + setAgeCheckErrorText && + onFocusAgeInput && + onBlurAgeInput && ( + <> + {/* TODO: original component had a SR-only label that is not straightforward to implement with existing InputText component +SR-only text: "How old are you? To learn why we ask for your age, follow the “why do we ask” link below. */} + + { + // clear error tooltip if user types in the field + if (ageCheckErrorText) { + setAgeCheckErrorText(''); + } + }} + inputRef={register({ + pattern: /^[0-9]*$/, + maxLength: 3, + required: true, + })} + onFocusCb={onFocusAgeInput} + onBlurCb={onBlurAgeInput} + errorText={ageCheckErrorText} + tooltipPosition="bottom" + anchorPosition="end" + prefixDataTestId="age" + /> + + + GleanMetrics.registration.whyWeAsk()} + > + Why do we ask? + + + + )} + + {isSync + ? showCWTS() + : !isDesktopRelay && + setSelectedNewsletterSlugs && ( + + )} + + ); +}; diff --git a/packages/fxa-settings/src/components/FormSetupAccount/interfaces.ts b/packages/fxa-settings/src/components/FormSetupAccount/interfaces.ts new file mode 100644 index 00000000000..f8a073bbfa3 --- /dev/null +++ b/packages/fxa-settings/src/components/FormSetupAccount/interfaces.ts @@ -0,0 +1,34 @@ +/* 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 { UseFormMethods } from 'react-hook-form'; +import { SetPasswordFormData } from '../../pages/PostVerify/SetPassword/interfaces'; +import { SignupFormData } from '../../pages/Signup/interfaces'; +import { syncEngineConfigs } from '../ChooseWhatToSync/sync-engines'; + +export type FormSetupAccountData = SignupFormData | SetPasswordFormData; + +export type FormSetupAccountProps = { + formState: UseFormMethods['formState']; + errors: UseFormMethods['errors']; + trigger: UseFormMethods['trigger']; + register: UseFormMethods['register']; + getValues: UseFormMethods['getValues']; + onFocus?: () => void; + email: string; + onFocusMetricsEvent?: () => void; + onSubmit: (e?: React.BaseSyntheticEvent) => Promise; + loading: boolean; + isSync: boolean; + offeredSyncEngineConfigs?: typeof syncEngineConfigs; + setDeclinedSyncEngines: React.Dispatch>; + isDesktopRelay: boolean; + setSelectedNewsletterSlugs?: React.Dispatch>; + // Age check props, if not provided it will not be rendered + ageCheckErrorText?: string; + setAgeCheckErrorText?: React.Dispatch>; + onFocusAgeInput?: () => void; + onBlurAgeInput?: () => void; + submitButtonGleanId?: string; +}; diff --git a/packages/fxa-settings/src/lib/glean/index.ts b/packages/fxa-settings/src/lib/glean/index.ts index 010039c9145..478e2626012 100644 --- a/packages/fxa-settings/src/lib/glean/index.ts +++ b/packages/fxa-settings/src/lib/glean/index.ts @@ -31,6 +31,7 @@ import * as accountPref from 'fxa-shared/metrics/glean/web/accountPref'; import * as accountBanner from 'fxa-shared/metrics/glean/web/accountBanner'; import * as deleteAccount from 'fxa-shared/metrics/glean/web/deleteAccount'; import * as thirdPartyAuth from 'fxa-shared/metrics/glean/web/thirdPartyAuth'; +import * as thirdPartyAuthSetPassword from 'fxa-shared/metrics/glean/web/thirdPartyAuthSetPassword'; import { userIdSha256, userId } from 'fxa-shared/metrics/glean/web/account'; import { oauthClientId, @@ -182,6 +183,11 @@ const populateMetrics = async (gleanPingMetrics: GleanPingMetrics) => { } } + // Initial cwts values will be included not only in the cwtsEngage event, + // but also in subsequent events (sucha as page load events). This is because there was no suitable data type for an + // event's extra keys that worked for both string and event metrics. + // It should be noted that the user may change their sync settings after the initial cwtsEngage event + // but the new settings will not be reflected in the glean pings. if (gleanPingMetrics?.sync?.cwts) { Object.entries(gleanPingMetrics.sync.cwts).forEach(([k, v]) => { sync.cwts[k].set(v); @@ -517,6 +523,9 @@ const recordEventMetric = ( reason: gleanPingMetrics?.event?.['reason'] || '', }); break; + case 'third_party_auth_set_password_success': + thirdPartyAuthSetPassword.success.record(); + break; } }; diff --git a/packages/fxa-settings/src/lib/hooks/useSyncEngines/index.tsx b/packages/fxa-settings/src/lib/hooks/useSyncEngines/index.tsx new file mode 100644 index 00000000000..f5078c545e0 --- /dev/null +++ b/packages/fxa-settings/src/lib/hooks/useSyncEngines/index.tsx @@ -0,0 +1,117 @@ +/* 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 { useEffect, useMemo, useState } from 'react'; +import { + Integration, + isOAuthIntegration, + isSyncDesktopV3Integration, +} from '../../../models'; +import { + defaultDesktopV3SyncEngineConfigs, + getSyncEngineIds, + syncEngineConfigs, + webChannelDesktopV3EngineConfigs, +} from '../../../components/ChooseWhatToSync/sync-engines'; +import firefox from '../../channels/firefox'; +import { Constants } from '../../constants'; + +type SyncEnginesIntegration = Pick; + +export function useSyncEngines(integration: SyncEnginesIntegration) { + const isSyncOAuth = isOAuthIntegration(integration) && integration.isSync(); + const isSyncDesktopV3 = isSyncDesktopV3Integration(integration); + const isSync = integration.isSync(); + + const [webChannelEngines, setWebChannelEngines] = useState< + string[] | undefined + >(); + const [offeredSyncEngineConfigs, setOfferedSyncEngineConfigs] = useState< + typeof syncEngineConfigs | undefined + >(); + const [declinedSyncEngines, setDeclinedSyncEngines] = useState([]); + + useEffect(() => { + // This sends a web channel message to the browser to prompt a response + // that we listen for. + // TODO: In content-server, we send this on app-start for all integration types. + // Do we want to move this somewhere else once the index page is Reactified? + if (isSync) { + (async () => { + const status = await firefox.fxaStatus({ + // TODO: Improve getting 'context', probably set this on the integration + context: isSyncDesktopV3 + ? Constants.FX_DESKTOP_V3_CONTEXT + : Constants.OAUTH_CONTEXT, + isPairing: false, + service: Constants.SYNC_SERVICE, + }); + if (!webChannelEngines && status.capabilities.engines) { + // choose_what_to_sync may be disabled for mobile sync, see: + // https://github.com/mozilla/application-services/issues/1761 + // Desktop OAuth Sync will always provide this capability too + // for consistency. + if ( + isSyncDesktopV3 || + (isSyncOAuth && status.capabilities.choose_what_to_sync) + ) { + setWebChannelEngines(status.capabilities.engines); + } + } + })(); + } + }, [isSync, isSyncDesktopV3, isSyncOAuth, webChannelEngines]); + + useEffect(() => { + if (webChannelEngines) { + if (isSyncDesktopV3) { + // Desktop v3 web channel message sends additional engines + setOfferedSyncEngineConfigs([ + ...defaultDesktopV3SyncEngineConfigs, + ...webChannelDesktopV3EngineConfigs.filter((engine) => + webChannelEngines.includes(engine.id) + ), + ]); + } else if (isSyncOAuth) { + // OAuth Webchannel context sends all engines + setOfferedSyncEngineConfigs( + syncEngineConfigs.filter((engine) => + webChannelEngines.includes(engine.id) + ) + ); + } + } + }, [isSyncDesktopV3, isSyncOAuth, webChannelEngines]); + + useEffect(() => { + if (offeredSyncEngineConfigs) { + const defaultDeclinedSyncEngines = offeredSyncEngineConfigs + .filter((engineConfig) => !engineConfig.defaultChecked) + .map((engineConfig) => engineConfig.id); + setDeclinedSyncEngines(defaultDeclinedSyncEngines); + } + }, [offeredSyncEngineConfigs, setDeclinedSyncEngines]); + + const offeredSyncEngines = getSyncEngineIds(offeredSyncEngineConfigs || []); + + const selectedEngines = useMemo(() => { + if (isSync) { + return offeredSyncEngines.reduce((acc, syncEngId) => { + acc[syncEngId] = !declinedSyncEngines.includes(syncEngId); + return acc; + }, {} as Record); + } + return {}; + }, [isSync, declinedSyncEngines, offeredSyncEngines]); + + return { + offeredSyncEngines, + offeredSyncEngineConfigs, + declinedSyncEngines, + setDeclinedSyncEngines, + selectedEngines, + }; +} + +export default useSyncEngines; diff --git a/packages/fxa-settings/src/lib/hooks/useSyncEngines/mocks.tsx b/packages/fxa-settings/src/lib/hooks/useSyncEngines/mocks.tsx new file mode 100644 index 00000000000..dc69a951a95 --- /dev/null +++ b/packages/fxa-settings/src/lib/hooks/useSyncEngines/mocks.tsx @@ -0,0 +1,29 @@ +/* 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 { useState } from 'react'; +import { + getSyncEngineIds, + syncEngineConfigs, +} from '../../../components/ChooseWhatToSync/sync-engines'; + +export function useMockSyncEngines() { + const [declinedSyncEngines, setDeclinedSyncEngines] = useState([]); + const offeredSyncEngines = getSyncEngineIds(syncEngineConfigs); + + const selectedEngines = offeredSyncEngines.reduce((acc, syncEngId) => { + acc[syncEngId] = !declinedSyncEngines.includes(syncEngId); + return acc; + }, {} as Record); + + return { + offeredSyncEngines, + offeredSyncEngineConfigs: syncEngineConfigs, + declinedSyncEngines, + setDeclinedSyncEngines, + selectedEngines, + }; +} + +export default useMockSyncEngines; diff --git a/packages/fxa-settings/src/lib/storage-utils.ts b/packages/fxa-settings/src/lib/storage-utils.ts index 0c02403befe..c9d658d5fb1 100644 --- a/packages/fxa-settings/src/lib/storage-utils.ts +++ b/packages/fxa-settings/src/lib/storage-utils.ts @@ -101,3 +101,10 @@ export function storeAccountData(accountData: StoredAccountData) { setCurrentAccount(accountData.uid); sessionToken(accountData.sessionToken); // Can we remove this? It seems unnecessary... } + +export function getCurrentAccountData(): StoredAccountData { + const storage = localStorage(); + const uid = storage.get('currentAccountUid'); + let accounts = storage.get('accounts') || {}; + return accounts[uid]; +} diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 8058edb3037..97b3b488635 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -540,7 +540,7 @@ export class Account implements AccountData { } async createPassword(newPassword: string) { - const passwordCreated = await this.withLoadingStatus( + const passwordCreatedResult = await this.withLoadingStatus( this.authClient.createPassword( sessionToken()!, this.primaryEmail.email, @@ -552,7 +552,7 @@ export class Account implements AccountData { id: cache.identify({ __typename: 'Account' }), fields: { passwordCreated() { - return passwordCreated; + return passwordCreatedResult.passwordCreated; }, }, }); diff --git a/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.test.tsx b/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.test.tsx index 118f82c61c4..f34564af7b1 100644 --- a/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.test.tsx +++ b/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.test.tsx @@ -8,17 +8,19 @@ import InlineRecoveryKeySetupContainer from './container'; import * as InlineRecoveryKeySetupModule from '.'; import * as ModelsModule from '../../models'; import * as utils from 'fxa-react/lib/utils'; +import * as CacheModule from '../../lib/cache'; import AuthClient from 'fxa-auth-client/browser'; import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../models/mocks'; import { - MOCK_EMAIL, - MOCK_UID, MOCK_SESSION_TOKEN, MOCK_UNWRAP_BKEY, MOCK_AUTH_PW, + MOCK_STORED_ACCOUNT, } from '../../pages/mocks'; import { AUTH_DATA_KEY } from '../../lib/sensitive-data-client'; import { InlineRecoveryKeySetupProps } from './interfaces'; +import { MOCK_EMAIL } from '../InlineTotpSetup/mocks'; +import { LocationProvider } from '@reach/router'; jest.mock('../../models', () => ({ ...jest.requireActual('../../models'), @@ -59,34 +61,26 @@ function mockModelsModule() { }); } +// Call this when testing local storage +function mockCurrentAccount( + storedAccount = { + uid: '123', + sessionToken: MOCK_SESSION_TOKEN, + email: MOCK_EMAIL, + } +) { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount); + jest.spyOn(CacheModule, 'discardSessionToken'); +} + function applyDefaultMocks() { jest.resetAllMocks(); jest.restoreAllMocks(); mockModelsModule(); mockInlineRecoveryKeySetupModule(); - mockLocationState = { - email: MOCK_EMAIL, - uid: MOCK_UID, - sessionToken: MOCK_SESSION_TOKEN, - unwrapBKey: MOCK_UNWRAP_BKEY, - }; + mockCurrentAccount(MOCK_STORED_ACCOUNT); } -let mockLocationState = {}; -const mockLocation = () => { - return { - pathname: '/inline_recovery_key_setup', - state: mockLocationState, - }; -}; -jest.mock('@reach/router', () => { - return { - __esModule: true, - ...jest.requireActual('@reach/router'), - useLocation: () => mockLocation(), - }; -}); - let currentProps: InlineRecoveryKeySetupProps | undefined; function mockInlineRecoveryKeySetupModule() { currentProps = undefined; @@ -103,13 +97,22 @@ describe('InlineRecoveryKeySetupContainer', () => { applyDefaultMocks(); }); - it('navigates to CAD when location state values are missing', () => { + it('navigates to CAD when local storage values are missing', () => { let hardNavigateSpy: jest.SpyInstance; hardNavigateSpy = jest .spyOn(utils, 'hardNavigate') .mockImplementation(() => {}); - mockLocationState = {}; - render(); + const storedAccount = { + ...MOCK_STORED_ACCOUNT, + email: '', + }; + mockCurrentAccount(storedAccount); + + render( + + + + ); expect(hardNavigateSpy).toHaveBeenCalledWith( '/pair?showSuccessMessage=true' @@ -118,13 +121,21 @@ describe('InlineRecoveryKeySetupContainer', () => { }); it('gets data from sensitive data client, renders component', async () => { - render(); + render( + + + + ); expect(mockSensitiveDataClient.getData).toHaveBeenCalledWith(AUTH_DATA_KEY); expect(InlineRecoveryKeySetupModule.default).toBeCalled(); }); it('createRecoveryKey calls expected authClient methods', async () => { - render(); + render( + + + + ); expect(currentProps).toBeDefined(); await currentProps?.createRecoveryKeyHandler(); @@ -139,7 +150,11 @@ describe('InlineRecoveryKeySetupContainer', () => { }); it('updateRecoveryHint calls authClient', async () => { - render(); + render( + + + + ); expect(currentProps).toBeDefined(); await currentProps?.updateRecoveryHintHandler('take the hint'); diff --git a/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.tsx b/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.tsx index 5d46875be60..3673b424f3c 100644 --- a/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.tsx +++ b/packages/fxa-settings/src/pages/InlineRecoveryKeySetup/container.tsx @@ -10,8 +10,7 @@ import { } from '../../models'; import { RouteComponentProps, useLocation } from '@reach/router'; import InlineRecoveryKeySetup from '.'; -import { SigninLocationState } from '../Signin/interfaces'; -import { cache } from '../../lib/cache'; +import { cache, currentAccount } from '../../lib/cache'; import { generateRecoveryKey } from 'fxa-auth-client/browser'; import { CreateRecoveryKeyHandler } from './interfaces'; import { AUTH_DATA_KEY } from '../../lib/sensitive-data-client'; @@ -25,10 +24,11 @@ export const InlineRecoveryKeySetupContainer = (_: RouteComponentProps) => { const ftlMsgResolver = useFtlMsgResolver(); const authClient = useAuthClient(); - const location = useLocation() as ReturnType & { - state?: SigninLocationState; - }; - const { email, uid, sessionToken } = location.state || {}; + const location = useLocation(); + const storedLocalAccount = currentAccount(); + const email = storedLocalAccount?.email; + const sessionToken = storedLocalAccount?.sessionToken; + const uid = storedLocalAccount?.uid; const sensitiveDataClient = useSensitiveDataClient(); const sensitiveData = sensitiveDataClient.getData(AUTH_DATA_KEY); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx new file mode 100644 index 00000000000..b2e758ecc28 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx @@ -0,0 +1,256 @@ +/* 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 * as ModelsModule from '../../../models'; +import * as CacheModule from '../../../lib/cache'; +import * as SetPasswordModule from '.'; + +import AuthClient from 'fxa-auth-client/browser'; +import { + MOCK_AUTH_PW, + MOCK_EMAIL, + MOCK_KEY_FETCH_TOKEN, + MOCK_OAUTH_FLOW_HANDLER_RESPONSE, + MOCK_PASSWORD, + MOCK_SESSION_TOKEN, + MOCK_STORED_ACCOUNT, + MOCK_UID, + MOCK_UNWRAP_BKEY, +} from '../../mocks'; +import { SetPasswordProps } from './interfaces'; +import { LocationProvider } from '@reach/router'; +import SetPasswordContainer from './container'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../../models/mocks'; +import { act } from '@testing-library/react'; +import { AUTH_DATA_KEY } from '../../../lib/sensitive-data-client'; +import { + getSyncEngineIds, + syncEngineConfigs, +} from '../../../components/ChooseWhatToSync/sync-engines'; +import { + useFinishOAuthFlowHandler, + useOAuthKeysCheck, +} from '../../../lib/oauth/hooks'; +import firefox from '../../../lib/channels/firefox'; + +jest.mock('../../../models', () => ({ + ...jest.requireActual('../../../models'), + useAuthClient: jest.fn(), + useSensitiveDataClient: jest.fn(), +})); +const mockAuthClient = new AuthClient('http://localhost:9000', { + keyStretchVersion: 1, +}); +jest.mock('../../../lib/oauth/hooks.tsx', () => { + return { + __esModule: true, + useFinishOAuthFlowHandler: jest.fn(), + useOAuthKeysCheck: jest.fn(), + }; +}); +jest.mock('../../../lib/hooks/useSyncEngines', () => { + const useMockSyncEngines = + require('../../../lib/hooks/useSyncEngines/mocks').default; + return { + __esModule: true, + default: useMockSyncEngines, + }; +}); + +const mockSensitiveDataClient = createMockSensitiveDataClient(); +mockSensitiveDataClient.setData = jest.fn(); + +const mockNavigate = jest.fn(); +jest.mock('@reach/router', () => ({ + ...jest.requireActual('@reach/router'), + navigate: jest.fn(), + useNavigate: () => mockNavigate, +})); +function mockModelsModule() { + mockAuthClient.createPassword = jest.fn().mockResolvedValue({ + passwordCreated: 123456, + authPW: MOCK_AUTH_PW, + unwrapBKey: MOCK_UNWRAP_BKEY, + }); + mockAuthClient.sessionReauthWithAuthPW = jest + .fn() + .mockResolvedValue({ keyFetchToken: MOCK_KEY_FETCH_TOKEN }); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( + () => mockAuthClient + ); + (ModelsModule.useSensitiveDataClient as jest.Mock).mockImplementation( + () => mockSensitiveDataClient + ); + (useOAuthKeysCheck as jest.Mock).mockImplementation(() => ({ + oAuthKeysCheckError: null, + })); +} +// Call this when testing local storage +function mockCurrentAccount( + storedAccount = { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + email: MOCK_EMAIL, + } +) { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount); +} + +let currentSetPasswordProps: SetPasswordProps | undefined; +function mockInlineRecoveryKeySetupModule() { + jest + .spyOn(SetPasswordModule, 'default') + .mockImplementation((props: SetPasswordProps) => { + currentSetPasswordProps = props; + return
set password mock
; + }); +} + +function applyDefaultMocks() { + jest.resetAllMocks(); + jest.restoreAllMocks(); + mockModelsModule(); + mockInlineRecoveryKeySetupModule(); + mockCurrentAccount(MOCK_STORED_ACCOUNT); + (useFinishOAuthFlowHandler as jest.Mock).mockImplementation(() => ({ + finishOAuthFlowHandler: jest + .fn() + .mockReturnValueOnce(MOCK_OAUTH_FLOW_HANDLER_RESPONSE), + oAuthDataError: null, + })); +} + +function render(integration = mockSyncDesktopV3Integration()) { + renderWithLocalizationProvider( + + + + ); +} +function mockSyncDesktopV3Integration() { + return { + type: ModelsModule.IntegrationType.SyncDesktopV3, + getService: () => 'sync', + getClientId: () => undefined, + isSync: () => true, + wantsKeys: () => true, + data: { service: 'sync' }, + isDesktopSync: () => true, + isDesktopRelay: () => false, + } as ModelsModule.Integration; +} +function mockOAuthNativeIntegration() { + return { + type: ModelsModule.IntegrationType.OAuthNative, + getService: () => 'sync', + getClientId: () => undefined, + isSync: () => true, + wantsKeys: () => true, + data: { service: 'sync' }, + isDesktopSync: () => true, + isDesktopRelay: () => false, + } as ModelsModule.Integration; +} + +describe('SetPassword container', () => { + beforeEach(() => { + applyDefaultMocks(); + }); + + it('navigates to signin when local storage values are missing', async () => { + const storedAccount = { + ...MOCK_STORED_ACCOUNT, + email: '', + }; + mockCurrentAccount(storedAccount); + + render(); + expect(mockNavigate).toHaveBeenCalledWith('/signin', { replace: true }); + expect(SetPasswordModule.default).not.toBeCalled(); + }); + + it('renders the component when local storage values are present', async () => { + render(); + expect(mockNavigate).not.toBeCalled(); + expect(SetPasswordModule.default).toBeCalled(); + expect(currentSetPasswordProps).toBeDefined(); + }); + + describe('calling createPassword', () => { + let fxaLoginSpy: jest.SpyInstance; + let fxaOAuthLoginSpy: jest.SpyInstance; + beforeEach(() => { + fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin'); + fxaOAuthLoginSpy = jest.spyOn(firefox, 'fxaOAuthLogin'); + }); + + it('does the expected things with desktop v3', async () => { + render(); + + expect(currentSetPasswordProps?.createPasswordHandler).toBeDefined(); + await act(async () => { + await currentSetPasswordProps?.createPasswordHandler(MOCK_PASSWORD); + }); + expect(mockSensitiveDataClient.setData).toBeCalledWith(AUTH_DATA_KEY, { + authPW: MOCK_AUTH_PW, + emailForAuth: MOCK_EMAIL, + unwrapBKey: MOCK_UNWRAP_BKEY, + }); + expect(mockAuthClient.sessionReauthWithAuthPW).toBeCalledWith( + MOCK_SESSION_TOKEN, + MOCK_EMAIL, + MOCK_AUTH_PW, + { + keys: true, + reason: 'signin', + } + ); + expect(fxaLoginSpy).toBeCalledWith({ + email: MOCK_EMAIL, + sessionToken: MOCK_SESSION_TOKEN, + uid: MOCK_UID, + verified: true, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + unwrapBKey: MOCK_UNWRAP_BKEY, + services: { + sync: { + offeredEngines: getSyncEngineIds(syncEngineConfigs), + declinedEngines: [], + }, + }, + }); + expect(fxaOAuthLoginSpy).not.toBeCalled(); + }); + + it('does the expected things with oauth native', async () => { + render(mockOAuthNativeIntegration()); + + expect(currentSetPasswordProps?.createPasswordHandler).toBeDefined(); + await act(async () => { + await currentSetPasswordProps?.createPasswordHandler(MOCK_PASSWORD); + }); + expect(fxaLoginSpy).toBeCalledWith({ + email: MOCK_EMAIL, + sessionToken: MOCK_SESSION_TOKEN, + uid: MOCK_UID, + verified: true, + services: { + sync: { + offeredEngines: getSyncEngineIds(syncEngineConfigs), + declinedEngines: [], + }, + }, + }); + expect(firefox.fxaOAuthLogin).toBeCalledWith({ + action: 'signin', + ...MOCK_OAUTH_FLOW_HANDLER_RESPONSE, + }); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx new file mode 100644 index 00000000000..0a41649c89a --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx @@ -0,0 +1,172 @@ +/* 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 { RouteComponentProps } from '@reach/router'; +import SetPassword from '.'; +import { currentAccount } from '../../../lib/cache'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; +import { + Integration, + useAuthClient, + useSensitiveDataClient, +} from '../../../models'; +import { cache } from '../../../lib/cache'; +import { useCallback } from 'react'; +import { CreatePasswordHandler } from './interfaces'; +import { HandledError } from '../../../lib/error-utils'; +import { + AuthUiErrorNos, + AuthUiErrors, +} from '../../../lib/auth-errors/auth-errors'; +import useSyncEngines from '../../../lib/hooks/useSyncEngines'; +import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; +import OAuthDataError from '../../../components/OAuthDataError'; +import { AUTH_DATA_KEY } from '../../../lib/sensitive-data-client'; +import { NavigationOptions } from '../../Signin/interfaces'; +import { handleNavigation } from '../../Signin/utils'; +import GleanMetrics from '../../../lib/glean'; + +const SetPasswordContainer = ({ + integration, +}: { integration: Integration } & RouteComponentProps) => { + const navigate = useNavigate(); + const authClient = useAuthClient(); + const storedLocalAccount = currentAccount(); + const email = storedLocalAccount?.email; + const sessionToken = storedLocalAccount?.sessionToken; + const uid = storedLocalAccount?.uid; + + const { + offeredSyncEngines, + offeredSyncEngineConfigs, + declinedSyncEngines, + setDeclinedSyncEngines, + selectedEngines, + } = useSyncEngines(integration); + const sensitiveDataClient = useSensitiveDataClient(); + + const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( + authClient, + integration + ); + + const getKeyFetchToken = useCallback( + async (authPW: string, email: string, sessionToken: string) => { + // We must reauth for another `keyFetchToken` because it was used in + // the oauth flow + const { keyFetchToken } = await authClient.sessionReauthWithAuthPW( + sessionToken, + email, + authPW, + { + keys: true, + reason: 'signin', + } + ); + return keyFetchToken; + }, + [authClient] + ); + + const createPassword = useCallback( + (uid: string, email: string, sessionToken: string): CreatePasswordHandler => + async (newPassword: string) => { + try { + const { passwordCreated, authPW, unwrapBKey } = + await authClient.createPassword(sessionToken, email, newPassword); + cache.modify({ + id: cache.identify({ __typename: 'Account' }), + fields: { + passwordCreated() { + return passwordCreated; + }, + }, + }); + + sensitiveDataClient.setData(AUTH_DATA_KEY, { + // Store for inline recovery key flow + authPW, + emailForAuth: email, + unwrapBKey, + }); + + const keyFetchToken = await getKeyFetchToken( + authPW, + email, + sessionToken + ); + + GleanMetrics.thirdPartyAuthSetPassword.success({ + sync: { cwts: selectedEngines }, + }); + + const navigationOptions: NavigationOptions = { + email, + signinData: { + uid, + sessionToken, + verified: true, + keyFetchToken, + }, + unwrapBKey, + integration, + finishOAuthFlowHandler, + queryParams: '', + handleFxaLogin: true, + handleFxaOAuthLogin: true, + showInlineRecoveryKeySetup: true, + syncEngines: { + offeredEngines: offeredSyncEngines, + declinedEngines: declinedSyncEngines, + }, + }; + + const { error } = await handleNavigation(navigationOptions); + return { error }; + } catch (error) { + const { errno } = error as HandledError; + if (errno && AuthUiErrorNos[errno]) { + return { error }; + } + return { error: AuthUiErrors.UNEXPECTED_ERROR as HandledError }; + } + }, + [ + authClient, + declinedSyncEngines, + integration, + finishOAuthFlowHandler, + getKeyFetchToken, + offeredSyncEngines, + selectedEngines, + sensitiveDataClient, + ] + ); + + // Users must be already authenticated on this page. + // This page is currently always for the Sync flow. + if (!email || !sessionToken || !uid || !integration.isSync()) { + navigate('/signin', { replace: true }); + return ; + } + if (oAuthDataError) { + return ; + } + // Curry already checked values + const createPasswordHandler = createPassword(uid, email, sessionToken); + + return ( + + ); +}; + +export default SetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/en.ftl b/packages/fxa-settings/src/pages/PostVerify/SetPassword/en.ftl new file mode 100644 index 00000000000..405f9c0f524 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/en.ftl @@ -0,0 +1,6 @@ +## SetPassword page +## Third party auth users that do not have a password set yet are prompted for a +## password to complete their sign-in when they want to login to a service requiring it. + +set-password-heading = Create password +set-password-info = Your sync data is encrypted with your password to protect your privacy. diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.stories.tsx new file mode 100644 index 00000000000..3cf59de93d5 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.stories.tsx @@ -0,0 +1,25 @@ +/* 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 { withLocalization } from 'fxa-react/lib/storybooks'; +import SetPassword from '.'; +import { Meta } from '@storybook/react'; +import { SetPasswordProps } from './interfaces'; +import { Subject } from './mocks'; + +export default { + title: 'Pages/PostVerify/SetPassword', + component: SetPassword, + decorators: [withLocalization], +} as Meta; + +const storyWithProps = ({ + ...props // overrides +}: Partial = {}) => { + const story = () => ; + return story; +}; + +export const Default = storyWithProps(); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx new file mode 100644 index 00000000000..e36979cf162 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.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 { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { Subject } from './mocks'; +import { screen, waitFor } from '@testing-library/react'; +import { MOCK_EMAIL } from '../../mocks'; + +describe('SetPassword page', () => { + it('renders as expected', async () => { + renderWithLocalizationProvider(); + + screen.getByRole('heading', { name: 'Create password' }); + screen.getByText(MOCK_EMAIL); + screen.getByText( + 'Your sync data is encrypted with your password to protect your privacy.' + ); + await waitFor(() => { + screen.getByText('Choose what to sync'); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx new file mode 100644 index 00000000000..b3909776b6a --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx @@ -0,0 +1,100 @@ +/* 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 { FormSetupAccount } from '../../../components/FormSetupAccount'; +import { SetPasswordFormData, SetPasswordProps } from './interfaces'; +import { useForm } from 'react-hook-form'; +import { useCallback, useState } from 'react'; +import { useFtlMsgResolver } from '../../../models'; +import { getLocalizedErrorMessage } from '../../../lib/error-utils'; +import Banner from '../../../components/Banner'; + +export const SetPassword = ({ + email, + createPasswordHandler, + offeredSyncEngineConfigs, + setDeclinedSyncEngines, +}: SetPasswordProps) => { + const ftlMsgResolver = useFtlMsgResolver(); + const [createPasswordLoading, setCreatePasswordLoading] = + useState(false); + const [bannerErrorText, setBannerErrorText] = useState(''); + + const onSubmit = useCallback( + async ({ newPassword }: SetPasswordFormData) => { + setCreatePasswordLoading(true); + setBannerErrorText(''); + + const { error } = await createPasswordHandler(newPassword); + + if (error) { + const localizedErrorMessage = getLocalizedErrorMessage( + ftlMsgResolver, + error + ); + setBannerErrorText(localizedErrorMessage); + // if the request errored, loading state must be marked as false to reenable submission + setCreatePasswordLoading(false); + return; + } + }, + [createPasswordHandler, ftlMsgResolver] + ); + + const { handleSubmit, register, getValues, errors, formState, trigger } = + useForm({ + mode: 'onChange', + criteriaMode: 'all', + defaultValues: { + email, + newPassword: '', + confirmPassword: '', + }, + }); + + return ( + + +

Create password

+
+

{email}

+ + {bannerErrorText && ( + + )} + + +

+ Your sync data is encrypted with your password to protect your + privacy. +

+
+ + +
+ ); +}; + +export default SetPassword; diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts new file mode 100644 index 00000000000..10594aeaf55 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts @@ -0,0 +1,27 @@ +/* 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 { syncEngineConfigs } from '../../../components/ChooseWhatToSync/sync-engines'; +import { HandledError } from '../../../lib/error-utils'; + +export interface SetPasswordFormData { + email: string; + newPassword: string; + confirmPassword: string; +} + +export interface CreatePasswordHandlerError { + error: HandledError | null; +} + +export type CreatePasswordHandler = ( + newPassword: string +) => Promise; + +export interface SetPasswordProps { + email: string; + createPasswordHandler: CreatePasswordHandler; + offeredSyncEngineConfigs?: typeof syncEngineConfigs; + setDeclinedSyncEngines: React.Dispatch>; +} diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx new file mode 100644 index 00000000000..6c066d92fb9 --- /dev/null +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx @@ -0,0 +1,33 @@ +/* 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 SetPassword from '.'; +import { LocationProvider } from '@reach/router'; +import { CreatePasswordHandler } from './interfaces'; +import { MOCK_EMAIL } from '../../mocks'; +import { useMockSyncEngines } from '../../../lib/hooks/useSyncEngines/mocks'; + +export const Subject = ({ + email = MOCK_EMAIL, + createPasswordHandler = () => Promise.resolve({ error: null }), +}: { + email?: string; + createPasswordHandler?: CreatePasswordHandler; +}) => { + const { offeredSyncEngineConfigs, setDeclinedSyncEngines } = + useMockSyncEngines(); + return ( + + + + ); +}; diff --git a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx index c383a2311f0..c7b7d7e2f9c 100644 --- a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx @@ -90,12 +90,12 @@ const ThirdPartyAuthCallback = ({ integration, finishOAuthFlowHandler, queryParams: location.search, - }; - - const { error: navError } = await handleNavigation(navigationOptions, { + isSignInWithThirdPartyAuth: true, handleFxaLogin: false, handleFxaOAuthLogin: false, - }); + }; + + const { error: navError } = await handleNavigation(navigationOptions); if (navError) { // TODO validate what should happen here diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx index 0e5d3515475..92b4e9b7266 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx @@ -84,12 +84,11 @@ const SigninRecoveryCode = ({ finishOAuthFlowHandler, redirectTo, queryParams: location.search, - }; - - const { error } = await handleNavigation(navigationOptions, { handleFxaLogin: true, handleFxaOAuthLogin: true, - }); + }; + + const { error } = await handleNavigation(navigationOptions); if (error) { setBannerErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, error)); } diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx index c7341211439..b1b3ceba13a 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx @@ -149,14 +149,13 @@ const SigninTokenCode = ({ queryParams: location.search, redirectTo, showInlineRecoveryKeySetup, + handleFxaLogin: false, + handleFxaOAuthLogin: true, }; await GleanMetrics.isDone(); - const { error: navError } = await handleNavigation(navigationOptions, { - handleFxaLogin: false, - handleFxaOAuthLogin: true, - }); + const { error: navError } = await handleNavigation(navigationOptions); if (navError) { setLocalizedErrorBannerMessage( getLocalizedErrorMessage(ftlMsgResolver, navError) diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx index 5a074fa81c2..01986426e9c 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx @@ -130,12 +130,11 @@ export const SigninTotpCode = ({ redirectTo, queryParams: location.search, showInlineRecoveryKeySetup, - }; - - const { error } = await handleNavigation(navigationOptions, { handleFxaLogin: true, handleFxaOAuthLogin: true, - }); + }; + + const { error } = await handleNavigation(navigationOptions); if (error) { setBannerError(getLocalizedErrorMessage(ftlMsgResolver, error)); } diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx index 4f7a3040dc2..163794afead 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx @@ -127,12 +127,11 @@ export const SigninUnblock = ({ integration, finishOAuthFlowHandler, queryParams: location.search, - }; - - const { error: navError } = await handleNavigation(navigationOptions, { handleFxaLogin: true, handleFxaOAuthLogin: true, - }); + }; + + const { error: navError } = await handleNavigation(navigationOptions); if (navError) { setBannerErrorMessage( getLocalizedErrorMessage(ftlMsgResolver, navError) diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 57fe4912807..ddce30fae14 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -213,12 +213,11 @@ const Signin = ({ : '', queryParams: location.search, showInlineRecoveryKeySetup: data.showInlineRecoveryKeySetup, - }; - - const { error: navError } = await handleNavigation(navigationOptions, { handleFxaLogin: true, handleFxaOAuthLogin: true, - }); + }; + + const { error: navError } = await handleNavigation(navigationOptions); if (navError) { setLocalizedBannerError( getLocalizedErrorMessage(ftlMsgResolver, navError) diff --git a/packages/fxa-settings/src/pages/Signin/interfaces.ts b/packages/fxa-settings/src/pages/Signin/interfaces.ts index c3813ff8a39..985e5351311 100644 --- a/packages/fxa-settings/src/pages/Signin/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/interfaces.ts @@ -199,6 +199,13 @@ export interface NavigationOptions { redirectTo?: string; queryParams: string; showInlineRecoveryKeySetup?: boolean; + isSignInWithThirdPartyAuth?: boolean; + handleFxaLogin?: boolean; + handleFxaOAuthLogin?: boolean; + syncEngines?: { + offeredEngines: string[]; + declinedEngines: string[]; + } } export interface OAuthSigninResult { diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index b5e187c3a7a..fd7a1a77095 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -32,9 +32,18 @@ interface NavigationTargetError { export function getSyncNavigate( queryParams: string, - showInlineRecoveryKeySetup?: boolean + showInlineRecoveryKeySetup?: boolean, + isSignInWithThirdPartyAuth?: boolean ) { const searchParams = new URLSearchParams(queryParams); + + if (isSignInWithThirdPartyAuth) { + return { + to: `/post_verify/third_party_auth/set_password?${searchParams}`, + shouldHardNavigate: false, + }; + } + if (showInlineRecoveryKeySetup) { return { to: `/inline_recovery_key_setup?${searchParams}`, @@ -60,15 +69,10 @@ export function getSyncNavigate( // React signin until CAD/pair is converted to React because we'd need to pass this // data back to Backbone. This means temporarily we need to send the sync data up // _before_ we hard navigate to CAD/pair in these flows. -export async function handleNavigation( - navigationOptions: NavigationOptions, - { - handleFxaLogin = false, - handleFxaOAuthLogin = false, - }: { handleFxaLogin?: boolean; handleFxaOAuthLogin?: boolean } = {} -) { +export async function handleNavigation(navigationOptions: NavigationOptions) { const { integration } = navigationOptions; const isOAuth = isOAuthIntegration(integration); + console.log('isOAuth', isOAuth, integration); const isWebChannelIntegration = integration.isSync() || integration.isDesktopRelay(); @@ -77,7 +81,7 @@ export async function handleNavigation( getUnverifiedNavigationTarget(navigationOptions); if ( isWebChannelIntegration && - handleFxaLogin && + navigationOptions.handleFxaLogin === true && // If the _next page_ is `signin_totp_code`, we don't want to send this // because we end up sending it twice with the first message containing // `verified: false`, causing a Sync sign-in issue (see FXA-9837). @@ -98,7 +102,7 @@ export async function handleNavigation( return { error: undefined }; } - if (isWebChannelIntegration && handleFxaLogin) { + if (isWebChannelIntegration && navigationOptions.handleFxaLogin === true) { // This _must_ be sent before fxaOAuthLogin for Desktop OAuth flow. // Mobile doesn't care about this message (see FXA-10388) sendFxaLogin(navigationOptions); @@ -122,7 +126,7 @@ export async function handleNavigation( } if ( isOAuthNativeIntegration(integration) && - handleFxaOAuthLogin && + navigationOptions.handleFxaOAuthLogin === true && oauthData ) { firefox.fxaOAuthLogin({ @@ -179,7 +183,7 @@ function sendFxaLogin(navigationOptions: NavigationOptions) { }), services: navigationOptions.integration.isDesktopRelay() ? { relay: {} } - : { sync: {} }, + : { sync: navigationOptions.syncEngines || {} }, }); } @@ -230,12 +234,20 @@ function performNavigation({ const getNonOAuthNavigationTarget = async ( navigationOptions: NavigationOptions ): Promise => { - const { integration, queryParams, showInlineRecoveryKeySetup, redirectTo } = - navigationOptions; + const { + integration, + queryParams, + showInlineRecoveryKeySetup, + redirectTo, + isSignInWithThirdPartyAuth, + } = navigationOptions; if (integration.isSync()) { return { - ...getSyncNavigate(queryParams, showInlineRecoveryKeySetup), - locationState: createSigninLocationState(navigationOptions), + ...getSyncNavigate( + queryParams, + showInlineRecoveryKeySetup, + isSignInWithThirdPartyAuth + ), }; } if (redirectTo) { @@ -248,6 +260,21 @@ const getOAuthNavigationTarget = async ( navigationOptions: NavigationOptions ): Promise => { const locationState = createSigninLocationState(navigationOptions); + + if ( + navigationOptions.integration.isSync() && + navigationOptions.isSignInWithThirdPartyAuth + ) { + return { + ...getSyncNavigate( + navigationOptions.queryParams, + locationState.showInlineRecoveryKeySetup, + navigationOptions.isSignInWithThirdPartyAuth + ), + locationState, + }; + } + const { error, redirect, code, state } = await navigationOptions.finishOAuthFlowHandler( navigationOptions.signinData.uid, @@ -273,7 +300,8 @@ const getOAuthNavigationTarget = async ( return { ...getSyncNavigate( navigationOptions.queryParams, - locationState.showInlineRecoveryKeySetup + locationState.showInlineRecoveryKeySetup, + navigationOptions.isSignInWithThirdPartyAuth ), oauthData: { code, diff --git a/packages/fxa-settings/src/pages/Signup/container.tsx b/packages/fxa-settings/src/pages/Signup/container.tsx index d2e8e407acc..f0388e1a37f 100644 --- a/packages/fxa-settings/src/pages/Signup/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.tsx @@ -4,12 +4,7 @@ import { RouteComponentProps, useLocation } from '@reach/router'; import { useNavigateWithQuery as useNavigate } from '../../lib/hooks/useNavigateWithQuery'; -import { - isOAuthIntegration, - isSyncDesktopV3Integration, - useAuthClient, - useConfig, -} from '../../models'; +import { useAuthClient, useConfig } from '../../models'; import { Signup } from '.'; import { useValidatedQueryParams } from '../../lib/hooks/useValidate'; import { SignupQueryParams } from '../../models/pages/signup'; @@ -29,8 +24,6 @@ import { getKeysV2, } from 'fxa-auth-client/lib/crypto'; import { LoadingSpinner } from 'fxa-react/components/LoadingSpinner'; -import { firefox } from '../../lib/channels/firefox'; -import { Constants } from '../../lib/constants'; import { createSaltV2 } from 'fxa-auth-client/lib/salt'; import { KeyStretchExperiment } from '../../models/experiments/key-stretch-experiment'; import { handleGQLError } from './utils'; @@ -38,6 +31,7 @@ import VerificationMethods from '../../constants/verification-methods'; import { queryParamsToMetricsContext } from '../../lib/metrics'; import { QueryParams } from '../..'; import { isFirefoxService } from '../../models/integrations/utils'; +import useSyncEngines from '../../lib/hooks/useSyncEngines'; /* * In content-server, the `email` param is optional. If it's provided, we @@ -87,16 +81,14 @@ const SignupContainer = ({ // Since we may perform an async call on initial render that can affect what is rendered, // return a spinner on first render. const [showLoadingSpinner, setShowLoadingSpinner] = useState(true); - const [webChannelEngines, setWebChannelEngines] = useState< - string[] | undefined - >(); - - const isOAuth = isOAuthIntegration(integration); - const isSyncOAuth = isOAuth && integration.isSync(); - const isSyncDesktopV3 = isSyncDesktopV3Integration(integration); - const isSync = integration.isSync(); const wantsKeys = integration.wantsKeys(); + // TODO: in PostVerify/SetPassword we call this and handle web channel messaging + // in the container compoment, but here we handle web channel messaging in the + // presentation component and we should be consistent. Calling this here allows for + // some easier mocking, especially until we can upgrade to Storybook 8. + const useSyncEnginesResult = useSyncEngines(integration); + useEffect(() => { (async () => { // Modify this once index is converted to React @@ -131,37 +123,6 @@ const SignupContainer = ({ })(); }); - useEffect(() => { - // This sends a web channel message to the browser to prompt a response - // that we listen for. - // TODO: In content-server, we send this on app-start for all integration types. - // Do we want to move this somewhere else once the index page is Reactified? - if (isSync) { - (async () => { - const status = await firefox.fxaStatus({ - // TODO: Improve getting 'context', probably set this on the integration - context: isSyncDesktopV3 - ? Constants.FX_DESKTOP_V3_CONTEXT - : Constants.OAUTH_CONTEXT, - isPairing: false, - service: Constants.SYNC_SERVICE, - }); - if (!webChannelEngines && status.capabilities.engines) { - // choose_what_to_sync may be disabled for mobile sync, see: - // https://github.com/mozilla/application-services/issues/1761 - // Desktop OAuth Sync will always provide this capability too - // for consistency. - if ( - isSyncDesktopV3 || - (isSyncOAuth && status.capabilities.choose_what_to_sync) - ) { - setWebChannelEngines(status.capabilities.engines); - } - } - })(); - } - }, [isSync, isSyncDesktopV3, isSyncOAuth, webChannelEngines]); - const [beginSignup] = useMutation(BEGIN_SIGNUP_MUTATION); const beginSignupHandler: BeginSignupHandler = useCallback( @@ -273,7 +234,7 @@ const SignupContainer = ({ integration, queryParamModel, beginSignupHandler, - webChannelEngines, + useSyncEnginesResult, }} /> ); diff --git a/packages/fxa-settings/src/pages/Signup/index.stories.tsx b/packages/fxa-settings/src/pages/Signup/index.stories.tsx index 1a1f1fa6d66..43a643f072d 100644 --- a/packages/fxa-settings/src/pages/Signup/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.stories.tsx @@ -21,8 +21,8 @@ import { MONITOR_CLIENTIDS, POCKET_CLIENTIDS, } from '../../models/integrations/client-matching'; -import { getSyncEngineIds } from '../../components/ChooseWhatToSync/sync-engines'; import { AppContext } from '../../models'; +import { useMockSyncEngines } from '../../lib/hooks/useSyncEngines/mocks'; export default { title: 'Pages/Signup', @@ -33,10 +33,14 @@ export default { const urlQueryData = mockUrlQueryData(signupQueryParams); const queryParamModel = new SignupQueryParams(urlQueryData); -const storyWithProps = ( - integration: SignupIntegration = createMockSignupOAuthWebIntegration() -) => { - const story = () => ( +const StoryWithProps = ({ + integration = createMockSignupOAuthWebIntegration(), +}: { + integration?: SignupIntegration; +}) => { + const useSyncEnginesResult = useMockSyncEngines(); + + return ( ); - return story; }; -export const Default = storyWithProps(); - -export const CantChangeEmail = storyWithProps(); - -export const ClientIsPocket = storyWithProps( - createMockSignupOAuthWebIntegration(POCKET_CLIENTIDS[0]) +export const Default = () => ; +export const CantChangeEmail = () => ; +export const ClientIsPocket = () => ( + ); - -export const ClientIsMonitor = storyWithProps( - createMockSignupOAuthWebIntegration(MONITOR_CLIENTIDS[0]) +export const ClientIsMonitor = () => ( + ); - -export const SyncDesktopV3 = storyWithProps( - createMockSignupSyncDesktopV3Integration() +export const SyncDesktopV3 = () => ( + ); - -export const SyncOAuth = storyWithProps( - createMockSignupOAuthNativeIntegration() +export const SyncOAuth = () => ( + ); - -export const OAuthDestkopServiceRelay = storyWithProps( - createMockSignupOAuthNativeIntegration('relay', false) +export const OAuthDesktopServiceRelay = () => ( + ); diff --git a/packages/fxa-settings/src/pages/Signup/index.tsx b/packages/fxa-settings/src/pages/Signup/index.tsx index 8703716b866..876d050e814 100644 --- a/packages/fxa-settings/src/pages/Signup/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.tsx @@ -4,25 +4,12 @@ import React from 'react'; import { useLocation } from '@reach/router'; -import LinkExternal from 'fxa-react/components/LinkExternal'; -import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils'; import { isEmailMask } from 'fxa-shared/email/helpers'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import AppLayout from '../../components/AppLayout'; import CardHeader from '../../components/CardHeader'; -import ChooseNewsletters from '../../components/ChooseNewsletters'; -import { newsletters } from '../../components/ChooseNewsletters/newsletters'; -import ChooseWhatToSync from '../../components/ChooseWhatToSync'; -import { - defaultDesktopV3SyncEngineConfigs, - getSyncEngineIds, - syncEngineConfigs, - webChannelDesktopV3EngineConfigs, -} from '../../components/ChooseWhatToSync/sync-engines'; -import FormPasswordWithBalloons from '../../components/FormPasswordWithBalloons'; -import InputText from '../../components/InputText'; import TermsPrivacyAgreement from '../../components/TermsPrivacyAgreement'; import ThirdPartyAuth from '../../components/ThirdPartyAuth'; import { REACT_ENTRYPOINT } from '../../constants'; @@ -41,7 +28,6 @@ import { MozServices } from '../../lib/types'; import { isOAuthIntegration, isOAuthNativeIntegrationSync, - isSyncDesktopV3Integration, useFtlMsgResolver, useSensitiveDataClient, } from '../../models'; @@ -52,6 +38,7 @@ import { import { SignupFormData, SignupProps } from './interfaces'; import Banner from '../../components/Banner'; import { AUTH_DATA_KEY } from '../../lib/sensitive-data-client'; +import { FormSetupAccount } from '../../components/FormSetupAccount'; export const viewName = 'signup'; @@ -59,7 +46,13 @@ export const Signup = ({ integration, queryParamModel, beginSignupHandler, - webChannelEngines, + useSyncEnginesResult: { + offeredSyncEngines, + offeredSyncEngineConfigs, + declinedSyncEngines, + setDeclinedSyncEngines, + selectedEngines, + }, }: SignupProps) => { const sensitiveDataClient = useSensitiveDataClient(); usePageViewEvent(viewName, REACT_ENTRYPOINT); @@ -70,7 +63,6 @@ export const Signup = ({ const isOAuth = isOAuthIntegration(integration); const isSyncOAuth = isOAuthNativeIntegrationSync(integration); - const isSyncDesktopV3 = isSyncDesktopV3Integration(integration); const isSync = integration.isSync(); const isDesktopRelay = integration.isDesktopRelay(); const email = queryParamModel.email; @@ -90,7 +82,7 @@ export const Signup = ({ ] = useState(false); const navigate = useNavigate(); const location = useLocation(); - const [declinedSyncEngines, setDeclinedSyncEngines] = useState([]); + // no newsletters are selected by default const [selectedNewsletterSlugs, setSelectedNewsletterSlugs] = useState< string[] @@ -111,40 +103,6 @@ export const Signup = ({ } }, [integration, isOAuth]); - const [offeredSyncEngineConfigs, setOfferedSyncEngineConfigs] = useState< - typeof syncEngineConfigs | undefined - >(); - - useEffect(() => { - if (webChannelEngines) { - if (isSyncDesktopV3) { - // Desktop v3 web channel message sends additional engines - setOfferedSyncEngineConfigs([ - ...defaultDesktopV3SyncEngineConfigs, - ...webChannelDesktopV3EngineConfigs.filter((engine) => - webChannelEngines.includes(engine.id) - ), - ]); - } else if (isSyncOAuth) { - // OAuth Webchannel context sends all engines - setOfferedSyncEngineConfigs( - syncEngineConfigs.filter((engine) => - webChannelEngines.includes(engine.id) - ) - ); - } - } - }, [isSyncDesktopV3, isSyncOAuth, webChannelEngines]); - - useEffect(() => { - if (offeredSyncEngineConfigs) { - const defaultDeclinedSyncEngines = offeredSyncEngineConfigs - .filter((engineConfig) => !engineConfig.defaultChecked) - .map((engineConfig) => engineConfig.id); - setDeclinedSyncEngines(defaultDeclinedSyncEngines); - } - }, [offeredSyncEngineConfigs, setDeclinedSyncEngines]); - const { handleSubmit, register, getValues, errors, formState, trigger } = useForm({ mode: 'onChange', @@ -253,22 +211,15 @@ export const Signup = ({ unwrapBKey: data.unwrapBKey, }); - const getOfferedSyncEngines = () => - getSyncEngineIds(offeredSyncEngineConfigs || []); - if (isSync) { const syncEngines = { - offeredEngines: getOfferedSyncEngines(), + offeredEngines: offeredSyncEngines, declinedEngines: declinedSyncEngines, }; - const syncOptions = syncEngines.offeredEngines.reduce( - (acc, syncEngId) => { - acc[syncEngId] = !declinedSyncEngines.includes(syncEngId); - return acc; - }, - {} as Record - ); - GleanMetrics.registration.cwts({ sync: { cwts: syncOptions } }); + GleanMetrics.registration.cwts({ + sync: { cwts: selectedEngines }, + }); + firefox.fxaLogin({ email, // Do not send these values if OAuth. Mobile doesn't care about this message, and @@ -313,9 +264,11 @@ export const Signup = ({ origin: 'signup', selectedNewsletterSlugs, // Sync desktop v3 sends a web channel message up on Signup - // while OAuth Sync does on confirm signup + // while OAuth Sync (mobile) does on confirm signup. + // Once mobile clients read this from fxaLogin to match + // oauth desktop, we can stop sending this on confirm signup code. ...(isSyncOAuth && { - offeredSyncEngines: getOfferedSyncEngines(), + offeredSyncEngines, declinedSyncEngines, }), }, @@ -340,7 +293,8 @@ export const Signup = ({ declinedSyncEngines, email, isSync, - offeredSyncEngineConfigs, + offeredSyncEngines, + selectedEngines, isSyncOAuth, localizedValidAgeError, isDesktopRelay, @@ -349,28 +303,6 @@ export const Signup = ({ ] ); - const showCWTS = () => { - if (isSync) { - if (offeredSyncEngineConfigs) { - return ( - - ); - } else { - // Waiting to receive webchannel message from browser - return ; - } - } else { - // Display nothing if Sync flow that does not support webchannels - // or if CWTS is disabled - return <>; - } - }; - return ( // TODO: FXA-8268, if force_auth && AuthErrors.is(error, 'DELETED_ACCOUNT'): // - forceMessage('Account no longer exists. Recreate it?') @@ -454,7 +386,7 @@ export const Signup = ({ - - {/* TODO: original component had a SR-only label that is not straightforward to implement with existing InputText component - SR-only text: "How old are you? To learn why we ask for your age, follow the “why do we ask” link below. */} - - { - // clear error tooltip if user types in the field - if (ageCheckErrorText) { - setAgeCheckErrorText(''); - } - }} - inputRef={register({ - pattern: /^[0-9]*$/, - maxLength: 3, - required: true, - })} - onFocusCb={onFocusAgeInput} - onBlurCb={onBlurAgeInput} - errorText={ageCheckErrorText} - tooltipPosition="bottom" - anchorPosition="end" - prefixDataTestId="age" - /> - - - GleanMetrics.registration.whyWeAsk()} - > - Why do we ask? - - - - {isSync - ? showCWTS() - : !isDesktopRelay && ( - - )} - + onSubmit={handleSubmit(onSubmit)} + /> {/* Third party auth is not currently supported for sync */} {!isSync && !isDesktopRelay && } diff --git a/packages/fxa-settings/src/pages/Signup/interfaces.ts b/packages/fxa-settings/src/pages/Signup/interfaces.ts index 955df92f394..5706d3f098e 100644 --- a/packages/fxa-settings/src/pages/Signup/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signup/interfaces.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { HandledError } from '../../lib/error-utils'; +import useSyncEngines from '../../lib/hooks/useSyncEngines'; import { BaseIntegration, OAuthIntegration } from '../../models'; import { SignupQueryParams } from '../../models/pages/signup'; import { MetricsContext } from 'fxa-auth-client/browser'; @@ -42,7 +43,7 @@ export interface SignupProps { integration: SignupIntegration; queryParamModel: SignupQueryParams; beginSignupHandler: BeginSignupHandler; - webChannelEngines: string[] | undefined; + useSyncEnginesResult: ReturnType; } export type SignupIntegration = SignupOAuthIntegration | SignupBaseIntegration; diff --git a/packages/fxa-settings/src/pages/Signup/mocks.tsx b/packages/fxa-settings/src/pages/Signup/mocks.tsx index a9667566277..5ee63202551 100644 --- a/packages/fxa-settings/src/pages/Signup/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signup/mocks.tsx @@ -24,7 +24,7 @@ import { SignupIntegration, SignupOAuthIntegration, } from './interfaces'; -import { getSyncEngineIds } from '../../components/ChooseWhatToSync/sync-engines'; +import { useMockSyncEngines } from '../../lib/hooks/useSyncEngines/mocks'; export const MOCK_SEARCH_PARAMS = { email: MOCK_EMAIL, @@ -139,6 +139,7 @@ export const Subject = ({ }) => { const urlQueryData = mockUrlQueryData(queryParams); const queryParamModel = new SignupQueryParams(urlQueryData); + const useMockSyncEnginesResult = useMockSyncEngines(); return ( diff --git a/packages/fxa-shared/metrics/glean/web/index.ts b/packages/fxa-shared/metrics/glean/web/index.ts index a19df5daec7..f75f6a4984b 100644 --- a/packages/fxa-shared/metrics/glean/web/index.ts +++ b/packages/fxa-shared/metrics/glean/web/index.ts @@ -138,6 +138,13 @@ export const eventsMap = { viewWithNoPasswordSet: 'third_party_auth_login_no_pw_view', }, + thirdPartyAuthSetPassword: { + view: 'third_party_auth_set_password_view', + engage: 'third_party_auth_set_password_engage', + submit: 'third_party_auth_set_password_submit', + success: 'third_party_auth_set_password_success', + }, + cadMobilePair: { view: 'cad_mobile_pair_view', submit: 'cad_mobile_pair_submit',