Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({

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.
Expand Down Expand Up @@ -558,6 +562,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
return;
}

isFlowCompletedRef.current = true;
onComplete?.(response);
};

Expand Down Expand Up @@ -773,7 +778,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
* Handle component submission (for buttons outside forms).
*/
const handleSubmit = async (component: any, data?: Record<string, any>, skipValidation?: boolean): Promise<void> => {
if (!currentFlow) {
if (!currentFlow || isFlowCompletedRef.current) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,12 @@ const SignUp: FC<SignUpProps> = ({
}

// 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;
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
const captured: {submit?: (component: unknown, data?: unknown, skipValidation?: boolean) => Promise<void>} = {};

render(
<ThunderIDContext.Provider value={thunderIDContext}>
<I18nProvider>
<ThemeProvider>
<SignUp shouldRedirectAfterSignUp={false} {...props}>
{({handleSubmit}: {handleSubmit: typeof captured.submit}) => {
captured.submit = handleSubmit;
return <div />;
}}
</SignUp>
</ThemeProvider>
</I18nProvider>
</ThunderIDContext.Provider>,
);

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);
});
});
Loading