Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/fxa-content-server/server/lib/beta-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
8 changes: 8 additions & 0 deletions packages/fxa-content-server/server/lib/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -143,6 +144,9 @@ function getIndexRouteDefinition(config) {
enabled: CMS_ENABLED,
l10nEnabled: CMS_L10N_ENABLED,
},
passkeys: {
maxPerUser: PASSKEYS_MAX_PER_USER,
},
Comment on lines +147 to +149
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds passkeys.maxPerUser to the config rendered for the root React app, but /settings gets its injected config from server/lib/beta-settings.js (settingsConfig). Unless settingsConfig is also extended to include this value, fxa-settings will always use its default (10) and won’t reflect PASSKEYS__MAX_PASSKEYS_PER_USER as described in the PR.

Copilot uses AI. Check for mistakes.
nimbus: {
enabled: NIMBUS_ENABLED,
preview: NIMBUS_PREVIEW,
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-settings/src/components/Banner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const Banner = ({
link,
isFancy,
bannerId,
className,
textAlignClassName = 'text-start',
iconAlignClassName = 'self-center',
}: BannerProps) => {
Expand All @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-settings/src/components/Banner/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@ export type BannerProps = {
link?: BannerLinkProps;
isFancy?: boolean;
bannerId?: string;
className?: string;
iconAlignClassName?: 'self-start' | 'self-center';
Comment on lines 26 to 30
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BannerProps JSDoc lists supported props, but the newly added className prop isn’t documented. Please update the doc comment so it’s clear callers can override spacing/margins via className.

Copilot uses AI. Check for mistakes.
textAlignClassName?: 'text-start' | 'text-center';
};
Expand Down
3 changes: 0 additions & 3 deletions packages/fxa-settings/src/components/Settings/SubRow/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}}
/>
);
Expand All @@ -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,
}}
/>
);
Expand All @@ -162,7 +162,7 @@ export const PasskeyNeverUsed: StoryFn = () => (
id: '3',
name: 'Windows PC',
createdAt: new Date('2025-11-01').getTime(),
canSync: true,
prfEnabled: true,
}}
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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/);
Expand Down
19 changes: 5 additions & 14 deletions packages/fxa-settings/src/components/Settings/SubRow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const SubRow = ({
return (
<div
className={classNames(
'flex flex-col w-full max-w-full mt-8 p-4 @mobileLandscape/unitRow:mt-4 @mobileLandscape/unitRow:rounded-lg border items-start text-sm gap-2',
'flex flex-col w-full max-w-full mt-4 px-4 py-3 @mobileLandscape/unitRow:rounded-lg border items-start text-sm gap-2',
{
'bg-grey-10 dark:bg-grey-700 border-transparent': !border,
'bg-white dark:bg-grey-700 border-grey-100 dark:border-grey-500':
Expand Down Expand Up @@ -131,7 +131,7 @@ const SubRow = ({
linkExternalProps && <ExtraInfoLink />}
</div>
{localizedDescription && (
<p className="text-sm w-full mx-2 mt-2">
<p className="text-sm w-full mt-1">
{localizedDescription}{' '}
{!localizedInfoMessage && linkExternalProps && <ExtraInfoLink />}
</p>
Expand Down Expand Up @@ -348,7 +348,7 @@ type Passkey = {
name: string;
createdAt: number;
lastUsed?: number;
canSync: boolean;
prfEnabled: boolean;
};

export type PasskeySubRowProps = {
Expand Down Expand Up @@ -440,19 +440,10 @@ export const PasskeySubRow = ({
<>
<SubRow
idPrefix="passkey"
icon={
<PasskeyIcon ariaHidden className="h-8 w-5 ms-2 text-purple-600" />
}
icon={<PasskeyIcon ariaHidden className="h-8 w-5 text-purple-600" />}
localizedRowTitle={passkey.name}
localizedDescription={localizedDescription}
{...(!passkey.canSync && {
statusIcon: 'alert',
message: (
<FtlMsg id="passkey-sub-row-sign-in-only">
<p>Sign in only. Can’t be used to sync.</p>
</FtlMsg>
),
})}
// TODO (passkeys phase 2): show upgrade prompt when passkey.prfEnabled
onDeleteClick={(event) => {
event.stopPropagation();
revealDeleteModal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export const UnitRow = ({
secondaryCtaRoute ||
revealSecondaryModal) && (
<div className="unit-row-actions @mobileLandscape/unitRow:flex-1 @mobileLandscape/unitRow:flex @mobileLandscape/unitRow:justify-end ">
<div className="flex items-center h-8 gap-2 mt-2 @mobileLandscape/unitRow:mt-0 ">
<div className="flex items-center h-8 gap-2 mt-4 @mobileLandscape/unitRow:mt-0 ">
{/* Primary Action */}
{!hideCtaText &&
ctaText &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

##
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

Expand All @@ -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,
}))
);
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

Expand All @@ -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'
);
Expand All @@ -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();
});
});
Loading
Loading