Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/fxa-settings/src/lib/passkeys/signin-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export interface UsePasskeySignInResult {
isNavigating: boolean;
errorBanner: React.ReactNode | undefined;
onClick: () => Promise<void>;
/** Dismisses the passkey error banner. */
clearError: () => void;
}

/**
Expand Down Expand Up @@ -607,5 +609,7 @@ export function usePasskeySignIn({
supportsKeysOptionalLogin,
]);

return { isLoading, isNavigating, errorBanner, onClick };
const clearError = useCallback(() => setBanner(undefined), []);

return { isLoading, isNavigating, errorBanner, onClick, clearError };
}
88 changes: 87 additions & 1 deletion packages/fxa-settings/src/pages/Index/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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<typeof userEvent.setup>;

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(<Subject passkeyEnabled />);

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(<Subject passkeyEnabled />);

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(
<Subject
passkeyEnabled
initialErrorBanner={bannerError}
initialTooltipMessage={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 () => {
renderWithLocalizationProvider(
<Subject
passkeyEnabled
initialErrorBanner={bannerError}
initialTooltipMessage={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();
});
});
});
12 changes: 12 additions & 0 deletions packages/fxa-settings/src/pages/Index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -97,6 +106,7 @@ export const Index = ({
}, []);

const onSubmit = async ({ email }: IndexFormData) => {
passkey.clearError();
setIsSubmitting(true);
try {
await processEmailSubmission(email.trim());
Expand Down Expand Up @@ -256,6 +266,8 @@ export const Index = ({
: undefined
}
errorBanner={showPasskeySignin ? passkey.errorBanner : undefined}
onContinueWithGoogle={clearErrors}
onContinueWithApple={clearErrors}
disabled={authInProgress}
viewName="index"
flowQueryParams={flowQueryParams}
Expand Down
80 changes: 49 additions & 31 deletions packages/fxa-settings/src/pages/Index/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';
Expand Down Expand Up @@ -137,6 +139,7 @@ export const Subject = ({
initialTooltipMessage = '',
isMobile = false,
supportsKeysOptionalLogin = false,
passkeyEnabled = false,
}: {
integration?: IndexIntegration;
serviceName?: MozServices;
Expand All @@ -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);
Expand All @@ -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 (
<MemoryRouter>
<Index
processEmailSubmission={async () => {}}
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,
}}
/>
<AppContext.Provider value={contextValue}>
<Index
processEmailSubmission={async () => {}}
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,
}}
/>
</AppContext.Provider>
</MemoryRouter>
);
};
85 changes: 85 additions & 0 deletions packages/fxa-settings/src/pages/Signin/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading