From cea431f7f3ff76c16b5b95ed50319ca0da774d13 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Fri, 28 Aug 2026 20:42:27 -0400 Subject: [PATCH] feat(ui,clerk-js,shared): render the setup-passkey session task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clerk's backend can now offer passkey enrolment right after sign-up, by returning a `pending` session carrying a new `setup-passkey` task. No SDK knows that key, and an unknown task key is not handled gracefully: `getTaskEndpoint` builds `/tasks/undefined`, the SessionTasks router matches no route, and the user is left on a pending session they cannot clear — unable to use the app at all. This adds the client half. A new TaskSetupPasskey card registers a passkey through the existing `/v1/me/passkeys` endpoints, and `Session.skipTask` declines the offer. `SUPPORTED_FAPI_VERSION` moves to 2026-08-20, which is the version the backend gates the task behind. 1. `skipTask` is public API, not internal. For `setup-mfa` a headless flow clears the task implicitly by calling the ordinary TOTP endpoints, but an optional task has no side-effect equivalent — skipping is the only way to clear it, so custom sign-up flows need it or their users get stuck in `pending` permanently. 2. The instance chooses `off`, `optional` or `required` via `passkey_settings.prompt_at_sign_up`, read from the environment. Only an explicit `required` removes the decline button, so stale settings can never trap a user in a task they cannot clear. 3. On a device with no platform authenticator, `optional` silently skips the task and never renders the card. `required` cannot skip, so it shows an explanatory dead end rather than a spinner. 4. Required mode uses its own subtitle. The offer framing ("Next time, sign in with your fingerprint") reads as an invitation, which is misleading on a step the user cannot decline. Part of CORE-3729 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VdvBsr6erhXVy4edPejNGx --- .changeset/optional-setup-passkey-task.md | 27 ++ .../clerk-js/src/core/resources/Session.ts | 19 ++ .../src/core/resources/UserSettings.ts | 1 + .../core/resources/__tests__/Session.test.ts | 76 +++++ packages/localizations/src/en-US.ts | 18 ++ packages/msw/SessionService.ts | 2 + .../shared/src/internal/clerk-js/constants.ts | 2 +- .../src/internal/clerk-js/sessionTasks.ts | 1 + packages/shared/src/types/clerk.ts | 12 + packages/shared/src/types/localization.ts | 16 + packages/shared/src/types/session.ts | 10 +- packages/shared/src/types/userSettings.ts | 13 + .../ui/src/components/SessionTasks/index.tsx | 9 + .../__tests__/TaskSetupPasskey.test.tsx | 166 ++++++++++ .../tasks/TaskSetupPasskey/index.tsx | 283 ++++++++++++++++++ .../src/contexts/ClerkUIComponentsContext.tsx | 12 + .../src/contexts/components/SessionTasks.ts | 20 +- packages/ui/src/elements/contexts/index.tsx | 4 +- packages/ui/src/types.ts | 11 +- 19 files changed, 696 insertions(+), 6 deletions(-) create mode 100644 .changeset/optional-setup-passkey-task.md create mode 100644 packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/__tests__/TaskSetupPasskey.test.tsx create mode 100644 packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/index.tsx diff --git a/.changeset/optional-setup-passkey-task.md b/.changeset/optional-setup-passkey-task.md new file mode 100644 index 00000000000..966ac2c3cb8 --- /dev/null +++ b/.changeset/optional-setup-passkey-task.md @@ -0,0 +1,27 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add the `setup-passkey` session task, offered after sign-up so a new user can enroll a passkey before continuing. It is never re-offered on sign-in. + +Whether the task can be declined is per-instance, from the new `user_settings.passkey_settings.prompt_at_sign_up` setting: `off` creates no task, `optional` creates one the user may skip, and `required` creates one that must be resolved by enrolling a passkey. The task payload itself carries only its key, so clients read the mode from the environment. An absent or empty value means `off`, since settings cached before the field existed do not carry it. + +New public API on `Session`: + +```ts +await clerk.session.skipTask('setup-passkey'); +``` + +`skipTask(taskKey)` posts to `POST /v1/client/sessions/{sessionId}/tasks/{taskKey}/skip` and returns the updated session, whose `currentTask` has advanced to the next pending task. It is typed against the `SessionTask['key']` union, and it is the only way to clear an optional task, so custom (headless) sign-up flows need it to let a user decline. A task that cannot be skipped — a `required` passkey prompt, or `reset-password`, `setup-mfa` and `choose-organization` — is rejected with a `session_task_not_skippable` API error, which is thrown to the caller. + +Also included: + +- `SessionTask['key']` now includes `'setup-passkey'`, routed at `/tasks/setup-passkey`. +- `PasskeySettingsData` gains `prompt_at_sign_up`, typed as the new `PasskeyPromptAtSignUp` union. +- The prebuilt task card explains what a passkey is for readers who have never met one. In `optional` mode it pairs "Create a passkey" with a visible "Not now", and a device with no platform authenticator never sees the card at all — the task is declined automatically before anything renders. In `required` mode there is no decline affordance, the subtitle states the requirement rather than framing the task as an offer, and a device that cannot create a passkey gets an explicit dead-end explanation instead of a silent skip that the Frontend API would reject anyway. +- Cancelling the OS WebAuthn dialog returns to the card rather than stranding the flow. +- Copy lives under the new `taskSetupPasskey` localization keys. +- `SUPPORTED_FAPI_VERSION` moves to `2026-08-20`, the Frontend API version that emits this task. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 981a30a6f6c..f16ed5524a8 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -141,6 +141,25 @@ export class Session extends BaseResource implements SessionResource { return this; }; + /** + * Declines an optional session task so the session can move on to the next one. + * + * Only optional tasks can be skipped. A required task is rejected by the Frontend API with a + * `session_task_not_skippable` error, which is thrown to the caller rather than swallowed. + * + * The response piggybacks the client, so the client resource is refreshed as part of the + * request and `currentTask` ends up pointing at the next pending task (or nothing at all). + */ + skipTask = async (taskKey: SessionTask['key']): Promise => { + // `path()` percent-encodes its argument, so the nested segments are joined by hand. + const json = await BaseResource._fetch({ + method: 'POST', + path: `${this.path('tasks')}/${encodeURIComponent(taskKey)}/skip`, + }); + + return this.fromJSON((json?.response || json) as SessionJSON); + }; + /** * Internal method to touch the session without updating the client or explicitly emitting the TokenUpdate event. * diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 86c928f6d74..521d64f1d26 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -112,6 +112,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { passkeySettings: PasskeySettingsData = { allow_autofill: false, show_sign_in_button: false, + prompt_at_sign_up: 'off', }; passwordSettings: PasswordSettingsData = {} as PasswordSettingsData; signIn: SignInData = { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 33ce91597e1..8486bcceb0c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1092,6 +1092,82 @@ describe('Session', () => { }); }); + describe('skipTask()', () => { + const mockSessionData = { + status: 'pending', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + tasks: [{ key: 'setup-passkey' }, { key: 'choose-organization' }], + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as unknown as SessionJSON; + + beforeEach(() => { + BaseResource.clerk = clerkMock(); + }); + + afterEach(() => { + BaseResource.clerk = null as any; + }); + + it('posts to the task skip endpoint without a body', async () => { + const session = new Session(mockSessionData); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockResolvedValue({ payload: { response: mockSessionData }, status: 200 }); + + await session.skipTask('setup-passkey'); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sessions/session_1/tasks/setup-passkey/skip', + }), + expect.anything(), + ); + expect(requestSpy.mock.calls[0][0].body).toBeUndefined(); + }); + + it('advances currentTask from the response', async () => { + const session = new Session(mockSessionData); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockResolvedValue({ + payload: { response: { ...mockSessionData, tasks: [{ key: 'choose-organization' }] } }, + status: 200, + }); + + expect(session.currentTask).toEqual({ key: 'setup-passkey' }); + + await session.skipTask('setup-passkey'); + + expect(session.currentTask).toEqual({ key: 'choose-organization' }); + }); + + // A required task cannot be skipped, and the rejection must reach the caller so the UI can + // surface it instead of silently pretending the task is gone. + it('propagates the API error for a task that cannot be skipped', async () => { + const session = new Session(mockSessionData); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockResolvedValue({ + payload: { + errors: [ + { + code: 'session_task_not_skippable', + message: 'Task is not skippable', + long_message: 'This session task cannot be skipped.', + }, + ], + }, + status: 400, + }); + + await expect(session.skipTask('setup-mfa')).rejects.toThrow(ClerkAPIResponseError); + expect(session.currentTask).toEqual({ key: 'setup-passkey' }); + }); + }); + describe('isAuthorized()', () => { it('user with permission to delete the organization should be able to delete the organization', async () => { const session = new Session({ diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 2126dc5cbef..73a5c0b8204 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1756,6 +1756,24 @@ export const enUS: LocalizationResource = { }, }, }, + taskSetupPasskey: { + formButtonPrimary: 'Create a passkey', + formButtonSkip: 'Not now', + infoText: 'Your passkey never leaves this device, so there is nothing to remember and nothing to leak.', + signOut: { + actionLink: 'Sign out', + actionText: 'Signed in as {{identifier}}', + }, + subtitle: 'Next time, sign in with your fingerprint, face, or screen lock instead of a password.', + subtitle__required: + 'Your account requires a passkey to finish signing up. You will sign in with your fingerprint, face, or screen lock instead of a password.', + title: 'Set up a passkey', + unsupportedDevice: { + subtitle: + 'This account requires a passkey, but this browser or device cannot create one. Sign in again from a device that unlocks with a fingerprint, face, or screen lock.', + title: "This device can't create a passkey", + }, + }, unstable__errors: { action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", already_a_member_in_organization: '{{email}} is already a member of the organization.', diff --git a/packages/msw/SessionService.ts b/packages/msw/SessionService.ts index 39acd4897c4..ecaba6f728c 100644 --- a/packages/msw/SessionService.ts +++ b/packages/msw/SessionService.ts @@ -30,6 +30,7 @@ const EXCLUDED_KEYS = new Set([ 'prepareSecondFactorVerification', 'remove', 'resolve', + 'skipTask', 'startVerification', 'touch', 'verifyWithPasskey', @@ -110,6 +111,7 @@ export class SessionService { prepareFirstFactorVerification: async () => ({}) as any, prepareSecondFactorVerification: async () => ({}) as any, remove: async () => session, + skipTask: async () => session, startVerification: async () => ({}) as any, touch: async () => session, verifyWithPasskey: async () => ({}) as any, diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..799a0a2118e 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -66,7 +66,7 @@ export const SIGN_UP_MODES = { } satisfies Record; // This is the currently supported version of the Frontend API -export const SUPPORTED_FAPI_VERSION = '2026-05-12'; +export const SUPPORTED_FAPI_VERSION = '2026-08-20'; export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; diff --git a/packages/shared/src/internal/clerk-js/sessionTasks.ts b/packages/shared/src/internal/clerk-js/sessionTasks.ts index e0d0fd1e0f8..2da1a4745aa 100644 --- a/packages/shared/src/internal/clerk-js/sessionTasks.ts +++ b/packages/shared/src/internal/clerk-js/sessionTasks.ts @@ -10,6 +10,7 @@ export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record; + actionLink: LocalizationValue; + }; + }; web3SolanaWalletButtons: { connect: LocalizationValue<'walletName'>; continue: LocalizationValue<'walletName'>; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 878fd6e8ecb..61a9789552f 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -279,6 +279,14 @@ export interface SessionResource extends ClerkResource { * Updates the session's last active timestamp to the current time. This method should be called periodically to indicate ongoing user activity and prevent the session from becoming stale. The updated timestamp is used for session management and analytics purposes. */ touch: (params?: SessionTouchParams) => Promise; + /** + * Declines an optional [session task](https://clerk.com/docs/guides/configure/session-tasks) so the session can move on to the next one. + * + * Only optional tasks can be skipped. A required task (for example `reset-password`, `setup-mfa` or `choose-organization`) is rejected with a `session_task_not_skippable` API error, which is thrown rather than swallowed. + * @param taskKey - The key of the task to decline, as found on `Session.currentTask`. + * @returns The updated [`Session`](https://clerk.com/docs/reference/objects/session), whose `currentTask` is now the next pending task, if any. + */ + skipTask: (taskKey: SessionTask['key']) => Promise; /** * Gets the current user's [session token](https://clerk.com/docs/guides/sessions/session-tokens) or a [custom JWT template](https://clerk.com/docs/guides/sessions/jwt-templates). * @@ -494,7 +502,7 @@ export interface SessionTask { /** * A unique identifier for the task */ - key: 'choose-organization' | 'reset-password' | 'setup-mfa'; + key: 'choose-organization' | 'reset-password' | 'setup-mfa' | 'setup-passkey'; } /** @generateWithEmptyComment */ diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index ec5a599a2f6..7475333e743 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -88,9 +88,22 @@ export type AttackProtectionData = { }; }; +/** + * Whether a user is prompted to enroll a passkey after signing up, and whether that prompt can be declined. + * + * `off` creates no prompt at all, `optional` creates a `setup-passkey` session task the user may skip, and + * `required` creates one that must be resolved by enrolling a passkey. + */ +export type PasskeyPromptAtSignUp = 'off' | 'optional' | 'required'; + export type PasskeySettingsData = { allow_autofill: boolean; show_sign_in_button: boolean; + /** + * Optional, and possibly empty, because settings cached before this field existed do not carry it. + * Both an absent and an empty value mean `off`. + */ + prompt_at_sign_up?: PasskeyPromptAtSignUp | ''; }; export type OAuthProviders = { diff --git a/packages/ui/src/components/SessionTasks/index.tsx b/packages/ui/src/components/SessionTasks/index.tsx index 0672ff7ff6a..382fb0e7793 100644 --- a/packages/ui/src/components/SessionTasks/index.tsx +++ b/packages/ui/src/components/SessionTasks/index.tsx @@ -13,12 +13,14 @@ import { TaskChooseOrganizationContext, TaskResetPasswordContext, TaskSetupMFAContext, + TaskSetupPasskeyContext, useSessionTasksContext, } from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; import { TaskChooseOrganization } from './tasks/TaskChooseOrganization'; import { TaskResetPassword } from './tasks/TaskResetPassword'; import { TaskSetupMFA } from './tasks/TaskSetupMfa'; +import { TaskSetupPasskey } from './tasks/TaskSetupPasskey'; const SessionTasksStart = () => { const clerk = useClerk(); @@ -108,6 +110,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/__tests__/TaskSetupPasskey.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/__tests__/TaskSetupPasskey.test.tsx new file mode 100644 index 00000000000..353efdc702c --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/__tests__/TaskSetupPasskey.test.tsx @@ -0,0 +1,166 @@ +import { ClerkAPIResponseError, ClerkWebAuthnError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { mockWebAuthn, render, waitFor } from '@/test/utils'; + +import { TaskSetupPasskey } from '..'; + +const { createFixtures } = bindCreateFixtures('TaskSetupPasskey'); + +const withPendingTask = createFixtures.config(f => { + f.withUser({ + email_addresses: ['test@clerk.com'], + identifier: 'test@clerk.com', + tasks: [{ key: 'setup-passkey' }], + }); +}); + +// The task payload carries only its key, so the declinable/required split comes from the instance +// setting. The base fixture leaves `prompt_at_sign_up` unset, which is the optional-style default. +const withRequiredPasskey = createFixtures.config(f => { + f.withPasskeySettings({ prompt_at_sign_up: 'required' }); +}); + +describe('TaskSetupPasskey', () => { + // jsdom exposes no `window.PublicKeyCredential`, so anything outside `mockWebAuthn` + // is a device that cannot create a passkey. + describe('unsupported devices', () => { + // GIVEN a device without a platform authenticator + // WHEN the task mounts + // THEN it is declined on the user's behalf and the offer is never rendered + it('skips the task without ever rendering the card', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + + const { queryByText } = render(, { wrapper }); + + await waitFor(() => expect(fixtures.clerk.session?.skipTask).toHaveBeenCalledWith('setup-passkey')); + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + expect(queryByText('Set up a passkey')).not.toBeInTheDocument(); + expect(fixtures.clerk.user?.createPasskey).not.toHaveBeenCalled(); + }); + + it('falls back to a decline-only card when the automatic skip fails', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + fixtures.clerk.session?.skipTask.mockRejectedValueOnce( + new ClerkAPIResponseError('Request failed', { + data: [{ code: 'internal_server_error', message: 'Something went wrong', long_message: undefined }], + status: 500, + }), + ); + + const { findByText, queryByRole } = render(, { wrapper }); + + expect(await findByText('Set up a passkey')).toBeInTheDocument(); + expect(queryByRole('button', { name: /not now/i })).toBeInTheDocument(); + expect(queryByRole('button', { name: /create a passkey/i })).not.toBeInTheDocument(); + }); + }); + + mockWebAuthn(() => { + describe('task guard', () => { + it('does not render component without existing session task', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], identifier: 'test@clerk.com' }); + }); + + const { queryByText } = render(, { wrapper }); + + await waitFor(() => expect(queryByText('Set up a passkey')).not.toBeInTheDocument()); + expect(fixtures.clerk.session?.skipTask).not.toHaveBeenCalled(); + }); + }); + + it('renders the offer with both a register and a decline action', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + + const { findByText, getByRole, queryByText } = render(, { wrapper }); + + expect(await findByText('Set up a passkey')).toBeInTheDocument(); + // An optional task is an offer, and the copy has to read like one. + expect(queryByText(/next time, sign in/i)).toBeInTheDocument(); + expect(queryByText(/requires a passkey to finish signing up/i)).not.toBeInTheDocument(); + expect(getByRole('button', { name: /create a passkey/i })).toBeInTheDocument(); + expect(getByRole('button', { name: /not now/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /sign out/i })).toBeInTheDocument(); + expect(fixtures.clerk.session?.skipTask).not.toHaveBeenCalled(); + }); + + it('registers a passkey and moves on to the next task', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + fixtures.clerk.user?.createPasskey.mockResolvedValue({} as any); + + const { findByRole, userEvent } = render(, { wrapper }); + + await userEvent.click(await findByRole('button', { name: /create a passkey/i })); + + await waitFor(() => expect(fixtures.clerk.user?.createPasskey).toHaveBeenCalled()); + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + expect(fixtures.clerk.session?.skipTask).not.toHaveBeenCalled(); + }); + + it('declines the task when the secondary action is used', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + + const { findByRole, userEvent } = render(, { wrapper }); + + await userEvent.click(await findByRole('button', { name: /not now/i })); + + await waitFor(() => expect(fixtures.clerk.session?.skipTask).toHaveBeenCalledWith('setup-passkey')); + expect(fixtures.clerk.user?.createPasskey).not.toHaveBeenCalled(); + }); + + // GIVEN the user dismisses the OS WebAuthn dialog + // WHEN registration rejects + // THEN the card stays put with both actions still available + it('keeps the card usable when the WebAuthn dialog is cancelled', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask); + fixtures.clerk.user?.createPasskey.mockRejectedValue( + new ClerkWebAuthnError('Passkey registration was cancelled or timed out.', { + code: 'passkey_registration_cancelled', + }), + ); + + const { findByRole, findByText, getByRole, userEvent } = render(, { wrapper }); + + await userEvent.click(await findByRole('button', { name: /create a passkey/i })); + + expect(await findByText(/cancelled or timed out/i)).toBeInTheDocument(); + expect(getByRole('button', { name: /create a passkey/i })).toBeInTheDocument(); + expect(getByRole('button', { name: /not now/i })).toBeInTheDocument(); + expect(fixtures.clerk.setActive).not.toHaveBeenCalled(); + }); + + // GIVEN an instance that requires a passkey + // WHEN the card renders on a capable device + // THEN there is no way to decline, and the copy states the requirement instead of offering a choice + it('states the requirement and offers no decline affordance when the task is required', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask, withRequiredPasskey); + + const { findByRole, queryByRole, queryByText } = render(, { wrapper }); + + expect(await findByRole('button', { name: /create a passkey/i })).toBeInTheDocument(); + expect(queryByRole('button', { name: /not now/i })).not.toBeInTheDocument(); + expect(queryByText(/requires a passkey to finish signing up/i)).toBeInTheDocument(); + expect(queryByText(/next time, sign in/i)).not.toBeInTheDocument(); + expect(fixtures.clerk.session?.skipTask).not.toHaveBeenCalled(); + }); + }); + + // GIVEN an instance that requires a passkey and a device that cannot create one + // WHEN the task renders + // THEN the user gets a dead-end explanation, and the task is never auto-skipped + it('explains the dead end for a required task on an unsupported device', async () => { + const { wrapper, fixtures } = await createFixtures(withPendingTask, withRequiredPasskey); + + const { findByText, queryByRole } = render(, { wrapper }); + + expect(await findByText("This device can't create a passkey")).toBeInTheDocument(); + expect(fixtures.clerk.session?.skipTask).not.toHaveBeenCalled(); + expect(fixtures.clerk.setActive).not.toHaveBeenCalled(); + expect(queryByRole('button', { name: /create a passkey/i })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /not now/i })).not.toBeInTheDocument(); + // Signing out is the only way forward, and it stays reachable. + expect(queryByRole('link', { name: /sign out/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/index.tsx new file mode 100644 index 00000000000..29ed5c647db --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupPasskey/index.tsx @@ -0,0 +1,283 @@ +import { useClerk, useReverification, useSession, useUser } from '@clerk/shared/react'; +import { + isWebAuthnPlatformAuthenticatorSupported as isWebAuthnPlatformAuthenticatorSupportedOnWindow, + isWebAuthnSupported as isWebAuthnSupportedOnWindow, +} from '@clerk/shared/webauthn'; +import { useCallback, useEffect, useState } from 'react'; + +import { useEnvironment, useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; +import { useSessionTasksContext, useTaskSetupPasskeyContext } from '@/ui/contexts/components/SessionTasks'; +import { Button, Col, descriptors, Flow, localizationKeys, Text } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { LoadingCardContainer } from '@/ui/elements/LoadingCard'; +import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; +import { handleError } from '@/ui/utils/errorHandler'; + +import { withTaskGuardOnlyOnMount } from '../shared'; + +const TASK_KEY = 'setup-passkey'; + +/** + * `checking` runs the WebAuthn capability probe, `offer` is the regular card, `declineOnly` is the + * fallback for an optional task whose automatic skip failed, and `unsupported` is the dead end for + * a required task on a device that cannot create a passkey. + */ +type Screen = 'checking' | 'offer' | 'declineOnly' | 'unsupported'; + +const TaskSetupPasskeyInternal = () => { + const clerk = useClerk(); + const card = useCardState(); + const { user } = useUser(); + const { session } = useSession(); + const { userSettings } = useEnvironment(); + const { redirectUrlComplete } = useTaskSetupPasskeyContext(); + const { navigateOnSetActive, redirectOnActiveSession } = useSessionTasksContext(); + const createPasskey = useReverification(() => user?.createPasskey()); + const [screen, setScreen] = useState('checking'); + + // The task payload carries only its key, so whether it can be declined comes from the instance + // setting. Settings cached before the field existed leave it absent or empty; anything other than + // an explicit `required` keeps the decline affordance, so stale settings cannot trap a user. + const isRequired = (userSettings.passkeySettings.prompt_at_sign_up || 'off') === 'required'; + + // Navigation is driven from here instead of by the parent, so resolving this task lands on the + // next pending task rather than short-circuiting straight to `redirectUrlComplete`. + useEffect(() => { + if (redirectOnActiveSession) { + redirectOnActiveSession.current = false; + } + }, [redirectOnActiveSession]); + + const continueToNextTask = useCallback(async () => { + await clerk.setActive({ + session: session?.id, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive?.({ session, redirectUrlComplete, decorateUrl }); + }, + }); + }, [clerk, session?.id, navigateOnSetActive, redirectUrlComplete]); + + const declineTask = useCallback(async () => { + await clerk.session?.skipTask(TASK_KEY); + await continueToNextTask(); + }, [clerk, continueToNextTask]); + + useEffect(() => { + let isCancelled = false; + + void (async () => { + // Native hosts (Expo, Electron) swap in their own implementations. + const isWebAuthnSupported = + // @ts-expect-error - This is not a public API + (clerk.__internal_isWebAuthnSupported as (() => boolean) | undefined) ?? isWebAuthnSupportedOnWindow; + const isPlatformAuthenticatorSupported = + // @ts-expect-error - This is not a public API + (clerk.__internal_isWebAuthnPlatformAuthenticatorSupported as (() => Promise) | undefined) ?? + isWebAuthnPlatformAuthenticatorSupportedOnWindow; + + const canRegisterPasskey = isWebAuthnSupported() && (await isPlatformAuthenticatorSupported()); + + if (isCancelled) { + return; + } + + if (canRegisterPasskey) { + setScreen('offer'); + return; + } + + // A required task cannot be satisfied on this device and cannot be declined either — skipping + // would only earn a `session_task_not_skippable` 400. Say so instead of spinning or auto-skipping. + if (isRequired) { + setScreen('unsupported'); + return; + } + + // An optional task on a device without a platform authenticator is declined on the user's + // behalf, so the offer never renders. + try { + await declineTask(); + } catch (err) { + if (isCancelled) { + return; + } + // Surfacing the failure beats stranding the user on a spinner. The device still cannot + // register a passkey, so only the decline action is offered as a retry. The screen is + // switched first because `handleError` rethrows errors it does not recognise. + setScreen('declineOnly'); + handleError(err as Error, [], card.setError); + } + })(); + + return () => { + isCancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRegisterPasskey = () => + card.runAsync(async () => { + if (!user) { + return; + } + + try { + await createPasskey(); + // The Frontend API clears the task once the attempt succeeds, so refreshing the session + // is enough to pick up the next task. + await continueToNextTask(); + } catch (err) { + // Dismissing the OS dialog rejects here. Stay on the card so the user can retry or decline. + handleError(err as Error, [], card.setError); + } + }); + + const handleSkipTask = () => + card.runAsync(async () => { + try { + await declineTask(); + } catch (err) { + handleError(err as Error, [], card.setError); + } + }); + + if (screen === 'checking') { + return ( + + + + + + + + + + + ); + } + + if (screen === 'unsupported') { + return ( + + + + + + + + + {card.error} + + + + + + + + + ); + } + + return ( + + + + + + + {/* A required task is a gate, not an offer, so it must not be framed as one. */} + + + {card.error} + + + + {screen === 'offer' && ( +