From 4c948686dec6d6478dcc40ce9557c86fef009a2e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 11:46:29 -0600 Subject: [PATCH] fix(mosaic): reject when a retry is asked to reverify again useReverificationWithState resolved with the reverification hint when the retried action still required verification, so callers saw a success. It now rejects with a ClerkRuntimeError coded reverification_incomplete, which has copy in the errors catalog. --- .../mosaic-reverification-incomplete.md | 2 ++ .../use-reverification-with-state.test.tsx | 27 +++++++++++++++++++ .../use-reverification-with-state.ts | 21 ++++++++++++++- .../src/localization/errors.messages.ts | 1 + 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-reverification-incomplete.md diff --git a/.changeset/mosaic-reverification-incomplete.md b/.changeset/mosaic-reverification-incomplete.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-reverification-incomplete.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 7a0760ee2eb..7b474c53828 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -1,3 +1,4 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; import { ClerkRuntimeError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import type * as SharedReact from '@clerk/shared/react'; import type { SessionVerificationLevel } from '@clerk/shared/types'; @@ -122,6 +123,32 @@ describe('useReverificationWithState', () => { expect(challengeCancel).not.toHaveBeenCalled(); }); + it('rejects when the retry is asked to reverify again', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValueOnce(reverificationError()); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let pending!: Promise; + act(() => { + pending = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + + await act(async () => { + await expect(pending).rejects.toSatisfy( + error => isClerkRuntimeError(error) && error.code === 'reverification_incomplete', + ); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('settles when the delayed retry fails', async () => { const retry = deferred(); const fetcher = vi diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index b332f98f39d..ac6cfb4ccb2 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -1,3 +1,4 @@ +import { isReverificationHint } from '@clerk/shared/authorization-errors'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -42,6 +43,14 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { }); } +const REVERIFICATION_INCOMPLETE_CODE = 'reverification_incomplete'; + +function reverificationIncompleteError(): ClerkRuntimeError { + return new ClerkRuntimeError('The action still requires verification after it was verified.', { + code: REVERIFICATION_INCOMPLETE_CODE, + }); +} + /** * Wraps useReverification without the default UI. Returns [handler, state]: call handler * to run the action, and render your own UI from state.phase ('inactive' | 'active' | @@ -52,6 +61,9 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { * is called again, or when the third return value, reset, is called. A run that was never * interrupted stays 'inactive' throughout. * + * A retry that is asked to reverify again rejects with a ClerkRuntimeError with code + * 'reverification_incomplete', so the handler never resolves with a reverification hint. + * * The handler runs one call at a time. A call made while another is pending rejects with a * ClerkRuntimeError with code 'request_already_in_progress'. */ @@ -132,7 +144,14 @@ export function useReverificationWithState( setReverificationState(releaseSettled); - const invocation = Promise.resolve().then(() => wrapped(...args)); + const invocation = Promise.resolve() + .then(() => wrapped(...args)) + .then(result => { + if (isReverificationHint(result)) { + throw reverificationIncompleteError(); + } + return result; + }); runtimeRef.current.operation = { status: 'requesting', promise: invocation }; void invocation .finally(() => { diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index d4dff125961..40a6728b68b 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -78,6 +78,7 @@ export const errorMessages: { readonly generic: string } & Readonly