diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts index 4eaa08bef3f..5ea3e07fae9 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -229,6 +229,8 @@ export interface UsePasskeySignInResult { isNavigating: boolean; errorBanner: React.ReactNode | undefined; onClick: () => Promise; + /** Dismisses the passkey error banner. */ + clearError: () => void; } /** @@ -607,5 +609,7 @@ export function usePasskeySignIn({ supportsKeysOptionalLogin, ]); - return { isLoading, isNavigating, errorBanner, onClick }; + const clearError = useCallback(() => setBanner(undefined), []); + + return { isLoading, isNavigating, errorBanner, onClick, clearError }; } diff --git a/packages/fxa-settings/src/pages/Index/index.test.tsx b/packages/fxa-settings/src/pages/Index/index.test.tsx index 6fd8c34c197..ff67a2113f6 100644 --- a/packages/fxa-settings/src/pages/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Index/index.test.tsx @@ -8,7 +8,8 @@ import { createMockIndexOAuthNativeIntegration, Subject } from './mocks'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { MozServices } from '../../lib/types'; import GleanMetrics from '../../lib/glean'; -import { MOCK_CMS_INFO } from '../mocks'; +import { MOCK_CMS_INFO, MOCK_EMAIL } from '../mocks'; +import * as utils from 'fxa-react/lib/utils'; const syncText = 'Sync your passwords, tabs, and bookmarks everywhere you use Firefox.'; @@ -284,4 +285,89 @@ describe('Index page', () => { expect(screen.getByLabelText('Enter your email')).toHaveFocus(); }); }); + + describe('error message collisions', () => { + // jsdom has no WebAuthn, so a passkey click always ends in the hook's + // "not supported" error banner. + const passkeyError = 'Your browser or device doesn’t support passkeys.'; + const bannerError = 'Banner error'; + const tooltipError = 'Tooltip error'; + + let user: ReturnType; + + const clickPasskey = () => + user.click(screen.getByRole('button', { name: 'Sign in with passkey' })); + + beforeEach(() => { + user = userEvent.setup(); + jest.spyOn(utils, 'hardNavigate').mockImplementation(() => {}); + }); + + it('clears the passkey error when the email form is submitted', async () => { + renderWithLocalizationProvider(); + + await clickPasskey(); + await screen.findByText(passkeyError); + + await user.type(screen.getByLabelText('Enter your email'), MOCK_EMAIL); + await user.click( + screen.getByRole('button', { name: 'Sign up or sign in' }) + ); + + await waitFor(() => + expect(screen.queryByText(passkeyError)).not.toBeInTheDocument() + ); + }); + + it('clears the passkey error when Google sign-in is clicked', async () => { + renderWithLocalizationProvider(); + + await clickPasskey(); + await screen.findByText(passkeyError); + + await user.click( + screen.getByRole('button', { name: /Continue with Google/ }) + ); + + await waitFor(() => + expect(screen.queryByText(passkeyError)).not.toBeInTheDocument() + ); + }); + + it('clears banner and tooltip errors when the passkey button is clicked', async () => { + renderWithLocalizationProvider( + + ); + + await clickPasskey(); + + await waitFor(() => + expect(screen.queryByText(bannerError)).not.toBeInTheDocument() + ); + expect(screen.queryByText(tooltipError)).not.toBeInTheDocument(); + }); + + it('clears banner and tooltip errors when Apple sign-in is clicked', async () => { + renderWithLocalizationProvider( + + ); + + await user.click( + screen.getByRole('button', { name: /Continue with Apple/ }) + ); + + await waitFor(() => + expect(screen.queryByText(bannerError)).not.toBeInTheDocument() + ); + expect(screen.queryByText(tooltipError)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/Index/index.tsx b/packages/fxa-settings/src/pages/Index/index.tsx index 02a79b44789..bb4d99cc526 100644 --- a/packages/fxa-settings/src/pages/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Index/index.tsx @@ -70,7 +70,16 @@ export const Index = ({ isButtonVisible: showPasskeySignin, supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin, }); + // Only one error belongs on the card at a time: starting a sign-in method + // dismisses the error left by the previous one. + const clearErrors = () => { + setErrorBannerMessage(''); + setTooltipErrorMessage(''); + passkey.clearError(); + }; + const handlePasskeyClick = () => { + clearErrors(); // Cancel any pending suggested-email auto-submit so it can't override // our /settings navigation after the ceremony writes localStorage. disableAutoSubmit(); @@ -97,6 +106,7 @@ export const Index = ({ }, []); const onSubmit = async ({ email }: IndexFormData) => { + passkey.clearError(); setIsSubmitting(true); try { await processEmailSubmission(email.trim()); @@ -256,6 +266,8 @@ export const Index = ({ : undefined } errorBanner={showPasskeySignin ? passkey.errorBanner : undefined} + onContinueWithGoogle={clearErrors} + onContinueWithApple={clearErrors} disabled={authInProgress} viewName="index" flowQueryParams={flowQueryParams} diff --git a/packages/fxa-settings/src/pages/Index/mocks.tsx b/packages/fxa-settings/src/pages/Index/mocks.tsx index a323abdb026..4b406d77a4b 100644 --- a/packages/fxa-settings/src/pages/Index/mocks.tsx +++ b/packages/fxa-settings/src/pages/Index/mocks.tsx @@ -6,12 +6,14 @@ import React from 'react'; import { MemoryRouter } from 'react-router'; import { MozServices } from '../../lib/types'; import { + AppContext, IntegrationData, IntegrationType, OAuthIntegrationData, OAuthWebIntegration, RelierCmsInfo, } from '../../models'; +import { mockAppContext } from '../../models/mocks'; import type AuthClient from 'fxa-auth-client/browser'; import { IndexIntegration } from './interfaces'; import Index from '.'; @@ -137,6 +139,7 @@ export const Subject = ({ initialTooltipMessage = '', isMobile = false, supportsKeysOptionalLogin = false, + passkeyEnabled = false, }: { integration?: IndexIntegration; serviceName?: MozServices; @@ -146,6 +149,8 @@ export const Subject = ({ initialTooltipMessage?: string; isMobile?: boolean; supportsKeysOptionalLogin?: boolean; + /** Turns the passkey signin feature flags on, so the CTA renders. */ + passkeyEnabled?: boolean; }) => { const [errorBannerMessage, setErrorBannerMessage] = React.useState(initialErrorBanner); @@ -157,39 +162,52 @@ export const Subject = ({ const mockUseFxAStatusResult = mockUseFxAStatus({ supportsKeysOptionalLogin, }); + const contextValue = mockAppContext(); + if (passkeyEnabled && contextValue.config) { + contextValue.config = { + ...contextValue.config, + featureFlags: { + ...contextValue.config.featureFlags, + passkeysEnabled: true, + passkeyAuthenticationEnabled: true, + }, + }; + } return ( - {}} - disableAutoSubmit={() => {}} - authClient={ - { - beginPasskeyAuthentication: async () => {}, - completePasskeyAuthentication: async () => {}, - accountProfile: async () => {}, - } as unknown as AuthClient - } - finishOAuthFlowHandler={async () => ({ - redirect: 'http://example.com', - code: 'mock-code', - state: 'mock-state', - scope: 'profile', - error: undefined, - })} - {...{ - prefillEmail, - integration, - serviceName, - errorBannerMessage, - successBannerMessage, - tooltipErrorMessage, - setErrorBannerMessage, - setSuccessBannerMessage, - setTooltipErrorMessage, - isMobile, - useFxAStatusResult: mockUseFxAStatusResult, - }} - /> + + {}} + disableAutoSubmit={() => {}} + authClient={ + { + beginPasskeyAuthentication: async () => {}, + completePasskeyAuthentication: async () => {}, + accountProfile: async () => {}, + } as unknown as AuthClient + } + finishOAuthFlowHandler={async () => ({ + redirect: 'http://example.com', + code: 'mock-code', + state: 'mock-state', + scope: 'profile', + error: undefined, + })} + {...{ + prefillEmail, + integration, + serviceName, + errorBannerMessage, + successBannerMessage, + tooltipErrorMessage, + setErrorBannerMessage, + setSuccessBannerMessage, + setTooltipErrorMessage, + isMobile, + useFxAStatusResult: mockUseFxAStatusResult, + }} + /> + ); }; diff --git a/packages/fxa-settings/src/pages/Signin/index.test.tsx b/packages/fxa-settings/src/pages/Signin/index.test.tsx index 71aa4b63b23..c6d992d7a57 100644 --- a/packages/fxa-settings/src/pages/Signin/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.test.tsx @@ -81,6 +81,9 @@ jest.mock('../../lib/glean', () => ({ engage: jest.fn(), lockedAccountBannerView: jest.fn(), alternativeAuthView: jest.fn(), + passkeySubmit: jest.fn(), + passkeySubmitFrontendError: jest.fn(), + passkeySubmitSuccess: jest.fn(), }, cachedLogin: { forgotPassword: jest.fn(), @@ -1981,4 +1984,86 @@ describe('Signin component', () => { }); }); }); + + describe('error message collisions', () => { + // The mock auth client has no passkey methods, so the hook surfaces its + // generic error banner. + const passkeyError = + 'Something went wrong. Try again or choose another sign-in method.'; + const bannerError = 'Banner error'; + const tooltipError = 'Valid password required'; + + const clickPasskey = () => + user.click(screen.getByRole('button', { name: 'Sign in with passkey' })); + + beforeEach(() => { + (isWebAuthnSupported as jest.Mock).mockReturnValue(true); + jest.spyOn(utils, 'hardNavigate').mockImplementation(() => {}); + }); + + it('clears the passkey error when the password form is submitted', async () => { + render({ hasPasskey: true }); + + await clickPasskey(); + await screen.findByText(passkeyError); + + await enterPasswordAndSubmit(); + + await waitFor(() => + expect(screen.queryByText(passkeyError)).not.toBeInTheDocument() + ); + }); + + it('clears the passkey error when Google sign-in is clicked', async () => { + render({ hasPasskey: true }); + + await clickPasskey(); + await screen.findByText(passkeyError); + + await user.click( + screen.getByRole('button', { name: /Continue with Google/ }) + ); + + await waitFor(() => + expect(screen.queryByText(passkeyError)).not.toBeInTheDocument() + ); + }); + + it('clears banner and tooltip errors when the passkey button is clicked', async () => { + render({ + hasPasskey: true, + localizedErrorFromLocationState: bannerError, + }); + + // An empty password sets the tooltip error. + await submit(); + await screen.findByText(tooltipError); + + await clickPasskey(); + + await waitFor(() => + expect(screen.queryByText(bannerError)).not.toBeInTheDocument() + ); + expect(screen.queryByText(tooltipError)).not.toBeInTheDocument(); + }); + + it('clears banner and tooltip errors when Apple sign-in is clicked', async () => { + render({ + hasPasskey: true, + localizedErrorFromLocationState: bannerError, + }); + + await submit(); + await screen.findByText(tooltipError); + + await user.click( + screen.getByRole('button', { name: /Continue with Apple/ }) + ); + + await waitFor(() => + expect(screen.queryByText(bannerError)).not.toBeInTheDocument() + ); + expect(screen.queryByText(tooltipError)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 99dd439cdaa..20a48a9c174 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -86,6 +86,8 @@ const Signin = ({ supportsKeysOptionalLogin, }); + const { clearError: clearPasskeyError } = passkey; + const [localizedBannerError, setLocalizedBannerError] = useState( localizedErrorFromLocationState || '' ); @@ -310,15 +312,31 @@ const Signin = ({ const onSubmit = useCallback( async ({ password }: { password: string }) => { + clearPasskeyError(); if (password === '') { setPasswordTooltipErrorText(localizedValidPasswordError); return; } signInWithPassword(password); }, - [signInWithPassword, localizedValidPasswordError] + [signInWithPassword, localizedValidPasswordError, clearPasskeyError] ); + // Only one error belongs on the card at a time: starting a sign-in method + // dismisses the error left by the previous one. + const clearErrors = () => { + setLocalizedBannerError(''); + setLocalizedBannerErrorDescription(''); + setLocalizedBannerErrorLink(undefined); + setPasswordTooltipErrorText(''); + clearPasskeyError(); + }; + + const handlePasskeyClick = () => { + clearErrors(); + passkey.onClick(); + }; + const cmsInfo = integration.getCmsInfo(); const signinPageCms = cmsInfo?.SigninPage; const title = signinPageCms?.pageTitle; @@ -421,10 +439,12 @@ const Signin = ({ showPasskeySignin={showPasskeySignin} passkeySignIn={ showPasskeySignin - ? { isLoading: passkey.isLoading, onClick: passkey.onClick } + ? { isLoading: passkey.isLoading, onClick: handlePasskeyClick } : undefined } errorBanner={showPasskeySignin ? passkey.errorBanner : undefined} + onContinueWithGoogle={clearErrors} + onContinueWithApple={clearErrors} disabled={alternativesLocked} {...{ viewName, flowQueryParams }} />