diff --git a/packages/functional-tests/lib/passkeyPolyfill.ts b/packages/functional-tests/lib/passkeyPolyfill.ts index 36d38b23b6a..d948579f4ff 100644 --- a/packages/functional-tests/lib/passkeyPolyfill.ts +++ b/packages/functional-tests/lib/passkeyPolyfill.ts @@ -139,6 +139,11 @@ export class PasskeyPolyfill { })); } + /** Returns raw credentials (with private keys) for signing later assertions. */ + getCredentialObjects(): VirtualCredential[] { + return this.credentials.slice(); + } + /** Clear all minted credentials and reset to `pending` mode. */ async cleanup() { this.credentials = []; diff --git a/packages/functional-tests/tests/passkeys/passkeyPasswordFallback.spec.ts b/packages/functional-tests/tests/passkeys/passkeyPasswordFallback.spec.ts new file mode 100644 index 00000000000..ff4dca64723 --- /dev/null +++ b/packages/functional-tests/tests/passkeys/passkeyPasswordFallback.spec.ts @@ -0,0 +1,190 @@ +/* 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/. */ + +// Browser-driven Sync passkey signin + password-fallback flow: enroll a +// passkey, simulate a new-device passkey ceremony via auth-client, seed the +// resulting session, then drive /signin_passkey_fallback from a fresh browser. + +import { Page } from '@playwright/test'; +import { FirefoxCommand } from '../../lib/channels'; +import { expect, test } from '../../lib/fixtures/standard'; +import { syncDesktopOAuthQueryParams } from '../../lib/query-params'; +import { + VirtualAuthenticator, + VirtualCredential, +} from '../../../../libs/accounts/passkey/src/lib/virtual-authenticator'; +import { BaseTarget } from '../../lib/targets/base'; + +async function seedAccountInLocalStorage( + page: Page, + args: { uid: string; sessionToken: string; email: string } +) { + await page.evaluate(({ uid, sessionToken, email }) => { + const account = { uid, sessionToken, email, verified: true }; + window.localStorage.setItem( + '__fxa_storage.accounts', + JSON.stringify({ [uid]: account }) + ); + window.localStorage.setItem( + '__fxa_storage.currentAccountUid', + JSON.stringify(uid) + ); + }, args); +} + +async function simulateNewDevicePasskeyAuth( + target: BaseTarget, + credential: VirtualCredential +) { + const origin = target.contentServerUrl; + const rpId = new URL(target.contentServerUrl).hostname; + const authStart = await target.authClient.beginPasskeyAuthentication(); + const assertion = VirtualAuthenticator.createAssertionResponse(credential, { + challenge: authStart.challenge, + origin, + rpId, + }); + return target.authClient.completePasskeyAuthentication( + assertion as any, + authStart.challenge, + { service: 'sync' } + ); +} + +test.describe('severity-1 #smoke', () => { + test.describe('Sync passkey signin with password fallback', () => { + test.beforeEach(async ({ pages: { configPage } }) => { + const config = await configPage.getConfig(); + test.skip( + !config.featureFlags?.passkeysEnabled || + !config.featureFlags?.passkeyRegistrationEnabled, + 'Passkey feature flags are not enabled' + ); + }); + + test('full flow: enable passkey → fallback page submit signs into Sync', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + signinTokenCode, + settings, + settingsPasskeyAdd, + connectAnotherDevice, + }, + testAccountTracker, + }) => { + const { email, password } = await testAccountTracker.signUpSync(); + + await signin.goto('/authorization', syncDesktopOAuthQueryParams); + await signin.fillOutEmailFirstForm(email); + await signin.fillOutPasswordForm(password); + await page.waitForURL(/signin_token_code/); + const tokenCode = await target.emailClient.getVerifyLoginCode(email); + await signinTokenCode.fillOutCodeForm(tokenCode); + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + + await settings.goto(); + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.passkey.status).toHaveText('Not set'); + + await settingsPasskeyAdd.initPasskeys(page); + await settingsPasskeyAdd.passkeyAuth.success(async () => { + await settings.passkey.createButton.click(); + await settings.confirmMfaGuard(email); + }); + await expect(settings.passkey.status).toHaveText('Enabled'); + + const [credential] = + settingsPasskeyAdd.passkeyAuth.getCredentialObjects(); + const authFinish = await simulateNewDevicePasskeyAuth(target, credential); + expect(authFinish.requiresPasswordForSync).toBe(true); + + // Simulate a fresh device so the fallback page hydrates from the seeded + // passkey session, not leftover state from the initial Sync OAuth flow. + await page.context().clearCookies(); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await seedAccountInLocalStorage(page, { + uid: authFinish.uid, + sessionToken: authFinish.sessionToken, + email, + }); + + await page.goto( + `${target.contentServerUrl}/signin_passkey_fallback?${syncDesktopOAuthQueryParams}` + ); + + await expect(page.getByTestId('passkey-fallback-email')).toHaveText( + email + ); + + await page.getByTestId('password-input-field').fill(password); + await page.getByTestId('continue-button').click(); + + await signin.checkWebChannelMessageScopes( + FirefoxCommand.OAuthLogin, + 'https://identity.mozilla.com/apps/oldsync' + ); + }); + + test('shows an error banner when the password is wrong', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + signinTokenCode, + settings, + settingsPasskeyAdd, + connectAnotherDevice, + }, + testAccountTracker, + }) => { + const { email, password } = await testAccountTracker.signUpSync(); + + await signin.goto('/authorization', syncDesktopOAuthQueryParams); + await signin.fillOutEmailFirstForm(email); + await signin.fillOutPasswordForm(password); + await page.waitForURL(/signin_token_code/); + const tokenCode = await target.emailClient.getVerifyLoginCode(email); + await signinTokenCode.fillOutCodeForm(tokenCode); + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + + await settings.goto(); + await settingsPasskeyAdd.initPasskeys(page); + await settingsPasskeyAdd.passkeyAuth.success(async () => { + await settings.passkey.createButton.click(); + await settings.confirmMfaGuard(email); + }); + await expect(settings.passkey.status).toHaveText('Enabled'); + + const [credential] = + settingsPasskeyAdd.passkeyAuth.getCredentialObjects(); + const authFinish = await simulateNewDevicePasskeyAuth(target, credential); + + await page.context().clearCookies(); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await seedAccountInLocalStorage(page, { + uid: authFinish.uid, + sessionToken: authFinish.sessionToken, + email, + }); + + await page.goto( + `${target.contentServerUrl}/signin_passkey_fallback?${syncDesktopOAuthQueryParams}` + ); + await page + .getByTestId('password-input-field') + .fill('definitely-the-wrong-password'); + await page.getByTestId('continue-button').click(); + + await expect(page.getByText(/password/i).first()).toBeVisible(); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index 33dec5d671b..690c9233c65 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -132,9 +132,10 @@ describe('passkeys routes', () => { emailVerified: true, verifierSetAt: 1234567890, }), - createPasskeyVerifiedSessionToken: jest - .fn() - .mockResolvedValue({ id: 'new-session-token-id' }), + createPasskeyVerifiedSessionToken: jest.fn().mockResolvedValue({ + id: 'new-session-token-id', + data: 'new-session-token-data', + }), securityEvent: jest.fn().mockResolvedValue(undefined), }; @@ -1044,7 +1045,7 @@ describe('passkeys routes', () => { ); expect(result).toEqual({ uid: UID, - sessionToken: 'new-session-token-id', + sessionToken: 'new-session-token-data', verified: true, requiresPasswordForSync: false, hasPassword: true, @@ -1238,7 +1239,10 @@ describe('passkeys routes', () => { mockRequest as any ); - expect(result).toEqual({ id: 'new-session-token-id' }); + expect(result).toEqual({ + id: 'new-session-token-id', + data: 'new-session-token-data', + }); expect(statsd.increment).toHaveBeenCalledWith( 'passkeys.createSessionToken.success' ); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index 78af26d912a..9d7e7ef0dc8 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -65,7 +65,7 @@ interface DB { uaOSVersion?: string; uaDeviceType?: string; uaFormFactor?: string; - }): Promise<{ id: string }>; + }): Promise<{ id: string; data: string }>; /** Records a security event in the audit log. */ securityEvent: (arg: any) => Promise; } @@ -429,7 +429,7 @@ export class PasskeyHandler { return { uid, - sessionToken: sessionToken.id, + sessionToken: sessionToken.data, verified: true, requiresPasswordForSync, hasPassword, diff --git a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts index a6b6f8b3407..71c08012a2b 100644 --- a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts @@ -332,3 +332,84 @@ describe('#integration - remote passkey authentication', () => { }).rejects.toBeDefined(); }); }); + +describe('#integration - remote passkey-then-password fallback via /session/reauth', () => { + let authEmail: string; + let authClient: any; + let registeredCred: VirtualCredential; + + async function authenticateWithPasskey(): Promise { + const startResult = await authClient.api.doRequest( + 'POST', + `${authClient.api.baseURL}/passkey/authentication/start`, + null, + {} + ); + const assertionResponse = VirtualAuthenticator.createAssertionResponse( + registeredCred, + { + challenge: startResult.challenge, + origin: passkeyOrigin, + rpId: passkeyRpId, + } + ); + const finishResult = await authClient.api.doRequest( + 'POST', + `${authClient.api.baseURL}/passkey/authentication/finish`, + null, + { response: assertionResponse, challenge: startResult.challenge } + ); + return finishResult.sessionToken; + } + + beforeEach(async () => { + authEmail = server.uniqueEmail(); + authClient = await Client.createAndVerify( + server.publicUrl, + authEmail, + password, + server.mailbox, + { version: 'V2' } + ); + + const accessToken = await getMfaAccessTokenForPasskey(authClient); + const options = await authClient.api.doRequestWithBearerToken( + 'POST', + `${authClient.api.baseURL}/passkey/registration/start`, + accessToken, + {} + ); + registeredCred = VirtualAuthenticator.createCredential(); + const registrationResponse = VirtualAuthenticator.createAttestationResponse( + registeredCred, + { + challenge: options.challenge, + origin: passkeyOrigin, + rpId: passkeyRpId, + } + ); + await authClient.api.doRequestWithBearerToken( + 'POST', + `${authClient.api.baseURL}/passkey/registration/finish`, + accessToken, + { response: registrationResponse, challenge: options.challenge } + ); + }); + + it('passkey-verified session can call /session/reauth?keys=true to obtain a keyFetchToken', async () => { + const passkeySessionTokenHex = await authenticateWithPasskey(); + const reauthClient = await Client.login( + server.publicUrl, + authEmail, + password, + { version: 'V2' } + ); + reauthClient.sessionToken = passkeySessionTokenHex; + + await reauthClient.reauth({ keys: true }); + + const keyFetchToken = + reauthClient.keyFetchTokenVersion2 || reauthClient.keyFetchToken; + expect(keyFetchToken).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js b/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js index 4ffcda8255d..18befa5a76e 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js @@ -77,8 +77,7 @@ const FRONTEND_ROUTES = [ 'signin_passwordless_code', 'oauth/signin_passwordless_code', 'signin_confirmed', - // TODO: FXA-13100 - Uncomment when passkey fallback is fully implemented - // 'signin_passkey_fallback', + 'signin_passkey_fallback', 'signin_permissions', 'signin_reported', 'signin_unblock', 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 d4e268019ac..3cf3a6bb8d4 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 @@ -79,6 +79,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => { 'signin', 'oauth/signin', 'oauth/force_auth', + 'signin_passkey_fallback', 'signin_token_code', 'signin_totp_code', 'signin_reported', diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 8b1c842141f..92bc0cca263 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -111,8 +111,8 @@ const SigninBounced = lazy(() => import('../../pages/Signin/SigninBounced')); const SigninConfirmed = lazy( () => import('../../pages/Signin/SigninConfirmed') ); -const SigninPasskeyFallback = lazy( - () => import('../../pages/Signin/SigninPasskeyFallback') +const SigninPasskeyFallbackContainer = lazy( + () => import('../../pages/Signin/SigninPasskeyFallback/container') ); const SigninRecoveryCodeContainer = lazy( () => import('../../pages/Signin/SigninRecoveryCode/container') @@ -698,7 +698,10 @@ const AuthAndAccountSetupRoutes = ({ path="/signin_confirmed/*" {...{ isSignedIn, serviceName, integration }} /> - + -
- -
-
-`; diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx new file mode 100644 index 00000000000..830f7beb113 --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx @@ -0,0 +1,215 @@ +/* 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 { LocationProvider } from '@reach/router'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; + +import * as CacheModule from '../../../lib/cache'; +import * as HooksModule from '../../../lib/oauth/hooks'; +import * as SigninUtilsModule from '../utils'; +import { Integration } from '../../../models'; +import { MOCK_EMAIL, MOCK_SESSION_TOKEN, MOCK_UID } from '../../mocks'; +import { createMockSyncIntegration } from '../SigninPushCode/mocks'; +import SigninPasskeyFallbackContainer from './container'; + +const MOCK_LOCATION_STATE = { + email: MOCK_EMAIL, + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + emailVerified: true, + sessionVerified: true, +}; + +const mockSessionReauth = jest.fn(); +const mockHandleNavigation = jest.fn(); +const mockNavigate = jest.fn(); +let mockLocationState: Record | undefined = undefined; +let mockOAuthDataError: unknown = null; +const mockFinishOAuthFlowHandler = jest.fn(); + +jest.mock('@reach/router', () => ({ + __esModule: true, + ...jest.requireActual('@reach/router'), + useLocation: () => ({ + pathname: '/signin_passkey_fallback', + search: '?context=oauth_webchannel_v1', + state: mockLocationState, + }), +})); + +jest.mock('../../../lib/hooks/useNavigateWithQuery', () => ({ + useNavigateWithQuery: () => mockNavigate, +})); + +jest.mock('../../../models', () => ({ + ...jest.requireActual('../../../models'), + useAuthClient: () => ({ + sessionReauth: mockSessionReauth, + }), + useFtlMsgResolver: () => ({ + getMsg: (_id: string, fallback: string) => fallback, + }), +})); + +jest.mock('../../../lib/oauth/hooks', () => ({ + useFinishOAuthFlowHandler: jest.fn(), +})); + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + handleNavigation: jest.fn(), +})); + +function applyDefaultMocks(): void { + jest.resetAllMocks(); + mockLocationState = MOCK_LOCATION_STATE; + mockOAuthDataError = null; + mockSessionReauth.mockResolvedValue({ + keyFetchToken: 'keyfetchtoken', + unwrapBKey: 'unwrapbkey', + }); + mockHandleNavigation.mockResolvedValue({ error: undefined }); + (SigninUtilsModule.handleNavigation as jest.Mock).mockImplementation( + mockHandleNavigation + ); + (HooksModule.useFinishOAuthFlowHandler as jest.Mock).mockImplementation( + () => ({ + finishOAuthFlowHandler: mockFinishOAuthFlowHandler, + oAuthDataError: mockOAuthDataError, + }) + ); +} + +function render(integration?: Integration) { + return renderWithLocalizationProvider( + + + + ); +} + +describe('SigninPasskeyFallback container', () => { + beforeEach(() => { + applyDefaultMocks(); + }); + + describe('signinState hydration', () => { + it('renders the page when router state provides session, email, uid', async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId('passkey-fallback-email')).toHaveTextContent( + MOCK_EMAIL + ); + }); + }); + + it('falls back to localStorage when router state is empty', async () => { + mockLocationState = undefined; + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue({ + uid: MOCK_UID, + email: MOCK_EMAIL, + sessionToken: MOCK_SESSION_TOKEN, + verified: true, + }); + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId('passkey-fallback-email')).toHaveTextContent( + MOCK_EMAIL + ); + }); + }); + + it('redirects to / when neither router state nor localStorage have a session', async () => { + mockLocationState = undefined; + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(undefined); + render(); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + }); + + describe('onContinue', () => { + function submitPassword(getByTestId: (id: string) => HTMLElement) { + fireEvent.change(getByTestId('password-input-field'), { + target: { value: 'pa55word' }, + }); + fireEvent.click(getByTestId('continue-button')); + } + + it('calls sessionReauth with keys=true and forwards to handleNavigation', async () => { + const { getByTestId } = render(); + submitPassword(getByTestId); + + await waitFor(() => { + expect(mockSessionReauth).toHaveBeenCalledWith( + MOCK_SESSION_TOKEN, + MOCK_EMAIL, + 'pa55word', + { keys: true } + ); + }); + expect(mockHandleNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + email: MOCK_EMAIL, + handleFxaLogin: true, + handleFxaOAuthLogin: true, + signinData: expect.objectContaining({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: 'keyfetchtoken', + }), + }) + ); + }); + + it('renders an error banner when sessionReauth throws', async () => { + mockSessionReauth.mockRejectedValueOnce({ + errno: 103, + message: 'Incorrect password', + }); + const { getByTestId, findByText } = render(); + submitPassword(getByTestId); + expect(await findByText(/Incorrect password/i)).toBeInTheDocument(); + }); + + it('renders an error banner when handleNavigation returns an error', async () => { + mockHandleNavigation.mockResolvedValueOnce({ + error: { errno: 103, message: 'Incorrect password' }, + }); + const { getByTestId, findByText } = render(); + submitPassword(getByTestId); + expect(await findByText(/Incorrect password/i)).toBeInTheDocument(); + }); + }); + + describe('onGoToSettings', () => { + it('navigates to /settings', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('go-to-settings-button')); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/settings'); + }); + }); + }); + + describe('OAuthDataError', () => { + it('renders OAuthDataError when oAuthDataError is set', () => { + mockOAuthDataError = { errno: 1000, message: 'OAuth error' }; + (HooksModule.useFinishOAuthFlowHandler as jest.Mock).mockImplementation( + () => ({ + finishOAuthFlowHandler: mockFinishOAuthFlowHandler, + oAuthDataError: mockOAuthDataError, + }) + ); + const { queryByTestId } = render(); + expect(queryByTestId('passkey-fallback-email')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx new file mode 100644 index 00000000000..f01572e2efe --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx @@ -0,0 +1,113 @@ +/* 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, useLocation } from '@reach/router'; +import { useCallback, useState } from 'react'; +import { Integration, useAuthClient, useFtlMsgResolver } from '../../../models'; +import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; +import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import { getLocalizedErrorMessage } from '../../../lib/error-utils'; +import OAuthDataError from '../../../components/OAuthDataError'; +import AppLayout from '../../../components/AppLayout'; +import VerificationMethods from '../../../constants/verification-methods'; +import { getSigninState, handleNavigation } from '../utils'; +import { SigninLocationState } from '../interfaces'; +import SigninPasskeyFallback from '.'; + +const SigninPasskeyFallbackContainer = ({ + integration, +}: { integration: Integration } & RouteComponentProps) => { + const authClient = useAuthClient(); + const ftlMsgResolver = useFtlMsgResolver(); + const navigateWithQuery = useNavigateWithQuery(); + const location = useLocation() as ReturnType & { + state?: SigninLocationState; + }; + const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( + authClient, + integration + ); + + // Falls back to cached localStorage account so the page survives refresh. + const signinState = getSigninState(location.state); + const [localizedErrorMessage, setLocalizedErrorMessage] = useState(''); + + const sessionToken = signinState?.sessionToken; + const email = signinState?.email; + const uid = signinState?.uid; + + const onContinue = useCallback( + async (password: string) => { + if (!sessionToken || !email || !uid) { + navigateWithQuery('/'); + return; + } + try { + const { keyFetchToken, unwrapBKey } = await authClient.sessionReauth( + sessionToken, + email, + password, + { keys: true } + ); + const { error: navError } = await handleNavigation({ + email, + signinData: { + uid, + sessionToken, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.PASSKEY, + keyFetchToken, + }, + unwrapBKey, + integration, + finishOAuthFlowHandler, + queryParams: location.search, + handleFxaLogin: true, + handleFxaOAuthLogin: true, + }); + if (navError) { + setLocalizedErrorMessage( + getLocalizedErrorMessage(ftlMsgResolver, navError) + ); + } + } catch (err) { + setLocalizedErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, err)); + } + }, + [ + authClient, + ftlMsgResolver, + finishOAuthFlowHandler, + integration, + location.search, + navigateWithQuery, + sessionToken, + email, + uid, + ] + ); + + // User opted out of Sync; account is still signed in (passkey-verified). + const onGoToSettings = useCallback(() => { + navigateWithQuery('/settings'); + }, [navigateWithQuery]); + + if (oAuthDataError) { + return ; + } + + if (!sessionToken || !email || !uid) { + navigateWithQuery('/'); + return ; + } + + return ( + + ); +}; + +export default SigninPasskeyFallbackContainer; diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.stories.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.stories.tsx index b83b2af0e0f..6f950137f94 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.stories.tsx @@ -6,6 +6,7 @@ import React from 'react'; import SigninPasskeyFallback from '.'; import { LocationProvider } from '@reach/router'; import { Meta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; import { withLocalization } from 'fxa-react/lib/storybooks'; export default { @@ -14,8 +15,25 @@ export default { decorators: [withLocalization], } as Meta; +const handlers = { + onContinue: async (password: string) => { + action('onContinue')(password); + }, + onGoToSettings: () => action('onGoToSettings')(), +}; + export const Default = () => ( - + + +); + +export const WithError = () => ( + + ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx index eaf0612cb29..a0ad4b627b0 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx @@ -13,18 +13,48 @@ describe('SigninPasskeyFallback', () => { jest.clearAllMocks(); }); - it('renders as expected', () => { - const { container } = renderWithRouter(); - expect(container).toMatchSnapshot(); + it('renders email, heading, password field, and both buttons', () => { + renderWithRouter(); + expect(screen.getByText('Enter your password to sync')).toBeInTheDocument(); + expect(screen.getByTestId('passkey-fallback-email')).toHaveTextContent( + 'user@example.com' + ); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByTestId('continue-button')).toBeInTheDocument(); + expect(screen.getByTestId('go-to-settings-button')).toBeInTheDocument(); }); - it('clears password error text on input change', async () => { + it('calls onContinue with the entered password', async () => { const user = userEvent.setup(); - renderWithRouter(); - const passwordInput = screen.getByLabelText('Password'); + const onContinue = jest.fn().mockResolvedValue(undefined); + renderWithRouter( + + ); + await user.type(screen.getByLabelText('Password'), 'hunter2-the-sequel'); + await user.click(screen.getByTestId('continue-button')); + expect(onContinue).toHaveBeenCalledWith('hunter2-the-sequel'); + }); - await user.type(passwordInput, 'newpassword'); + it('calls onGoToSettings when the user clicks Go to settings', async () => { + const user = userEvent.setup(); + const onGoToSettings = jest.fn(); + renderWithRouter( + + ); + await user.click(screen.getByTestId('go-to-settings-button')); + expect(onGoToSettings).toHaveBeenCalledTimes(1); + }); - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument(); + it('shows a banner when localizedErrorMessage is set', () => { + renderWithRouter( + + ); + expect(screen.getByText('Incorrect password')).toBeInTheDocument(); }); }); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx index 9663daba7f3..8b6664d5f33 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx @@ -7,9 +7,15 @@ import { RouteComponentProps } from '@reach/router'; import { useForm } from 'react-hook-form'; import { FtlMsg } from 'fxa-react/lib/utils'; import AppLayout from '../../../components/AppLayout'; +import { Banner } from '../../../components/Banner'; import InputPassword from '../../../components/InputPassword'; -export type SigninPasskeyFallbackProps = {}; +export type SigninPasskeyFallbackProps = { + email?: string; + onContinue?: (password: string) => Promise; + onGoToSettings?: () => void; + localizedErrorMessage?: string; +}; type FormData = { password: string; @@ -17,30 +23,43 @@ type FormData = { export const viewName = 'signin-passkey-fallback'; -const SigninPasskeyFallback = ( - _props: SigninPasskeyFallbackProps & RouteComponentProps -) => { - // State +const SigninPasskeyFallback = ({ + email, + onContinue, + onGoToSettings, + localizedErrorMessage, +}: SigninPasskeyFallbackProps & RouteComponentProps) => { const [passwordErrorText, setPasswordErrorText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); - // Form const { handleSubmit, register } = useForm({ mode: 'onTouched', criteriaMode: 'all', defaultValues: { password: '' }, }); - // Handlers - const onContinue = useCallback(async (data: FormData) => { - // TODO: FXA-13100 - Hook up password verification when container is added - }, []); - - const onGoToSettings = useCallback(() => { - // TODO: FXA-13100 - Hook up navigation when container is added - }, []); + const handleContinue = useCallback( + async (data: FormData) => { + if (!onContinue) return; + setIsSubmitting(true); + try { + await onContinue(data.password); + } finally { + setIsSubmitting(false); + } + }, + [onContinue] + ); return ( + {localizedErrorMessage && ( + + )} +

Finish sign in

@@ -49,6 +68,15 @@ const SigninPasskeyFallback = (

Enter your password to sync

+ {email && ( +

+ {email} +

+ )} +

To keep your data safe, you need to enter your password when you use @@ -56,7 +84,7 @@ const SigninPasskeyFallback = (

-
+ - {/* TODO: FXA-13100 - Add disabled={isSubmitting} while the password is submitting. */}
@@ -92,6 +120,7 @@ const SigninPasskeyFallback = ( type="submit" className="cta-primary cta-base-p tablet:flex-1" data-testid="continue-button" + disabled={isSubmitting} > Continue