diff --git a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx index 32e53bf7..4dc7315c 100644 --- a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx @@ -300,6 +300,10 @@ const BaseSignUpContent: FC = ({ const initializationAttemptedRef: any = useRef(false); const passkeyProcessedRef: any = useRef(false); + // A completed flow leaves its last step rendered while the consumer navigates away, and the + // redirect is not instantaneous. Latch the completion so the still-visible submit actions cannot + // fire again: the executionId is spent, and re-submitting would start a whole new sign-up flow. + const isFlowCompletedRef = useRef(false); /** * Restore any challenge token persisted before an OAuth redirect. @@ -558,6 +562,7 @@ const BaseSignUpContent: FC = ({ return; } + isFlowCompletedRef.current = true; onComplete?.(response); }; @@ -773,7 +778,7 @@ const BaseSignUpContent: FC = ({ * Handle component submission (for buttons outside forms). */ const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { - if (!currentFlow) { + if (!currentFlow || isFlowCompletedRef.current) { return; } diff --git a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx index b92a1aed..89a24243 100644 --- a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx @@ -112,8 +112,12 @@ const SignUp: FC = ({ } // For non-redirection responses (regular sign-up completion), handle redirect if configured. - // Skip when assertion is present — the SDK stored the session and the caller handled navigation. - if (response?.type !== EmbeddedSignUpFlowType.Redirection && afterSignUpUrl && !(response as any)?.assertion) { + // A completion carrying an assertion redirects too. The stored session is a reason to leave the + // flow, not to stay on it: the flow is over, so remaining on the finished step strands the user + // on a form whose submit actions no longer lead anywhere. Consumers that navigate themselves + // opt out through shouldRedirectAfterSignUp, and one that passes no afterSignUpUrl is never + // redirected here. + if (response?.type !== EmbeddedSignUpFlowType.Redirection && afterSignUpUrl) { window.location.href = afterSignUpUrl; } }; diff --git a/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx b/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx new file mode 100644 index 00000000..ddbce5d9 --- /dev/null +++ b/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx @@ -0,0 +1,128 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, waitFor, cleanup, act} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import I18nProvider from '../../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../../contexts/ThunderID/ThunderIDContext'; +import SignUp from '../SignUp'; + +const mockSignUp = vi.fn() as Mock; + +const thunderIDContext: ThunderIDContextProps = { + applicationId: 'app-1', + getStorageManager: vi.fn(() => + Promise.resolve({ + getTemporaryData: vi.fn(() => Promise.resolve({})), + removeTemporaryDataParameter: vi.fn(), + setTemporaryDataParameter: vi.fn(), + }), + ), + isInitialized: true, + isLoading: false, + signUp: mockSignUp, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +/** + * Renders SignUp with a render-prop child that captures handleSubmit, so a step can be submitted + * without depending on the default UI. Navigation is disabled unless a test opts in, because these + * tests run in a real browser and assigning window.location.href would navigate the test page. + */ +const renderSignUp = (props: Record = {}) => { + const captured: {submit?: (component: unknown, data?: unknown, skipValidation?: boolean) => Promise} = {}; + + render( + + + + + {({handleSubmit}: {handleSubmit: typeof captured.submit}) => { + captured.submit = handleSubmit; + return
; + }} + + + + , + ); + + return captured; +}; + +describe('SignUp', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState({}, '', '/signup'); + }); + + afterEach(() => { + cleanup(); + window.history.replaceState({}, '', '/signup'); + }); + + it('redirects to afterSignUpUrl when the completion carries an assertion', async () => { + // A URL differing from the current one only by its fragment is a same-document navigation: + // location updates without the page reloading, so the redirect can be observed from inside the + // test. Anything else would navigate the test page away and take the run with it. + const afterSignUpUrl = `${window.location.origin}${window.location.pathname}#signed-up`; + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + + const captured = renderSignUp({afterSignUpUrl, shouldRedirectAfterSignUp: true}); + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); + + mockSignUp.mockResolvedValueOnce({assertion: 'a-jwt', executionId: 'exec-1', flowStatus: 'COMPLETE'}); + await act(async () => { + await captured.submit?.({id: 'continue'}, {}, true); + }); + + await waitFor(() => { + expect(window.location.hash).toBe('#signed-up'); + }); + }); + + it('hands a completion carrying an assertion to onComplete', async () => { + const onComplete = vi.fn(); + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + + const captured = renderSignUp({onComplete}); + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); + + mockSignUp.mockResolvedValueOnce({assertion: 'a-jwt', executionId: 'exec-1', flowStatus: 'COMPLETE'}); + await act(async () => { + await captured.submit?.({id: 'continue'}, {}, true); + }); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalledTimes(1); + }); + expect(onComplete.mock.calls[0][0]).toMatchObject({flowStatus: 'COMPLETE', assertion: 'a-jwt'}); + }); + + it('refuses to submit again once the flow has completed', async () => { + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + + const captured = renderSignUp(); + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); + + mockSignUp.mockResolvedValueOnce({assertion: 'a-jwt', executionId: 'exec-1', flowStatus: 'COMPLETE'}); + await act(async () => { + await captured.submit?.({id: 'continue'}, {}, true); + }); + expect(mockSignUp).toHaveBeenCalledTimes(2); + + // The completed step stays on screen while the consumer navigates away, so its submit action + // is still clickable. Submitting again must not start a fresh flow. + await act(async () => { + await captured.submit?.({id: 'continue'}, {}, true); + }); + expect(mockSignUp).toHaveBeenCalledTimes(2); + }); +});