Skip to content

Commit 4e21b04

Browse files
authored
Merge pull request #20355 from mozilla/FXA-13369
feat(passkeys): show max-limit banner and disable Create button when passkey limit reached
2 parents 7406c2e + e6681ea commit 4e21b04

17 files changed

Lines changed: 154 additions & 79 deletions

File tree

packages/fxa-content-server/server/lib/beta-settings.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ const settingsConfig = {
9393
count: config.get('recovery_codes.count'),
9494
length: config.get('recovery_codes.length'),
9595
},
96+
passkeys: {
97+
maxPerUser: config.get('passkeys.maxPerUser'),
98+
},
9699
mfa: {
97100
otp: {
98101
expiresInMinutes: config.get('mfa.otp.expiresInMinutes'),

packages/fxa-content-server/server/lib/configuration.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,14 @@ const conf = (module.exports = convict({
272272
env: 'PASSWORDLESS_SIGNUP_ENABLED',
273273
},
274274
},
275+
passkeys: {
276+
maxPerUser: {
277+
default: 10,
278+
doc: 'Maximum number of passkeys a single user account may register. Must stay in sync with auth-server PASSKEYS__MAX_PASSKEYS_PER_USER.',
279+
format: Number,
280+
env: 'PASSKEYS__MAX_PASSKEYS_PER_USER',
281+
},
282+
},
275283
darkMode: {
276284
enabled: {
277285
default: false,

packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function getIndexRouteDefinition(config) {
6464
const FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED = config.get(
6565
'featureFlags.passkeyAuthenticationEnabled'
6666
);
67+
const PASSKEYS_MAX_PER_USER = config.get('passkeys.maxPerUser');
6768
const DARK_MODE_ENABLED = config.get('darkMode.enabled');
6869
const GLEAN_ENABLED = config.get('glean.enabled');
6970
const GLEAN_APPLICATION_ID = config.get('glean.applicationId');
@@ -143,6 +144,9 @@ function getIndexRouteDefinition(config) {
143144
enabled: CMS_ENABLED,
144145
l10nEnabled: CMS_L10N_ENABLED,
145146
},
147+
passkeys: {
148+
maxPerUser: PASSKEYS_MAX_PER_USER,
149+
},
146150
nimbus: {
147151
enabled: NIMBUS_ENABLED,
148152
preview: NIMBUS_PREVIEW,

packages/fxa-settings/src/components/Banner/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const Banner = ({
2727
link,
2828
isFancy,
2929
bannerId,
30+
className,
3031
textAlignClassName = 'text-start',
3132
iconAlignClassName = 'self-center',
3233
}: BannerProps) => {
@@ -36,6 +37,7 @@ export const Banner = ({
3637
id={bannerId || ''}
3738
className={classNames(
3839
'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',
40+
className,
3941
textAlignClassName,
4042
textAlignClassName === 'text-center' && 'justify-center',
4143
type === 'error' && 'bg-red-100',

packages/fxa-settings/src/components/Banner/interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NotificationType } from '../../models';
1515
* @property {Animation} [animation] - Optional animation settings for the banner.
1616
* @property {DismissButtonProps} [dismissButton] - Optional properties for a dismiss button.
1717
* @property {BannerLinkProps} [link] - Optional properties for a link within the banner.
18+
* @property {string} [className] - Optional CSS class overrides, e.g. to adjust the default vertical margin.
1819
*/
1920
export type BannerProps = {
2021
type: NotificationType;
@@ -25,6 +26,7 @@ export type BannerProps = {
2526
link?: BannerLinkProps;
2627
isFancy?: boolean;
2728
bannerId?: string;
29+
className?: string;
2830
iconAlignClassName?: 'self-start' | 'self-center';
2931
textAlignClassName?: 'text-start' | 'text-center';
3032
};

packages/fxa-settings/src/components/Settings/SubRow/en.ftl

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,6 @@ passkey-sub-row-created-date = Created: { $createdDate }
4949
# $lastUsedDate (String) - a localized date string
5050
passkey-sub-row-last-used-date = Last used: { $lastUsedDate }
5151
52-
# These two sentences are referring to the passkey
53-
passkey-sub-row-sign-in-only = Sign in only. Can’t be used to sync.
54-
5552
passkey-sub-row-delete-title = Delete passkey
5653
passkey-delete-modal-heading = Delete your passkey?
5754
passkey-delete-modal-content = This passkey will be removed from your account. You’ll need to sign in using a different way.

packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const PasskeyWithSync: StoryFn = () => (
139139
name: 'MacBook Pro',
140140
createdAt: new Date('2026-01-01').getTime(),
141141
lastUsed: new Date('2026-02-01').getTime(),
142-
canSync: true,
142+
prfEnabled: true,
143143
}}
144144
/>
145145
);
@@ -151,7 +151,7 @@ export const PasskeyWithoutSync: StoryFn = () => (
151151
name: 'iPhone 14 Pro',
152152
createdAt: new Date('2025-12-01').getTime(),
153153
lastUsed: new Date('2026-01-31').getTime(),
154-
canSync: false,
154+
prfEnabled: false,
155155
}}
156156
/>
157157
);
@@ -162,7 +162,7 @@ export const PasskeyNeverUsed: StoryFn = () => (
162162
id: '3',
163163
name: 'Windows PC',
164164
createdAt: new Date('2025-11-01').getTime(),
165-
canSync: true,
165+
prfEnabled: true,
166166
}}
167167
/>
168168
);

packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
MOCK_MASKED_NATIONAL_FORMAT_PHONE_NUMBER,
1616
MOCK_MASKED_PHONE_NUMBER_WITH_COPY,
1717
} from '../../../pages/mocks';
18-
import { Passkey } from '../UnitRowPasskey';
18+
import { PasskeyRowData } from '.';
1919
import { AppContext } from '../../../models';
2020
import { mockAppContext } from '../../../models/mocks';
2121
import { mockAuthClient } from './mock';
@@ -273,7 +273,7 @@ describe('PasskeySubRow', () => {
273273
name: 'MacBook Pro',
274274
createdAt: new Date('2026-01-01').getTime(),
275275
lastUsed: new Date('2026-02-01').getTime(),
276-
canSync: true,
276+
prfEnabled: true,
277277
};
278278

279279
const mockDeletePasskey = jest.fn();
@@ -285,7 +285,7 @@ describe('PasskeySubRow', () => {
285285
});
286286

287287
const renderPasskeySubRow = (
288-
passkey: Passkey = mockPasskey,
288+
passkey: PasskeyRowData = mockPasskey,
289289
deletePasskey = mockDeletePasskey
290290
) => {
291291
return render(
@@ -312,21 +312,6 @@ describe('PasskeySubRow', () => {
312312
expect(screen.queryByText(/Last used:/)).not.toBeInTheDocument();
313313
});
314314

315-
it('renders message when canSync is false', () => {
316-
const passkeyWithoutSync = { ...mockPasskey, canSync: false };
317-
renderPasskeySubRow(passkeyWithoutSync);
318-
expect(
319-
screen.queryByText('Sign in only. Can’t be used to sync.')
320-
).toBeInTheDocument();
321-
});
322-
323-
it('does not render message when canSync is true', () => {
324-
renderPasskeySubRow();
325-
expect(
326-
screen.queryByText('Sign in only. Can’t be used to sync.')
327-
).not.toBeInTheDocument();
328-
});
329-
330315
it('opens modal when delete button is clicked', async () => {
331316
renderPasskeySubRow();
332317
const deleteButtons = screen.getAllByTitle(/Delete passkey/);

packages/fxa-settings/src/components/Settings/SubRow/index.tsx

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const SubRow = ({
9999
return (
100100
<div
101101
className={classNames(
102-
'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',
102+
'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',
103103
{
104104
'bg-grey-10 dark:bg-grey-700 border-transparent': !border,
105105
'bg-white dark:bg-grey-700 border-grey-100 dark:border-grey-500':
@@ -131,7 +131,7 @@ const SubRow = ({
131131
linkExternalProps && <ExtraInfoLink />}
132132
</div>
133133
{localizedDescription && (
134-
<p className="text-sm w-full mx-2 mt-2">
134+
<p className="text-sm w-full mt-1">
135135
{localizedDescription}{' '}
136136
{!localizedInfoMessage && linkExternalProps && <ExtraInfoLink />}
137137
</p>
@@ -342,17 +342,16 @@ export const BackupPhoneSubRow = ({
342342
);
343343
};
344344

345-
// TODO: replace with actual Passkey type when available
346-
type Passkey = {
345+
export type PasskeyRowData = {
347346
id: string;
348347
name: string;
349348
createdAt: number;
350349
lastUsed?: number;
351-
canSync: boolean;
350+
prfEnabled: boolean;
352351
};
353352

354353
export type PasskeySubRowProps = {
355-
passkey: Passkey;
354+
passkey: PasskeyRowData;
356355
// passing in as a prop for the sake of mocking.
357356
// TODO: replace with actual auth client API call
358357
deletePasskey?: (passkeyId: string) => Promise<void>;
@@ -440,19 +439,10 @@ export const PasskeySubRow = ({
440439
<>
441440
<SubRow
442441
idPrefix="passkey"
443-
icon={
444-
<PasskeyIcon ariaHidden className="h-8 w-5 ms-2 text-purple-600" />
445-
}
442+
icon={<PasskeyIcon ariaHidden className="h-8 w-5 text-purple-600" />}
446443
localizedRowTitle={passkey.name}
447444
localizedDescription={localizedDescription}
448-
{...(!passkey.canSync && {
449-
statusIcon: 'alert',
450-
message: (
451-
<FtlMsg id="passkey-sub-row-sign-in-only">
452-
<p>Sign in only. Can’t be used to sync.</p>
453-
</FtlMsg>
454-
),
455-
})}
445+
// TODO (passkeys phase 2): show upgrade prompt when passkey.prfEnabled
456446
onDeleteClick={(event) => {
457447
event.stopPropagation();
458448
revealDeleteModal();

packages/fxa-settings/src/components/Settings/UnitRow/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export const UnitRow = ({
209209
secondaryCtaRoute ||
210210
revealSecondaryModal) && (
211211
<div className="unit-row-actions @mobileLandscape/unitRow:flex-1 @mobileLandscape/unitRow:flex @mobileLandscape/unitRow:justify-end ">
212-
<div className="flex items-center h-8 gap-2 mt-2 @mobileLandscape/unitRow:mt-0 ">
212+
<div className="flex items-center h-8 gap-2 mt-4 @mobileLandscape/unitRow:mt-0 ">
213213
{/* Primary Action */}
214214
{!hideCtaText &&
215215
ctaText &&

0 commit comments

Comments
 (0)