Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signin-protect-check-status-only-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix sign-ins gated by Clerk Protect via `status: 'needs_protect_check'` without an inline `protectCheck` payload bouncing back to the flow start. `<SignInProtectCheck />` now reloads the sign-in to fetch the challenge and only falls back to the flow start when no gate exists.
33 changes: 29 additions & 4 deletions packages/ui/src/components/SignIn/SignInProtectCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
import { useRouter } from '../../router';
import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams';
import { isSignInPendingOAuthTransfer, resumeSignInAfterProtectCheck } from './handleProtectCheck';
import {
isSignInPendingOAuthTransfer,
isSignInProtectGated,
resumeSignInAfterProtectCheck,
} from './handleProtectCheck';

function SignInProtectCheckInternal(): JSX.Element | null {
const card = useCardState();
Expand All @@ -43,17 +47,37 @@ function SignInProtectCheckInternal(): JSX.Element | null {
// persist that a protect check existed at some point
const [everSawProtectCheck, setEverSawProtectCheck] = useState(!!signIn.protectCheck);
const didStartNoCheckFallbackRef = useRef(false);
// 'none' | 'pending' | 'done' — one bounded reload for a gate signalled by status alone
const [statusOnlyReload, setStatusOnlyReload] = useState<'none' | 'pending' | 'done'>('none');

if (signIn.protectCheck && !everSawProtectCheck) {
setEverSawProtectCheck(true);
}

useEffect(() => {
if (!signIn.protectCheck && !everSawProtectCheck && !didStartNoCheckFallbackRef.current) {
if (signIn.protectCheck || everSawProtectCheck) {
return;
}
// A gate signalled by `status: 'needs_protect_check'` without an inline `protectCheck`
// payload is an in-progress, server-gated sign-in (e.g. gated on an OAuth callback
// exchange) — reload to fetch the challenge; bouncing to the flow start would discard
// the sign-in and force the user to restart.
if (isSignInProtectGated(signIn) && statusOnlyReload === 'none') {
setStatusOnlyReload('pending');
void signIn
.reload()
.catch(() => {})
.finally(() => setStatusOnlyReload('done'));
return;
}
if (statusOnlyReload === 'pending') {
return;
}
if (!didStartNoCheckFallbackRef.current) {
didStartNoCheckFallbackRef.current = true;
void navigateToFlowStart();
}
}, [everSawProtectCheck, navigateToFlowStart, signIn.protectCheck]);
}, [everSawProtectCheck, navigateToFlowStart, signIn, statusOnlyReload]);

const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner<SignInResource>({
getProtectCheck: () => signIn.protectCheck,
Expand Down Expand Up @@ -101,7 +125,8 @@ function SignInProtectCheckInternal(): JSX.Element | null {
// resolves" guarantee, nor keep a spinner next to the retry button.
const showSpinner = useSpinDelay(isRunning, { delay: 300 });

// Stale/direct visit that never had a check: render nothing while the flow-start redirect
// No challenge payload yet (stale/direct visit, or the status-only reload above is in
// flight): render nothing while the flow-start redirect
// scheduled above kicks in, instead of flashing the card shell for one paint. Must stay
// below every hook call.
if (!signIn.protectCheck && !everSawProtectCheck) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,4 +617,46 @@ describe('SignInProtectCheck', () => {
await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in'));
expect(mockExecute).not.toHaveBeenCalled();
});
it('reloads a status-only gate instead of bouncing to the flow start', async () => {
// `needs_protect_check` can be signalled without an inline `protectCheck` payload
// (e.g. a gate surfacing on an OAuth callback exchange); the challenge arrives on reload.
const { wrapper, fixtures } = await createFixtures(f => {
f.startSignInWithProtectCheck();
});
(fixtures.signIn as any).protectCheck = null;
const reload = vi.fn(() => {
(fixtures.signIn as any).protectCheck = {
status: 'pending',
token: 'challenge-token',
sdkUrl: 'https://protect.example.com/sdk.js',
};
return Promise.resolve(fixtures.signIn as unknown as SignInResource);
});
(fixtures.signIn as any).reload = reload;
mockExecute.mockReturnValue(new Promise(() => {}));

const { findByText } = render(<SignInProtectCheck />, { wrapper });

await waitFor(() => expect(reload).toHaveBeenCalled());
expect(await findByText(/verifying your request/i)).toBeInTheDocument();
expect(fixtures.router.navigate).not.toHaveBeenCalled();
});

it('falls back to the flow start when the reload still carries no challenge', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.startSignInWithProtectCheck();
});
(fixtures.signIn as any).protectCheck = null;
const reload = vi.fn(() => Promise.resolve(fixtures.signIn as unknown as SignInResource));
(fixtures.signIn as any).reload = reload;
fixtures.router.currentPath = '/sign-in/protect-check';
fixtures.router.fullPath = '/sign-in';
fixtures.router.indexPath = '/sign-in';

render(<SignInProtectCheck />, { wrapper });

await waitFor(() => expect(reload).toHaveBeenCalled());
await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in'));
expect(mockExecute).not.toHaveBeenCalled();
});
});
Loading