Skip to content

Commit 7856ef9

Browse files
fix(mosaic): preserve structured passkey action errors
1 parent 939f6de commit 7856ef9

5 files changed

Lines changed: 90 additions & 73 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,35 @@ function authenticator() {
6969
});
7070
}
7171

72+
describe('Passkey action errors', () => {
73+
it('hides an unknown Add failure and updates the fallback when localization changes', async () => {
74+
serveAccounts();
75+
const view = await renderWithClerk(
76+
<MosaicProvider
77+
localization={{ messages: { userProfilePasskeys: { saveError: 'Could not add this passkey.' } } }}
78+
>
79+
<UserProfilePasskeysSection />
80+
</MosaicProvider>,
81+
);
82+
const current = view.clerk.user;
83+
if (!current) {
84+
throw new Error('Expected the signed-in user');
85+
}
86+
vi.spyOn(current, 'createPasskey').mockRejectedValue(new Error('Private authenticator details'));
87+
await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
88+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not add this passkey.'));
89+
expect(screen.queryByText('Private authenticator details')).toBeNull();
90+
view.rerender(
91+
<MosaicProvider
92+
localization={{ messages: { userProfilePasskeys: { saveError: 'Impossible de créer cette clé.' } } }}
93+
>
94+
<UserProfilePasskeysSection />
95+
</MosaicProvider>,
96+
);
97+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de créer cette clé.'));
98+
});
99+
});
100+
72101
describe('Changing the active passkey account', () => {
73102
it('clears the previous account Add error after a user switch', async () => {
74103
serveAccounts();

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,17 +422,25 @@ describe('Removing a passkey', () => {
422422
expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
423423
});
424424

425-
it('keeps confirmation open on verification-required errors and retries manually', async () => {
425+
it('keeps confirmation open on localized verification-required errors and retries manually', async () => {
426426
const fapi = servePasskeys();
427-
await renderWithClerk(<UserProfilePasskeysSection />);
427+
await renderWithClerk(
428+
<MosaicProvider
429+
localization={{
430+
messages: { errors: { session_reverification_required: 'Veuillez confirmer votre identité.' } },
431+
}}
432+
>
433+
<UserProfilePasskeysSection />
434+
</MosaicProvider>,
435+
);
428436
const removal = holdRequests('post', '/v1/me/passkeys/pk_1');
429437
const user = userEvent.setup();
430438
await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
431439
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
432440
await user.click(screen.getByRole('button', { name: 'Remove' }));
433441
await waitFor(() => expect(removal.requests).toHaveLength(1));
434442
removal.fail('session_reverification_required');
435-
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('session_reverification_required'));
443+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Veuillez confirmer votre identité.'));
436444
expect(screen.getByRole('alertdialog')).toBeVisible();
437445
expect(screen.queryByLabelText('Password')).toBeNull();
438446
expect(removal.requests).toHaveLength(1);

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { http, HttpResponse } from 'msw';
4-
import { describe, expect, it } from 'vitest';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
55

66
import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
77
import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
88
import { renderWithClerk } from '../../../__tests__/feature/render';
99
import { MosaicProvider } from '../../../mosaic-provider';
1010
import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
1111

12+
afterEach(() => vi.restoreAllMocks());
13+
1214
function servePasskeys() {
1315
const environment = fapiEnvironment();
1416
environment.user_settings.attributes.passkey.enabled = true;
@@ -21,6 +23,31 @@ function servePasskeys() {
2123
}
2224

2325
describe('Validating a passkey name', () => {
26+
it('uses the localized Form fallback for an unknown rename failure and preserves the draft', async () => {
27+
servePasskeys();
28+
const { clerk } = await renderWithClerk(
29+
<MosaicProvider localization={{ messages: { form: { error: 'Impossible de sauvegarder.' } } }}>
30+
<UserProfilePasskeysSection />
31+
</MosaicProvider>,
32+
);
33+
const passkey = clerk.user?.passkeys[0];
34+
if (!passkey) {
35+
throw new Error('Expected the signed-in passkey');
36+
}
37+
vi.spyOn(passkey, 'update').mockRejectedValue(new Error('Private native failure'));
38+
const user = userEvent.setup();
39+
await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
40+
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
41+
const input = screen.getByRole('textbox', { name: 'Passkey name' });
42+
await user.type(input, ' renamed');
43+
await user.click(screen.getByRole('button', { name: 'Save' }));
44+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de sauvegarder.'));
45+
expect(screen.queryByText('Private native failure')).toBeNull();
46+
expect(input).toHaveValue('Laptop renamed');
47+
expect(screen.getByRole('dialog')).toBeVisible();
48+
expect(input).not.toHaveAttribute('aria-invalid', 'true');
49+
});
50+
2451
it('marks localized server name errors on the field and clears them when the draft changes', async () => {
2552
servePasskeys();
2653
worker.use(

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { useMessages } from '../../../localization';
1+
import type { ErrorDescription } from '../../../localization';
2+
import { useErrorText, useMessages } from '../../../localization';
23
import { setup } from '../../../machine/setup';
34
import { useMachine } from '../../../machine/use-machine';
5+
import { toLocalizableError } from '../../../utils/errors';
46
import type { UserProfilePasskeysModel } from './user-profile-passkeys-section.model';
57

68
type PasskeysControllerInput = Pick<
@@ -10,18 +12,17 @@ type PasskeysControllerInput = Pick<
1012

1113
interface Context {
1214
run: () => Promise<unknown>;
13-
error: string | undefined;
14-
fallbackErrorMessage: string;
15+
error: ErrorDescription | undefined;
1516
}
1617

17-
type Event = { type: 'ADD'; run: () => Promise<unknown>; fallbackErrorMessage: string };
18+
type Event = { type: 'ADD'; run: () => Promise<unknown> };
1819

1920
const { createMachine, assign, fromPromise } = setup<Context, Event>();
2021

2122
const machine = createMachine({
2223
id: 'createPasskey',
2324
initial: 'idle',
24-
context: { run: () => Promise.resolve(), error: undefined, fallbackErrorMessage: '' },
25+
context: { run: () => Promise.resolve(), error: undefined },
2526
states: {
2627
idle: {
2728
on: {
@@ -30,7 +31,6 @@ const machine = createMachine({
3031
actions: assign((_, event) => ({
3132
run: event.run,
3233
error: undefined,
33-
fallbackErrorMessage: event.fallbackErrorMessage,
3434
})),
3535
},
3636
},
@@ -40,10 +40,7 @@ const machine = createMachine({
4040
onDone: { target: 'idle' },
4141
onError: {
4242
target: 'idle',
43-
actions: assign((context, event) => ({
44-
error:
45-
event.error instanceof Error && event.error.message ? event.error.message : context.fallbackErrorMessage,
46-
})),
43+
actions: assign((_, event) => ({ error: toLocalizableError(event.error) })),
4744
},
4845
}),
4946
},
@@ -58,12 +55,13 @@ export function useUserProfilePasskeysSectionController({
5855
onRemove,
5956
}: PasskeysControllerInput) {
6057
const messages = useMessages('userProfilePasskeys');
58+
const errorText = useErrorText();
6159
const [snapshot, send] = useMachine(machine);
6260
return {
6361
passkeys,
6462
isAdding: snapshot.value === 'creating',
65-
addError: snapshot.context.error,
66-
onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd, fallbackErrorMessage: messages.saveError }) : undefined,
63+
addError: snapshot.context.error ? errorText(snapshot.context.error, messages.saveError) : undefined,
64+
onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd }) : undefined,
6765
onRename,
6866
validateName,
6967
onRemove,

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

Lines changed: 12 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { formatRelative } from '@clerk/shared/date';
2-
import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error';
32
import { useClerk, useSession, useUser } from '@clerk/shared/react';
43
import type { EnvironmentResource, PasskeyResource, UserResource } from '@clerk/shared/types';
54

65
import { FormSubmitError } from '../../../components/form';
76
import { getMosaicEnvironment, useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
8-
import { fill, useErrorText, useLocale, useMessages } from '../../../localization';
7+
import { fill, useLocale, useMessages } from '../../../localization';
8+
import { save, SaveError } from '../../../utils/errors';
99
import type {
1010
UserProfilePasskey,
1111
UserProfilePasskeyNameValidator,
@@ -87,7 +87,6 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
8787
const environment = useMosaicEnvironment();
8888
const locale = useLocale();
8989
const messages = useMessages('userProfilePasskeys');
90-
const errorText = useErrorText();
9190
const validateName: UserProfilePasskeyNameValidator = name =>
9291
new TextEncoder().encode(name).length > 256 ? { type: 'error', message: messages.nameTooLongError } : undefined;
9392

@@ -115,30 +114,13 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
115114
clerk.session?.id !== sessionId ||
116115
getPasskeysProjection(current, currentEnvironment, clerk.isSatellite).status === 'hidden'
117116
) {
118-
throw new FormSubmitError({ message: messages.accountUnavailableError });
117+
throw new SaveError({
118+
global: { code: 'passkey_account_unavailable', message: messages.accountUnavailableError },
119+
});
119120
}
120121
return current;
121122
};
122123

123-
function actionError(error: unknown): Error {
124-
if (isClerkAPIResponseError(error)) {
125-
const first = error.errors[0];
126-
return new Error(
127-
errorText({
128-
code: first?.code,
129-
paramName: first?.meta?.paramName,
130-
message: first?.longMessage || first?.message || messages.saveError,
131-
}),
132-
);
133-
}
134-
return new Error(
135-
errorText({
136-
code: isClerkRuntimeError(error) ? error.code : undefined,
137-
message: error instanceof Error && error.message ? error.message : messages.saveError,
138-
}),
139-
);
140-
}
141-
142124
function formatPasskeyDate(date: Date): string {
143125
const relative = formatRelative({ date, relativeTo: new Date() });
144126
if (!relative) {
@@ -170,13 +152,11 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
170152
? async () => {
171153
const current = currentUser();
172154
if (clerk.isSatellite) {
173-
throw new Error(messages.accountUnavailableError);
174-
}
175-
try {
176-
await current.createPasskey();
177-
} catch (error) {
178-
throw actionError(error);
155+
throw new SaveError({
156+
global: { code: 'passkey_account_unavailable', message: messages.accountUnavailableError },
157+
});
179158
}
159+
await save(() => current.createPasskey());
180160
}
181161
: undefined,
182162
onRename: async (id, name) => {
@@ -188,39 +168,14 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
188168
if (feedback) {
189169
throw new FormSubmitError<UserProfileRenamePasskeyValues>({ fields: { name: feedback.message } });
190170
}
191-
try {
192-
await passkey.update({ name });
193-
} catch (error) {
194-
if (!isClerkAPIResponseError(error)) {
195-
throw new FormSubmitError({ message: actionError(error).message });
196-
}
197-
const fields: { name?: string } = {};
198-
let message: string | undefined;
199-
for (const item of error.errors) {
200-
const text = errorText({
201-
code: item.code,
202-
paramName: item.meta?.paramName,
203-
message: item.longMessage || item.message || messages.saveError,
204-
});
205-
if (item.meta?.paramName === 'name') {
206-
fields.name ??= text;
207-
} else {
208-
message ??= text;
209-
}
210-
}
211-
throw new FormSubmitError<UserProfileRenamePasskeyValues>({ message, fields });
212-
}
171+
await save(() => passkey.update({ name }), ['name']);
213172
},
214173
onRemove: async id => {
215174
const passkey = currentUser().passkeys.find(candidate => candidate.id === id);
216175
if (!passkey) {
217-
throw new Error(messages.unavailableError);
218-
}
219-
try {
220-
await passkey.delete();
221-
} catch (error) {
222-
throw actionError(error);
176+
throw new SaveError({ global: { code: 'passkey_unavailable', message: messages.unavailableError } });
223177
}
178+
await save(() => passkey.delete());
224179
},
225180
};
226181
}

0 commit comments

Comments
 (0)