Skip to content

Commit b97e03d

Browse files
feat(mosaic): reverify adding passkeys (#10329)
1 parent 4c9b705 commit b97e03d

6 files changed

Lines changed: 79 additions & 23 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx‎

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -273,27 +273,62 @@ describe('Adding a passkey', () => {
273273
expect(add).toBeEnabled();
274274
});
275275

276-
it.each(['session_reverification_required', 'form_param_invalid'])(
277-
'shows %s and allows a manual retry',
278-
async code => {
279-
const fapi = servePasskeys([]);
280-
fakeAuthenticator();
281-
await renderWithClerk(<UserProfilePasskeysSection />);
282-
const creation = holdRequests('post', '/v1/me/passkeys');
283-
const user = userEvent.setup();
284-
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
285-
await waitFor(() => expect(creation.requests).toHaveLength(1));
286-
creation.fail(code);
287-
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(code));
288-
expect(screen.queryByRole('dialog')).toBeNull();
289-
expect(creation.requests).toHaveLength(1);
290-
const retried = serveFapi(fapi);
291-
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
292-
expect(await screen.findByText('Chrome on macOS')).toBeVisible();
293-
expect(retried.client.sessions[0]?.user.passkeys).toHaveLength(1);
294-
expect(screen.queryByRole('alert')).toBeNull();
295-
},
296-
);
276+
it('shows a creation error and allows a manual retry', async () => {
277+
const fapi = servePasskeys([]);
278+
fakeAuthenticator();
279+
await renderWithClerk(<UserProfilePasskeysSection />);
280+
const creation = holdRequests('post', '/v1/me/passkeys');
281+
const user = userEvent.setup();
282+
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
283+
await waitFor(() => expect(creation.requests).toHaveLength(1));
284+
creation.fail('form_param_invalid');
285+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('form_param_invalid'));
286+
expect(screen.queryByRole('dialog')).toBeNull();
287+
expect(creation.requests).toHaveLength(1);
288+
const retried = serveFapi(fapi);
289+
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
290+
expect(await screen.findByText('Chrome on macOS')).toBeVisible();
291+
expect(retried.client.sessions[0]?.user.passkeys).toHaveLength(1);
292+
expect(screen.queryByRole('alert')).toBeNull();
293+
});
294+
295+
it('verifies the session before adding a passkey, then creates it', async () => {
296+
const fapi = servePasskeys([], { verification: { secrets: { password: 'hunter2' } } });
297+
const authenticator = fakeAuthenticator();
298+
await renderWithClerk(<UserProfilePasskeysSection />);
299+
demandReverificationOnce('/v1/me/passkeys');
300+
const user = userEvent.setup();
301+
302+
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
303+
expect(await screen.findByText('Enter your current password to continue')).toBeVisible();
304+
await waitFor(() => expect(screen.getByLabelText('Password')).toHaveFocus());
305+
await user.keyboard('hunter2{Enter}');
306+
307+
expect(await screen.findByText('Chrome on macOS')).toBeVisible();
308+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
309+
expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(1);
310+
expect(authenticator).toHaveBeenCalledOnce();
311+
});
312+
313+
it('lets the user cancel passkey verification quietly and add one again', async () => {
314+
const fapi = servePasskeys([], { verification: { secrets: { password: 'hunter2' } } });
315+
const authenticator = fakeAuthenticator();
316+
await renderWithClerk(<UserProfilePasskeysSection />);
317+
demandReverificationOnce('/v1/me/passkeys');
318+
const user = userEvent.setup();
319+
320+
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
321+
await screen.findByText('Enter your current password to continue');
322+
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
323+
324+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
325+
expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(0);
326+
expect(authenticator).not.toHaveBeenCalled();
327+
expect(screen.queryByRole('alert')).toBeNull();
328+
329+
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
330+
expect(await screen.findByText('Chrome on macOS')).toBeVisible();
331+
});
297332

298333
it('uses the localized fallback when FAPI rejects creation without a message', async () => {
299334
servePasskeys([]);

‎packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { UserProfilePasskeysSectionViewProps } from './user-profile-passkey
1010

1111
type PasskeysControllerInput = Pick<
1212
Extract<UserProfilePasskeysModel, { status: 'ready' }>,
13-
'passkeys' | 'onAdd' | 'onRename' | 'validateName' | 'onRemove' | 'reverification'
13+
'passkeys' | 'onAdd' | 'onRename' | 'validateName' | 'onRemove' | 'reverification' | 'addReverification'
1414
>;
1515

1616
export function useUserProfilePasskeysSectionController({
@@ -20,6 +20,7 @@ export function useUserProfilePasskeysSectionController({
2020
validateName,
2121
onRemove,
2222
reverification,
23+
addReverification,
2324
}: PasskeysControllerInput): UserProfilePasskeysSectionViewProps {
2425
const messages = useMessages('userProfilePasskeys');
2526
const add = usePendingAction<'add'>({ errorFallback: messages.saveError });
@@ -47,5 +48,7 @@ export function useUserProfilePasskeysSectionController({
4748
registerTrigger: removalFocus.registerTrigger,
4849
remove,
4950
finalFocus: removalFocus.finalFocus,
51+
addReverification,
52+
addFinalFocus: addButtonRef,
5053
};
5154
}

‎packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type UserProfilePasskeysModel =
6565
validateName: UserProfilePasskeyNameValidator;
6666
onRemove: (id: string) => Promise<void>;
6767
reverification: ReverificationController;
68+
addReverification: ReverificationController;
6869
};
6970

7071
function getPasskeysProjection(
@@ -93,6 +94,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
9394
const locale = useLocale();
9495
const messages = useMessages('userProfilePasskeys');
9596
const [deletePasskey, reverification] = useReverificationFlow((passkey: PasskeyResource) => passkey.delete());
97+
const [createPasskey, addReverification] = useReverificationFlow((current: UserResource) => current.createPasskey());
9698
const validateName: UserProfilePasskeyNameValidator = name =>
9799
new TextEncoder().encode(name).length > 256 ? { type: 'error', message: messages.nameTooLongError } : undefined;
98100

@@ -162,7 +164,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
162164
global: { code: 'passkey_account_unavailable', message: messages.accountUnavailableError },
163165
});
164166
}
165-
await save(() => current.createPasskey());
167+
await save(() => createPasskey(current));
166168
}
167169
: undefined,
168170
onRename: async (id, name) => {
@@ -184,5 +186,6 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
184186
await save(() => deletePasskey(passkey));
185187
},
186188
reverification,
189+
addReverification,
187190
};
188191
}

‎packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.view.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import type { Ref } from 'react';
22

33
import type { ConfirmController } from '../../../blocks/confirm';
44
import { Confirmation } from '../../../blocks/confirmation';
5+
import { ReverificationDialog } from '../../../blocks/reverification';
56
import { Button } from '../../../components/button';
67
import type { DialogFocusTarget } from '../../../components/dialog';
78
import { Field } from '../../../components/field';
89
import { Icon } from '../../../components/icon';
910
import { Section } from '../../../components/section';
1011
import { Plus } from '../../../icons/glyphs/plus';
1112
import { fill, useMessages } from '../../../localization';
13+
import type { ReverificationController } from '../../reverification';
1214
import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view';
1315
import { styles } from './user-profile-passkeys-section.styles';
1416
import type { UserProfilePasskey, UserProfilePasskeyNameValidator } from './user-profile-passkeys-section.types';
@@ -27,6 +29,8 @@ export interface UserProfilePasskeysSectionViewProps {
2729
registerTrigger: (id: string) => Ref<HTMLButtonElement>;
2830
remove?: ConfirmController<UserProfilePasskey>;
2931
finalFocus?: DialogFocusTarget;
32+
addReverification?: ReverificationController;
33+
addFinalFocus?: DialogFocusTarget;
3034
}
3135

3236
export function UserProfilePasskeysSectionView({
@@ -41,6 +45,8 @@ export function UserProfilePasskeysSectionView({
4145
registerTrigger,
4246
remove,
4347
finalFocus,
48+
addReverification,
49+
addFinalFocus,
4450
}: UserProfilePasskeysSectionViewProps) {
4551
const m = useMessages('userProfilePasskeys');
4652

@@ -117,6 +123,12 @@ export function UserProfilePasskeysSectionView({
117123
finalFocus={finalFocus}
118124
/>
119125
) : null}
126+
{addReverification ? (
127+
<ReverificationDialog
128+
controller={addReverification}
129+
finalFocus={addFinalFocus}
130+
/>
131+
) : null}
120132
</>
121133
);
122134
}

‎packages/swingset/src/stories/fixtures/user-profile-passkeys.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function usePasskeysFixture({
4242
return useUserProfilePasskeysSectionController({
4343
passkeys,
4444
reverification: idleReverification,
45+
addReverification: idleReverification,
4546
onAdd: async () => {
4647
await attempt('add');
4748
const id = nextId.current++;

0 commit comments

Comments
 (0)