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: 6 additions & 0 deletions packages/functional-tests/pages/inlineTotpSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export class InlineTotpSetupPage extends BaseLayout {
});
}

// Structural, not copy — the banner id is guarded in the FlowSetup2faPrompt
// unit test, which is also where the wording is asserted.
get passkeySuccessBanner() {
return this.page.locator('#passkey-signin-success');
}

get continueButton() {
return this.page.getByRole('button', { name: 'Continue' });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ test.describe('severity-1 #smoke', () => {
await page.waitForURL(/inline_totp_setup/);
});

// The passkey context must survive the divert, so the page explains why
// 2FA is still needed rather than implying the passkey was insufficient.
await expect(inlineTotpSetup.passkeySuccessBanner).toBeVisible();

// Force TOTP enrollment so non-passkey sign-ins also satisfy AMR.
const { available: recoveryPhoneAvailable } =
await target.authClient.recoveryPhoneAvailable(
Expand Down Expand Up @@ -354,6 +358,7 @@ test.describe('severity-1 #smoke', () => {
target,
pages: {
page,
inlineTotpSetup,
signin,
signinPasswordlessCode,
settings,
Expand Down Expand Up @@ -402,6 +407,8 @@ test.describe('severity-1 #smoke', () => {
await signin.passkeySigninButton.click();
await page.waitForURL(/inline_totp_setup/);
});

await expect(inlineTotpSetup.passkeySuccessBanner).toBeVisible();
});

test('AMO-style profile AAL2: cached passkey session (no fresh ceremony) without TOTP is diverted to inline TOTP setup, not looped', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ flow-setup-2fa-prompt-heading = Set up two-step authentication
# that requests two-step authentication setup.
flow-setup-2fa-prompt-description = { $serviceName } requires you to set up two-step authentication to keep your account safe.

# Success banner shown at the top of the page when the user signed in with a passkey.
flow-setup-2fa-prompt-passkey-success-banner = Successfully signed in with passkey

# Body copy shown when the user signed in with a passkey and the service still
# requires two-step authentication setup.
# Variable { $serviceName } is the name of the product (e.g. Firefox Add-ons)
# that requests two-step authentication setup.
flow-setup-2fa-prompt-passkey-description = { $serviceName } also requires two-step authentication for your { -product-mozilla-account }. After setup, you’ll no longer need it when you sign in with a passkey.

# "these authenticator apps" links to https://support.mozilla.org/kb/secure-firefox-account-two-step-authentication
flow-setup-2fa-prompt-use-authenticator-apps = You can use any of <authenticationAppsLink>these authenticator apps</authenticationAppsLink> to proceed.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export const Default = () => (
/>
);

export const SignedInWithPasskey = () => (
<FlowSetup2faPrompt
localizedPageTitle="Two-step authentication"
serviceName="123Done"
onContinue={handleContinueClick}
onBackButtonClick={handleCancelClick}
signedInWithPasskey
/>
);

export const WithError = () => (
<FlowSetup2faPrompt
localizedPageTitle="Two-step authentication"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,69 @@ describe('FlowSetup2faPrompt', () => {
).toBeInTheDocument();
});

it('renders the passkey copy when signedInWithPasskey is true', () => {
renderFlowSetup2faPrompt({ signedInWithPasskey: true });

expect(
screen.getByText('Successfully signed in with passkey')
).toBeInTheDocument();
expect(
screen.getByText(
'123Done also requires two-step authentication for your Mozilla account. After setup, you’ll no longer need it when you sign in with a passkey.'
)
).toBeInTheDocument();
expect(
screen.queryByText(
'123Done requires you to set up two-step authentication to keep your account safe.'
)
).not.toBeInTheDocument();
});

it('keeps the shared copy unchanged when signedInWithPasskey is true', () => {
renderFlowSetup2faPrompt({ signedInWithPasskey: true });

expect(screen.getByText('Two-step authentication')).toBeInTheDocument();
expect(
screen.getByText('Set up two-step authentication')
).toBeInTheDocument();
expect(screen.getByText(/You can use any of/)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Continue' })
).toBeInTheDocument();
});

it('hides the passkey success banner by default', () => {
renderFlowSetup2faPrompt();

expect(
screen.queryByText('Successfully signed in with passkey')
).not.toBeInTheDocument();
});

// The functional tests locate this banner by id rather than by copy, so the
// id is a contract and is asserted here where it is cheap.
it('gives the passkey success banner a stable id', () => {
renderFlowSetup2faPrompt({ signedInWithPasskey: true });

expect(document.getElementById('passkey-signin-success')).toHaveTextContent(
'Successfully signed in with passkey'
);
});

it('shows only the error banner when an error and a passkey signin coincide', () => {
const localizedErrorMessage =
'An error occurred while setting up two-step authentication.';
renderFlowSetup2faPrompt({
localizedErrorMessage,
signedInWithPasskey: true,
});

expect(screen.getByText(localizedErrorMessage)).toBeInTheDocument();
expect(
screen.queryByText('Successfully signed in with passkey')
).not.toBeInTheDocument();
});

it('renders the error banner message when provided', () => {
const localizedErrorMessage =
'An error occurred while setting up two-step authentication.';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import LinkExternal from 'fxa-react/components/LinkExternal';
import FlowContainer from '../FlowContainer';
import { GleanClickEventType2FA } from '../../../lib/types';
import Banner from '../../Banner';
import { RelierCmsInfo } from '../../../models';
import { RelierCmsInfo, useFtlMsgResolver } from '../../../models';
import CmsButtonWithFallback from '../../CmsButtonWithFallback';

export type FlowSetup2faPromptProps = {
Expand All @@ -19,6 +19,7 @@ export type FlowSetup2faPromptProps = {
serviceName: string;
localizedErrorMessage?: string;
cmsInfo?: RelierCmsInfo;
signedInWithPasskey?: boolean;
};

export const FlowSetup2faPrompt = ({
Expand All @@ -29,31 +30,62 @@ export const FlowSetup2faPrompt = ({
serviceName,
localizedErrorMessage,
cmsInfo,
signedInWithPasskey = false,
}: FlowSetup2faPromptProps) => {
const ftlMsgResolver = useFtlMsgResolver();

return (
<FlowContainer
onBackButtonClick={onBackButtonClick}
hideBackButton={hideBackButton}
title={localizedPageTitle}
>
{localizedErrorMessage && (
{/* An error is the more urgent message, so it replaces the success banner
rather than stacking with it. */}
{localizedErrorMessage ? (
<Banner
type="error"
content={{ localizedHeading: localizedErrorMessage }}
/>
) : (
signedInWithPasskey && (
<Banner
type="success"
bannerId="passkey-signin-success"
content={{
localizedHeading: ftlMsgResolver.getMsg(
'flow-setup-2fa-prompt-passkey-success-banner',
'Successfully signed in with passkey'
),
}}
/>
)
)}
<BackupRecoveryPhoneCodeImage ariaHidden />
<FtlMsg id="flow-setup-2fa-prompt-heading">
<h2 className="font-bold text-xl my-2">
Set up two-step authentication
</h2>
</FtlMsg>
<FtlMsg id="flow-setup-2fa-prompt-description" vars={{ serviceName }}>
<p className="text-base mb-4">
{serviceName} requires you to set up two-step authentication to keep
your account safe.
</p>
</FtlMsg>
{signedInWithPasskey ? (
<FtlMsg
id="flow-setup-2fa-prompt-passkey-description"
vars={{ serviceName }}
>
<p className="text-base mb-4">
{serviceName} also requires two-step authentication for your Mozilla
account. After setup, you’ll no longer need it when you sign in with
a passkey.
</p>
</FtlMsg>
) : (
<FtlMsg id="flow-setup-2fa-prompt-description" vars={{ serviceName }}>
<p className="text-base mb-4">
{serviceName} requires you to set up two-step authentication to keep
your account safe.
</p>
</FtlMsg>
)}
<FtlMsg
id="flow-setup-2fa-prompt-use-authenticator-apps"
elems={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MOCK_TOTP_TOKEN,
MOCK_QUERY_PARAMS,
MOCK_SIGNIN_LOCATION_STATE,
MOCK_SIGNIN_LOCATION_STATE_PASSKEY,
MOCK_SIGNIN_RECOVERY_LOCATION_STATE,
} from './mocks';
import { screen, waitFor } from '@testing-library/react';
Expand Down Expand Up @@ -80,7 +81,10 @@ function setMocks() {
sendVerificationCode: mockSendVerificationCode,
});
// Default: TOTP doesn't exist, so we need to create one
mockCheckTotpTokenExists.mockResolvedValue({ exists: false, verified: false });
mockCheckTotpTokenExists.mockResolvedValue({
exists: false,
verified: false,
});
mockCreateTotpToken.mockResolvedValue(MOCK_TOTP_TOKEN);
jest.spyOn(InlineTotpSetupModule, 'default');
(InlineTotpSetupModule.default as jest.Mock).mockReset();
Expand Down Expand Up @@ -182,7 +186,10 @@ describe('InlineTotpSetupContainer', () => {
mockSessionHook.mockImplementationOnce(() => ({
isSessionVerified: async () => true,
}));
mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true });
mockCheckTotpTokenExists.mockResolvedValue({
exists: true,
verified: true,
});
render();
const location = mockLocationHook();
await waitFor(() => {
Expand All @@ -197,7 +204,10 @@ describe('InlineTotpSetupContainer', () => {
mockSessionHook.mockImplementationOnce(() => ({
isSessionVerified: async () => false,
}));
mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true });
mockCheckTotpTokenExists.mockResolvedValue({
exists: true,
verified: true,
});
render();
const location = mockLocationHook();
await waitFor(() => {
Expand All @@ -223,7 +233,10 @@ describe('InlineTotpSetupContainer', () => {
mockSessionHook.mockImplementationOnce(() => ({
isSessionVerified: async () => true,
}));
mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true });
mockCheckTotpTokenExists.mockResolvedValue({
exists: true,
verified: true,
});

render();

Expand Down Expand Up @@ -255,6 +268,34 @@ describe('InlineTotpSetupContainer', () => {
});
});

it('passes signedInWithPasskey when the signin state came from a passkey ceremony', async () => {
mockLocationHook.mockReturnValue({
pathname: '/inline_totp_setup',
search: '?' + new URLSearchParams(MOCK_QUERY_PARAMS),
state: MOCK_SIGNIN_LOCATION_STATE_PASSKEY,
});

render();

await waitFor(() => {
expect(InlineTotpSetupModule.default).toHaveBeenCalled();
});
const args = (InlineTotpSetupModule.default as jest.Mock).mock
.calls[0][0];
expect(args.signedInWithPasskey).toBe(true);
});

it('passes signedInWithPasskey as false for a non-passkey signin state', async () => {
render();

await waitFor(() => {
expect(InlineTotpSetupModule.default).toHaveBeenCalled();
});
const args = (InlineTotpSetupModule.default as jest.Mock).mock
.calls[0][0];
expect(args.signedInWithPasskey).toBe(false);
});

describe('callbacks', () => {
describe('verifyCodeHandler', () => {
it('throws an error when the server rejects the code', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export const InlineTotpSetupContainer = ({
return (
<InlineTotpSetup
{...{ totp, serviceName, verifyCodeHandler, integration }}
signedInWithPasskey={!!signinState.isPasskeySession}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const Default = () => (
/>
);

export const SignedInWithPasskey = () => (
<InlineTotpSetup
totp={MOCK_TOTP_TOKEN}
serviceName={MozServices.Addons}
verifyCodeHandler={verifyCodeHandler}
signedInWithPasskey
/>
);

export const onError = () => (
<InlineTotpSetup
totp={MOCK_TOTP_TOKEN}
Expand Down
18 changes: 18 additions & 0 deletions packages/fxa-settings/src/pages/InlineTotpSetup/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ describe('InlineTotpSetup', () => {
).toBeInTheDocument();
});

it('renders the passkey intro when signedInWithPasskey is set', () => {
renderWithLocalizationProvider(
<InlineTotpSetup {...mockProps} signedInWithPasskey />
);

expect(
screen.getByText('Successfully signed in with passkey')
).toBeInTheDocument();
expect(
screen.getByText(/also requires two-step authentication for your/)
).toBeInTheDocument();
expect(
screen.queryByText(
'Add-ons requires you to set up two-step authentication to keep your account safe.'
)
).not.toBeInTheDocument();
});

it('renders step 1 as expected, showing the QR code by default', async () => {
renderWithLocalizationProvider(<InlineTotpSetup {...mockProps} />);
await clickContinue();
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-settings/src/pages/InlineTotpSetup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const InlineTotpSetup = ({
serviceName,
verifyCodeHandler,
integration,
signedInWithPasskey,
}: InlineTotpSetupProps) => {
const ftlMsgResolver = useFtlMsgResolver();
const [currentStep, setCurrentStep] = useState<number>(0);
Expand Down Expand Up @@ -51,6 +52,7 @@ export const InlineTotpSetup = ({
localizedPageTitle={localizedPageTitle}
serviceName={serviceName}
cmsInfo={cmsInfo}
signedInWithPasskey={signedInWithPasskey}
/>
)}
{currentStep === 1 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface InlineTotpSetupProps {
serviceName: MozServices;
verifyCodeHandler: (code: string) => void;
integration?: Integration;
signedInWithPasskey?: boolean;
}

export interface InlineTotpSetupPropsOld {
Expand Down
Loading
Loading