From e8119d3a86003a8701bcb4c70ff83ee9261e4955 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 9 Apr 2026 17:35:57 -0700 Subject: [PATCH] feat(passkeys): show max-limit banner and disable Create button at passkey limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because: * Users need clear feedback when they've reached the passkey limit and should not be able to attempt creating another one. This commit: * Threads passkeys.maxPerUser config from content-server to fxa-settings using the same PASSKEYS__MAX_PASSKEYS_PER_USER env var as auth-server * Renders a warning Banner in the passkeys row when the limit is reached * Passes disabled/disabledReason to UnitRow to gray out the Create button Additional tweaks to align with latest UX designs: * Renames canSync → prfEnabled on the Passkey type (no phase-1 UI; needed for the phase-2 passwordless-sync upgrade flow) * Removes the sign-in-only SubRow badge (not shown in phase 1) * Updates link copy to "Learn more" with FTL id passkey-row-info-link-2 * Tightens SubRow padding and UnitRow action-button top margin * Adds className prop to Banner to allow margin overrides Closes #FXA-13369 --- .../server/lib/beta-settings.js | 3 ++ .../server/lib/configuration.js | 8 ++++ .../react-app/route-definition-index.js | 4 ++ .../src/components/Banner/index.tsx | 2 + .../src/components/Banner/interfaces.ts | 2 + .../src/components/Settings/SubRow/en.ftl | 3 -- .../Settings/SubRow/index.stories.tsx | 6 +-- .../components/Settings/SubRow/index.test.tsx | 17 +------- .../src/components/Settings/SubRow/index.tsx | 19 +++------ .../src/components/Settings/UnitRow/index.tsx | 2 +- .../components/Settings/UnitRowPasskey/en.ftl | 10 ++++- .../Settings/UnitRowPasskey/index.stories.tsx | 18 +++++--- .../Settings/UnitRowPasskey/index.test.tsx | 26 +++++++++--- .../Settings/UnitRowPasskey/index.tsx | 42 +++++++++++++++---- .../Settings/UnitRowSecondaryEmail/index.tsx | 2 +- packages/fxa-settings/src/lib/config.test.ts | 25 +++++++++++ packages/fxa-settings/src/lib/config.ts | 6 +++ 17 files changed, 136 insertions(+), 59 deletions(-) diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index c1dc841a23d..8a2e11b7081 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -93,6 +93,9 @@ const settingsConfig = { count: config.get('recovery_codes.count'), length: config.get('recovery_codes.length'), }, + passkeys: { + maxPerUser: config.get('passkeys.maxPerUser'), + }, mfa: { otp: { expiresInMinutes: config.get('mfa.otp.expiresInMinutes'), diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 399b24091ca..1f52202a709 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -272,6 +272,14 @@ const conf = (module.exports = convict({ env: 'PASSWORDLESS_SIGNUP_ENABLED', }, }, + passkeys: { + maxPerUser: { + default: 10, + doc: 'Maximum number of passkeys a single user account may register. Must stay in sync with auth-server PASSKEYS__MAX_PASSKEYS_PER_USER.', + format: Number, + env: 'PASSKEYS__MAX_PASSKEYS_PER_USER', + }, + }, darkMode: { enabled: { default: false, diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js index 661bbbdf8a4..3f7fcf21543 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js @@ -64,6 +64,7 @@ function getIndexRouteDefinition(config) { const FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED = config.get( 'featureFlags.passkeyAuthenticationEnabled' ); + const PASSKEYS_MAX_PER_USER = config.get('passkeys.maxPerUser'); const DARK_MODE_ENABLED = config.get('darkMode.enabled'); const GLEAN_ENABLED = config.get('glean.enabled'); const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); @@ -143,6 +144,9 @@ function getIndexRouteDefinition(config) { enabled: CMS_ENABLED, l10nEnabled: CMS_L10N_ENABLED, }, + passkeys: { + maxPerUser: PASSKEYS_MAX_PER_USER, + }, nimbus: { enabled: NIMBUS_ENABLED, preview: NIMBUS_PREVIEW, diff --git a/packages/fxa-settings/src/components/Banner/index.tsx b/packages/fxa-settings/src/components/Banner/index.tsx index ec37c7f7d46..9e0c2eb11dc 100644 --- a/packages/fxa-settings/src/components/Banner/index.tsx +++ b/packages/fxa-settings/src/components/Banner/index.tsx @@ -27,6 +27,7 @@ export const Banner = ({ link, isFancy, bannerId, + className, textAlignClassName = 'text-start', iconAlignClassName = 'self-center', }: BannerProps) => { @@ -36,6 +37,7 @@ export const Banner = ({ id={bannerId || ''} className={classNames( 'my-4 flex flex-row no-wrap items-center px-4 py-3 gap-3.5 rounded-md border border-transparent text-sm text-grey-700', + className, textAlignClassName, textAlignClassName === 'text-center' && 'justify-center', type === 'error' && 'bg-red-100', diff --git a/packages/fxa-settings/src/components/Banner/interfaces.ts b/packages/fxa-settings/src/components/Banner/interfaces.ts index 2a206c10887..1e03edfd926 100644 --- a/packages/fxa-settings/src/components/Banner/interfaces.ts +++ b/packages/fxa-settings/src/components/Banner/interfaces.ts @@ -15,6 +15,7 @@ import { NotificationType } from '../../models'; * @property {Animation} [animation] - Optional animation settings for the banner. * @property {DismissButtonProps} [dismissButton] - Optional properties for a dismiss button. * @property {BannerLinkProps} [link] - Optional properties for a link within the banner. + * @property {string} [className] - Optional CSS class overrides, e.g. to adjust the default vertical margin. */ export type BannerProps = { type: NotificationType; @@ -25,6 +26,7 @@ export type BannerProps = { link?: BannerLinkProps; isFancy?: boolean; bannerId?: string; + className?: string; iconAlignClassName?: 'self-start' | 'self-center'; textAlignClassName?: 'text-start' | 'text-center'; }; diff --git a/packages/fxa-settings/src/components/Settings/SubRow/en.ftl b/packages/fxa-settings/src/components/Settings/SubRow/en.ftl index 8399c2e9148..168d56462df 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/en.ftl +++ b/packages/fxa-settings/src/components/Settings/SubRow/en.ftl @@ -49,9 +49,6 @@ passkey-sub-row-created-date = Created: { $createdDate } # $lastUsedDate (String) - a localized date string passkey-sub-row-last-used-date = Last used: { $lastUsedDate } -# These two sentences are referring to the passkey -passkey-sub-row-sign-in-only = Sign in only. Can’t be used to sync. - passkey-sub-row-delete-title = Delete passkey passkey-delete-modal-heading = Delete your passkey? passkey-delete-modal-content = This passkey will be removed from your account. You’ll need to sign in using a different way. diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx index 02ce6f7e5d7..f40bdc32a37 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx @@ -139,7 +139,7 @@ export const PasskeyWithSync: StoryFn = () => ( name: 'MacBook Pro', createdAt: new Date('2026-01-01').getTime(), lastUsed: new Date('2026-02-01').getTime(), - canSync: true, + prfEnabled: true, }} /> ); @@ -151,7 +151,7 @@ export const PasskeyWithoutSync: StoryFn = () => ( name: 'iPhone 14 Pro', createdAt: new Date('2025-12-01').getTime(), lastUsed: new Date('2026-01-31').getTime(), - canSync: false, + prfEnabled: false, }} /> ); @@ -162,7 +162,7 @@ export const PasskeyNeverUsed: StoryFn = () => ( id: '3', name: 'Windows PC', createdAt: new Date('2025-11-01').getTime(), - canSync: true, + prfEnabled: true, }} /> ); diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx index f05606c2735..0126ab6ea4d 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx @@ -273,7 +273,7 @@ describe('PasskeySubRow', () => { name: 'MacBook Pro', createdAt: new Date('2026-01-01').getTime(), lastUsed: new Date('2026-02-01').getTime(), - canSync: true, + prfEnabled: true, }; const mockDeletePasskey = jest.fn(); @@ -312,21 +312,6 @@ describe('PasskeySubRow', () => { expect(screen.queryByText(/Last used:/)).not.toBeInTheDocument(); }); - it('renders message when canSync is false', () => { - const passkeyWithoutSync = { ...mockPasskey, canSync: false }; - renderPasskeySubRow(passkeyWithoutSync); - expect( - screen.queryByText('Sign in only. Can’t be used to sync.') - ).toBeInTheDocument(); - }); - - it('does not render message when canSync is true', () => { - renderPasskeySubRow(); - expect( - screen.queryByText('Sign in only. Can’t be used to sync.') - ).not.toBeInTheDocument(); - }); - it('opens modal when delete button is clicked', async () => { renderPasskeySubRow(); const deleteButtons = screen.getAllByTitle(/Delete passkey/); diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx index 25733106782..a5c3cd6d046 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx @@ -99,7 +99,7 @@ const SubRow = ({ return (
}
{localizedDescription && ( -

+

{localizedDescription}{' '} {!localizedInfoMessage && linkExternalProps && }

@@ -348,7 +348,7 @@ type Passkey = { name: string; createdAt: number; lastUsed?: number; - canSync: boolean; + prfEnabled: boolean; }; export type PasskeySubRowProps = { @@ -440,19 +440,10 @@ export const PasskeySubRow = ({ <> - } + icon={} localizedRowTitle={passkey.name} localizedDescription={localizedDescription} - {...(!passkey.canSync && { - statusIcon: 'alert', - message: ( - -

Sign in only. Can’t be used to sync.

-
- ), - })} + // TODO (passkeys phase 2): show upgrade prompt when passkey.prfEnabled onDeleteClick={(event) => { event.stopPropagation(); revealDeleteModal(); diff --git a/packages/fxa-settings/src/components/Settings/UnitRow/index.tsx b/packages/fxa-settings/src/components/Settings/UnitRow/index.tsx index b4441272666..b787d11d9cf 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRow/index.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRow/index.tsx @@ -209,7 +209,7 @@ export const UnitRow = ({ secondaryCtaRoute || revealSecondaryModal) && (
-
+
{/* Primary Action */} {!hideCtaText && ctaText && diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/en.ftl b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/en.ftl index e21ee818bc0..ae7792d816f 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/en.ftl +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/en.ftl @@ -5,7 +5,13 @@ passkey-row-enabled = Enabled passkey-row-not-set = Not set passkey-row-action-create = Create passkey-row-description = Make sign in easier and more secure by using your phone or other supported device to get into your account. -# External link to a support article. "This" refers to passkeys. -passkey-row-info-link = How this protects your account +# External link to a support article about passkeys. +passkey-row-info-link-2 = Learn more +# Shown as a warning banner when the user has registered the maximum number of passkeys. +# Variables: +# $count (Number) - the maximum number of passkeys allowed (defaults to 10 allowed) +passkey-row-max-limit-banner = You’ve used all { $count } passkeys. Delete a passkey to create a new one. +# Tooltip shown on the disabled Create button when the passkey limit is reached +passkey-row-max-limit-disabled-reason = You’ve reached the maximum number of passkeys. ## diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx index 171b70b37aa..ee177be934f 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx @@ -23,21 +23,21 @@ const mockPasskeys = [ name: 'MacBook Pro', createdAt: new Date('2026-01-01').getTime(), lastUsed: new Date('2026-02-01').getTime(), - canSync: false, + prfEnabled: false, }, { id: 'passkey-2', name: 'iPhone 15', createdAt: new Date('2025-12-01').getTime(), lastUsed: new Date('2026-01-31').getTime(), - canSync: true, + prfEnabled: true, }, { id: 'passkey-3', name: 'Work Laptop', createdAt: new Date('2025-11-01').getTime(), lastUsed: undefined, - canSync: false, + prfEnabled: false, }, ]; @@ -59,10 +59,18 @@ const storyWithPasskeys = (passkeys: Passkey[]) => { export const NoPasskeys = storyWithPasskeys([]); -export const WithPasskeys = storyWithPasskeys(mockPasskeys); - export const SinglePasskey = storyWithPasskeys([mockPasskeys[0]]); export const WithNeverUsedPasskey = storyWithPasskeys([mockPasskeys[2]]); export const MultiplePasskeys = storyWithPasskeys(mockPasskeys); + +export const AtMaxPasskeys = storyWithPasskeys( + Array.from({ length: 10 }, (_, i) => ({ + id: `passkey-${i + 1}`, + name: `Passkey ${i + 1}`, + createdAt: new Date('2026-01-01').getTime(), + lastUsed: new Date('2026-02-01').getTime(), + prfEnabled: i % 2 === 0, + })) +); diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx index 22aa7ac9fdf..e3cd4241885 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx @@ -29,14 +29,14 @@ describe('UnitRowPasskey', () => { name: 'MacBook Pro', createdAt: new Date('2026-01-01').getTime(), lastUsed: new Date('2026-02-01').getTime(), - canSync: true, + prfEnabled: true, }, { id: 'passkey-2', name: 'iPhone 15', createdAt: new Date('2025-12-01').getTime(), lastUsed: new Date('2026-01-31').getTime(), - canSync: false, + prfEnabled: false, }, ]; @@ -60,9 +60,7 @@ describe('UnitRowPasskey', () => { 'Make sign in easier and more secure by using your phone or other supported device to get into your account.' ) ).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: /How this protects your account/ }) - ).toHaveAttribute( + expect(screen.getByRole('link', { name: /Learn more/ })).toHaveAttribute( 'href', 'https://support.mozilla.org/kb/placeholder-article' ); @@ -87,4 +85,22 @@ describe('UnitRowPasskey', () => { expect(screen.getByText('MacBook Pro')).toBeInTheDocument(); expect(screen.getByText('iPhone 15')).toBeInTheDocument(); }); + + it('does not show banner and Create is a link when below max', () => { + renderUnitRowPasskey(mockPasskeys); + expect(screen.queryByText(/You’ve used all/)).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Create' })).toBeInTheDocument(); + }); + + it('shows warning banner and disabled Create button when at max passkeys', () => { + const atMaxPasskeys: Passkey[] = Array.from({ length: 10 }, (_, i) => ({ + id: `passkey-${i}`, + name: `Passkey ${i}`, + createdAt: new Date('2026-01-01').getTime(), + prfEnabled: false, + })); + renderUnitRowPasskey(atMaxPasskeys); + expect(screen.getByText(/You’ve used all 10 passkeys/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); }); diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx index ef47eb38a28..f8a2a96b8b0 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx @@ -4,10 +4,11 @@ import React from 'react'; import UnitRow, { UnitRowProps } from '../UnitRow'; -import { useFtlMsgResolver } from '../../../models'; +import { useFtlMsgResolver, useConfig } from '../../../models'; import { FtlMsg } from 'fxa-react/lib/utils'; import LinkExternal from 'fxa-react/components/LinkExternal'; import { PasskeySubRow } from '../SubRow'; +import { Banner } from '../../Banner'; // TODO: Update with actual passkey data types when available export type Passkey = { @@ -15,7 +16,7 @@ export type Passkey = { name: string; createdAt: number; lastUsed?: number; - canSync: boolean; + prfEnabled: boolean; }; export type UnitRowPasskeyProps = { @@ -24,7 +25,10 @@ export type UnitRowPasskeyProps = { export const UnitRowPasskey = ({ passkeys = [] }: UnitRowPasskeyProps) => { const ftlMsgResolver = useFtlMsgResolver(); + const config = useConfig(); + const maxPasskeys = config.passkeys.maxPerUser; const hasPasskeys = passkeys.length > 0; + const isAtLimit = passkeys.length >= maxPasskeys; const conditionalUnitRowProps: Partial = hasPasskeys ? { @@ -39,19 +43,34 @@ export const UnitRowPasskey = ({ passkeys = [] }: UnitRowPasskeyProps) => { ), }; - const getSubRows = () => { - return passkeys.map((passkey) => ( - - )); - }; + const getSubRows = () => ( + <> + {isAtLimit && ( + + )} + {passkeys.map((passkey) => ( + + ))} + + ); const learnMoreLink = ( - + - How this protects your account + Learn more ); @@ -64,6 +83,11 @@ export const UnitRowPasskey = ({ passkeys = [] }: UnitRowPasskeyProps) => { prefixDataTestId="passkey" ctaText={ftlMsgResolver.getMsg('passkey-row-action-create', 'Create')} route="/settings/passkeys/add" + disabled={isAtLimit} + disabledReason={ftlMsgResolver.getMsg( + 'passkey-row-max-limit-disabled-reason', + "You've reached the maximum number of passkeys." + )} {...conditionalUnitRowProps} subRows={getSubRows()} > diff --git a/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.tsx b/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.tsx index df2e2493916..83c1fdbc108 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.tsx @@ -162,7 +162,7 @@ export const UnitRowSecondaryEmail = () => { l10n.getString( 'se-verify-session-3', null, - "You'll need to confirm your current session to perform this action" + 'You’ll need to confirm your current session to perform this action' ) ); }} diff --git a/packages/fxa-settings/src/lib/config.test.ts b/packages/fxa-settings/src/lib/config.test.ts index 3bcf11b8884..8ce88fe7950 100644 --- a/packages/fxa-settings/src/lib/config.test.ts +++ b/packages/fxa-settings/src/lib/config.test.ts @@ -159,6 +159,31 @@ describe('reset', () => { }); }); +describe('passkeys', () => { + it('can parse passkeys.maxPerUser from server config', () => { + const data = { + passkeys: { + maxPerUser: 5, + }, + }; + + readConfigMeta(() => { + return { + getAttribute() { + return encodeURIComponent(JSON.stringify(data)); + }, + }; + }); + + expect(config.passkeys).toBeDefined(); + expect(config.passkeys.maxPerUser).toBe(5); + }); + + it('defaults passkeys.maxPerUser to 10', () => { + expect(config.passkeys.maxPerUser).toBe(10); + }); +}); + describe('featureFlags', () => { it('can parse passkeysEnabled feature flag', () => { const data = { diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 9d8b13041ce..5b3637ce5fe 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -62,6 +62,9 @@ export interface Config { count: number; length: number; }; + passkeys: { + maxPerUser: number; + }; version: string; googleAuthConfig: { enabled: boolean; @@ -172,6 +175,9 @@ export function getDefault() { count: 8, length: 10, }, + passkeys: { + maxPerUser: 10, + }, googleAuthConfig: { enabled: false, clientId: '',