diff --git a/packages/functional-tests/pages/inlineTotpSetup.tsx b/packages/functional-tests/pages/inlineTotpSetup.tsx index e8fda3bae33..a324fc90713 100644 --- a/packages/functional-tests/pages/inlineTotpSetup.tsx +++ b/packages/functional-tests/pages/inlineTotpSetup.tsx @@ -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' }); } diff --git a/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts b/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts index 71fdd9b2bff..8a436cd228d 100644 --- a/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts +++ b/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts @@ -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( @@ -354,6 +358,7 @@ test.describe('severity-1 #smoke', () => { target, pages: { page, + inlineTotpSetup, signin, signinPasswordlessCode, settings, @@ -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 ({ diff --git a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/en.ftl b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/en.ftl index c744488e29b..a3953a81eda 100644 --- a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/en.ftl +++ b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/en.ftl @@ -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 these authenticator apps to proceed. diff --git a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.stories.tsx b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.stories.tsx index 448d01a8cd1..c48aa42dd9d 100644 --- a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.stories.tsx +++ b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.stories.tsx @@ -25,6 +25,16 @@ export const Default = () => ( /> ); +export const SignedInWithPasskey = () => ( + +); + export const WithError = () => ( { ).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.'; diff --git a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.tsx b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.tsx index 4b9613aff26..492c663afd2 100644 --- a/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.tsx +++ b/packages/fxa-settings/src/components/Settings/FlowSetup2faPrompt/index.tsx @@ -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 = { @@ -19,6 +19,7 @@ export type FlowSetup2faPromptProps = { serviceName: string; localizedErrorMessage?: string; cmsInfo?: RelierCmsInfo; + signedInWithPasskey?: boolean; }; export const FlowSetup2faPrompt = ({ @@ -29,18 +30,36 @@ export const FlowSetup2faPrompt = ({ serviceName, localizedErrorMessage, cmsInfo, + signedInWithPasskey = false, }: FlowSetup2faPromptProps) => { + const ftlMsgResolver = useFtlMsgResolver(); + return ( - {localizedErrorMessage && ( + {/* An error is the more urgent message, so it replaces the success banner + rather than stacking with it. */} + {localizedErrorMessage ? ( + ) : ( + signedInWithPasskey && ( + + ) )} @@ -48,12 +67,25 @@ export const FlowSetup2faPrompt = ({ Set up two-step authentication - -

- {serviceName} requires you to set up two-step authentication to keep - your account safe. -

-
+ {signedInWithPasskey ? ( + +

+ {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. +

+
+ ) : ( + +

+ {serviceName} requires you to set up two-step authentication to keep + your account safe. +

+
+ )} { mockSessionHook.mockImplementationOnce(() => ({ isSessionVerified: async () => true, })); - mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); + mockCheckTotpTokenExists.mockResolvedValue({ + exists: true, + verified: true, + }); render(); const location = mockLocationHook(); await waitFor(() => { @@ -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(() => { @@ -223,7 +233,10 @@ describe('InlineTotpSetupContainer', () => { mockSessionHook.mockImplementationOnce(() => ({ isSessionVerified: async () => true, })); - mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); + mockCheckTotpTokenExists.mockResolvedValue({ + exists: true, + verified: true, + }); render(); @@ -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 () => { diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx index 2c3f83d187c..a57b4e00654 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx @@ -206,6 +206,7 @@ export const InlineTotpSetupContainer = ({ return ( ); }; diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/index.stories.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/index.stories.tsx index 12fa80e43b7..4094fa15517 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/index.stories.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/index.stories.tsx @@ -30,6 +30,15 @@ export const Default = () => ( /> ); +export const SignedInWithPasskey = () => ( + +); + export const onError = () => ( { ).toBeInTheDocument(); }); + it('renders the passkey intro when signedInWithPasskey is set', () => { + renderWithLocalizationProvider( + + ); + + 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(); await clickContinue(); diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/index.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/index.tsx index 9465c5e9d3d..f585d31a5e5 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/index.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/index.tsx @@ -17,6 +17,7 @@ export const InlineTotpSetup = ({ serviceName, verifyCodeHandler, integration, + signedInWithPasskey, }: InlineTotpSetupProps) => { const ftlMsgResolver = useFtlMsgResolver(); const [currentStep, setCurrentStep] = useState(0); @@ -51,6 +52,7 @@ export const InlineTotpSetup = ({ localizedPageTitle={localizedPageTitle} serviceName={serviceName} cmsInfo={cmsInfo} + signedInWithPasskey={signedInWithPasskey} /> )} {currentStep === 1 && ( diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/interfaces.ts b/packages/fxa-settings/src/pages/InlineTotpSetup/interfaces.ts index 7fff0e98bfb..6605eec7519 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/interfaces.ts +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/interfaces.ts @@ -10,6 +10,7 @@ export interface InlineTotpSetupProps { serviceName: MozServices; verifyCodeHandler: (code: string) => void; integration?: Integration; + signedInWithPasskey?: boolean; } export interface InlineTotpSetupPropsOld { diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/mocks.ts b/packages/fxa-settings/src/pages/InlineTotpSetup/mocks.ts index 3469b9c3ba5..f4a546ea3f4 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/mocks.ts +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/mocks.ts @@ -23,6 +23,10 @@ export const MOCK_SIGNIN_LOCATION_STATE = { uid: MOCK_UID, verified: true, }; +export const MOCK_SIGNIN_LOCATION_STATE_PASSKEY = { + ...MOCK_SIGNIN_LOCATION_STATE, + isPasskeySession: true, +}; export const MOCK_SIGNIN_RECOVERY_LOCATION_STATE = { ...MOCK_SIGNIN_LOCATION_STATE, totp: MOCK_TOTP_TOKEN, diff --git a/packages/fxa-settings/src/pages/Signin/interfaces.ts b/packages/fxa-settings/src/pages/Signin/interfaces.ts index e9a12c776c4..f32849a72c4 100644 --- a/packages/fxa-settings/src/pages/Signin/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/interfaces.ts @@ -314,6 +314,9 @@ export interface SigninLocationState { isSessionAALUpgrade?: boolean; isSignInWithThirdPartyAuth?: boolean; isPasswordlessOtpSignin?: boolean; + // True when this session was established by a passkey assertion. Only set on + // a fresh ceremony, so a cached passkey session arrives here undefined. + isPasskeySession?: boolean; /** * Sign-in surface the user came from before reaching SigninPasskeyFallback. * Used to populate the `reason` extra on `passkey_enter_password.*` Glean diff --git a/packages/fxa-settings/src/pages/Signin/utils.test.ts b/packages/fxa-settings/src/pages/Signin/utils.test.ts index e487b4a20a7..e18057bb216 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.test.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.test.ts @@ -610,6 +610,39 @@ describe('Signin utils', () => { ); }); + it('forwards isPasskeySession into the location state so the page can show the passkey copy', async () => { + const navigationOptions = buildPasskeyOAuthOptions({ + accountHasTotp: false, + finishOAuthFlowHandler: jest.fn(), + }); + + await handleNavigation(navigationOptions); + + expect(mockNavigate).toHaveBeenCalledWith( + '/inline_totp_setup?client_id=abc', + expect.objectContaining({ + state: expect.objectContaining({ isPasskeySession: true }), + }) + ); + }); + + it('forwards isPasskeySession as false for a cached session', async () => { + const navigationOptions = buildPasskeyOAuthOptions({ + isPasskeySession: false, + accountHasTotp: false, + finishOAuthFlowHandler: jest.fn(), + }); + + await handleNavigation(navigationOptions); + + expect(mockNavigate).toHaveBeenCalledWith( + '/inline_totp_setup?client_id=abc', + expect.objectContaining({ + state: expect.objectContaining({ isPasskeySession: false }), + }) + ); + }); + it('diverts a cached session (not a fresh passkey ceremony) when the account has no TOTP', async () => { // A cached passkey session is session-AAL2 without isPasskeySession set. // The divert must still fire so an AAL2 RP does not bounce indefinitely. diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index ee353b216e7..ffaaae18943 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -448,6 +448,7 @@ const createSigninLocationState = ( showInlineRecoveryKeySetup, isSignInWithThirdPartyAuth, isPasswordlessOtpSignin, + isPasskeySession, origin, } = navigationOptions; return { @@ -461,6 +462,7 @@ const createSigninLocationState = ( showInlineRecoveryKeySetup, isSignInWithThirdPartyAuth, isPasswordlessOtpSignin, + isPasskeySession, origin, }; };