Skip to content

Commit ecab001

Browse files
Merge pull request #20380 from mozilla/FXA-13073
feat(settings): wire passkey management UI to backend
2 parents 941e09f + 02e3c30 commit ecab001

18 files changed

Lines changed: 282 additions & 182 deletions

File tree

libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('PasskeyRepository (Integration)', () => {
6262
});
6363

6464
describe('update operations', () => {
65-
it('should update counter and lastUsed after authentication', async () => {
65+
it('should update counter and lastUsedAt after authentication', async () => {
6666
const uid = await createTestAccount();
6767
const passkey = PasskeyFactory({ uid });
6868
await PasskeyRepository.insertPasskey(db, passkey);

packages/fxa-auth-server/lib/routes/account.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4819,7 +4819,7 @@ describe('/account', () => {
48194819
const result: any = await runTest(route, request);
48204820

48214821
expect(mockService.listPasskeysForUser).toHaveBeenCalledWith(
4822-
Buffer.from(uid)
4822+
Buffer.from(uid, 'hex')
48234823
);
48244824
expect(result.passkeys).toHaveLength(1);
48254825
expect(result.passkeys[0]).toEqual({

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2457,7 +2457,9 @@ export class AccountHandler {
24572457
this.db.devices(uid),
24582458
listAuthorizedClients(uid),
24592459
this.config.passkeys?.enabled
2460-
? Container.get(PasskeyService).listPasskeysForUser(Buffer.from(uid))
2460+
? Container.get(PasskeyService).listPasskeysForUser(
2461+
Buffer.from(uid, 'hex')
2462+
)
24612463
: Promise.resolve([]),
24622464
]);
24632465

packages/fxa-auth-server/lib/routes/passkeys.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('passkeys routes', () => {
4141
mockPasskeyService: any,
4242
mockFxaMailer: any;
4343

44-
const UID = 'uid-123';
44+
const UID = 'f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6';
4545
const SESSION_TOKEN_ID = 'session-token-456';
4646
const TEST_EMAIL = '[email protected]';
4747
const CREDENTIAL_ID_B64 =

packages/fxa-content-server/server/config/local.json-dist

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@
7575
"featureFlags": {
7676
"recoveryCodeSetupOnSyncSignIn": true,
7777
"showLocaleToggle": true,
78-
"paymentsNextSubscriptionManagement": true
78+
"paymentsNextSubscriptionManagement": true,
79+
"passkeysEnabled": true,
80+
"passkeyRegistrationEnabled": true,
81+
"passkeyAuthenticationEnabled": false
7982
},
8083
"darkMode": {
8184
"enabled": true

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const mockCredential = {
8585
function renderPage() {
8686
const account = {
8787
getCachedJwtByScope: jest.fn(() => 'mock-jwt'),
88+
refresh: jest.fn(),
8889
} as unknown as Account;
8990

9091
const authClient = {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const PagePasskeyAdd = () => {
7979
credential,
8080
challenge
8181
);
82+
await account.refresh('passkeys');
8283

8384
if (!isMounted.current) return;
8485

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { withLocalization, withLocation } from 'fxa-react/lib/storybooks';
1414
import { CodeIcon } from '../../Icons';
1515
import { MOCK_NATIONAL_FORMAT_PHONE_NUMBER } from '../../../pages/mocks';
1616
import { AppContext } from '../../../models';
17-
import { mockAppContext } from '../../../models/mocks';
17+
import { MOCK_ACCOUNT, mockAppContext } from '../../../models/mocks';
1818
import { initLocalAccount, mockAuthClient } from './mock';
1919

2020
export default {
@@ -25,9 +25,16 @@ export default {
2525
withLocation(),
2626
(Story) => {
2727
initLocalAccount();
28+
const mockAccount = {
29+
...MOCK_ACCOUNT,
30+
deletePasskey: async () => {},
31+
};
2832
return (
2933
<AppContext.Provider
30-
value={mockAppContext({ authClient: mockAuthClient } as any)}
34+
value={mockAppContext({
35+
authClient: mockAuthClient,
36+
account: mockAccount,
37+
} as any)}
3138
>
3239
{/* @container/unitRow on the container div allows sub row to adjust based on the size of the parent container
3340
instead of the viewport. This fixes issues with the subrow and CTAs overflowing their parent container in mobileLandscape

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

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { Passkey } from 'fxa-auth-client/browser';
1919
import { AppContext } from '../../../models';
2020
import { mockAppContext } from '../../../models/mocks';
21+
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
2122
import { mockAuthClient } from './mock';
2223
import { LocationProvider } from '@reach/router';
2324

@@ -28,6 +29,10 @@ const mockAlertBar = {
2829
info: jest.fn(),
2930
};
3031

32+
let mockAccount = {
33+
deletePasskey: jest.fn(),
34+
};
35+
3136
jest.mock('../../../lib/cache', () => ({
3237
...jest.requireActual('../../../lib/cache'),
3338
JwtTokenCache: {
@@ -42,6 +47,7 @@ jest.mock('../../../models', () => ({
4247
...jest.requireActual('../../../models'),
4348
useAuthClient: () => mockAuthClient,
4449
useAlertBar: () => mockAlertBar,
50+
useAccount: () => mockAccount,
4551
}));
4652

4753
describe('SubRow', () => {
@@ -280,22 +286,17 @@ describe('PasskeySubRow', () => {
280286
prfEnabled: true,
281287
};
282288

283-
const mockDeletePasskey = jest.fn();
284-
285289
beforeEach(() => {
286-
mockDeletePasskey.mockClear();
290+
mockAccount.deletePasskey.mockClear();
287291
mockAlertBar.success.mockClear();
288292
mockAlertBar.error.mockClear();
289293
});
290294

291-
const renderPasskeySubRow = (
292-
passkey: Passkey = mockPasskey,
293-
deletePasskey = mockDeletePasskey
294-
) => {
295+
const renderPasskeySubRow = (passkey: Passkey = mockPasskey) => {
295296
return render(
296297
<LocationProvider>
297298
<AppContext.Provider value={mockAppContext()}>
298-
<PasskeySubRow passkey={passkey} deletePasskey={deletePasskey} />
299+
<PasskeySubRow passkey={passkey} />
299300
</AppContext.Provider>
300301
</LocationProvider>
301302
);
@@ -350,7 +351,7 @@ describe('PasskeySubRow', () => {
350351
});
351352

352353
it('calls deletePasskey when confirm button is clicked', async () => {
353-
mockDeletePasskey.mockResolvedValue(undefined);
354+
mockAccount.deletePasskey.mockResolvedValue(undefined);
354355
renderPasskeySubRow();
355356

356357
const deleteButtons = screen.getAllByTitle(/Delete passkey/);
@@ -361,13 +362,11 @@ describe('PasskeySubRow', () => {
361362
const confirmButton = screen.getByTestId('confirm-delete-passkey-button');
362363
await userEvent.click(confirmButton);
363364

364-
await waitFor(() => {
365-
expect(mockDeletePasskey).toHaveBeenCalledWith('passkey-1');
366-
});
365+
expect(mockAccount.deletePasskey).toHaveBeenCalledWith('passkey-1');
367366
});
368367

369368
it('shows success banner when deletion succeeds', async () => {
370-
mockDeletePasskey.mockResolvedValue(undefined);
369+
mockAccount.deletePasskey.mockResolvedValue(undefined);
371370
renderPasskeySubRow();
372371

373372
const deleteButtons = screen.getAllByTitle(/Delete passkey/);
@@ -389,8 +388,8 @@ describe('PasskeySubRow', () => {
389388
});
390389
});
391390

392-
it('shows error banner when deletion fails', async () => {
393-
mockDeletePasskey.mockRejectedValue(new Error('Some error'));
391+
it('shows generic error banner when deletion fails with an unexpected error', async () => {
392+
mockAccount.deletePasskey.mockRejectedValue(new Error('Some error'));
394393
renderPasskeySubRow();
395394

396395
const deleteButtons = screen.getAllByTitle(/Delete passkey/);
@@ -413,4 +412,27 @@ describe('PasskeySubRow', () => {
413412
).not.toBeInTheDocument();
414413
});
415414
});
415+
416+
it('shows "Passkey not found" error when passkey no longer exists', async () => {
417+
mockAccount.deletePasskey.mockRejectedValue(AuthUiErrors.PASSKEY_NOT_FOUND);
418+
renderPasskeySubRow();
419+
420+
const deleteButtons = screen.getAllByTitle(/Delete passkey/);
421+
await userEvent.click(deleteButtons[0]);
422+
423+
expect(await screen.findByText('Delete your passkey?')).toBeInTheDocument();
424+
425+
const confirmButton = screen.getByTestId('confirm-delete-passkey-button');
426+
await userEvent.click(confirmButton);
427+
428+
await waitFor(() => {
429+
expect(mockAlertBar.error).toHaveBeenCalledWith('Passkey not found');
430+
});
431+
432+
await waitFor(() => {
433+
expect(
434+
screen.queryByText('Delete your passkey?')
435+
).not.toBeInTheDocument();
436+
});
437+
});
416438
});

0 commit comments

Comments
 (0)