- {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,
};
};