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
11 changes: 11 additions & 0 deletions .changeset/signup-complete-without-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@asgardeo/i18n': patch
'@asgardeo/react': patch
'@asgardeo/nextjs': patch
---

Show a success message and a sign-in button when a registration completes without signing the user in.

- `BaseSignUp` accepts a `signInUrl` prop. When the flow completes and the prop is set, the card keeps showing the "account created" message and renders a Sign In button that takes the user to that URL.
- The Next.js `<SignUp />` passes the configured `signInUrl` automatically, and the provider no longer navigates to `afterSignUpUrl` when no session was created (for example after a social sign-up, or a multi-step registration). Previously the user was sent to a protected page and bounced straight to the sign-in form without ever seeing that the account had been created. A per-component `afterSignUpUrl` prop is still honoured.
- When the host does sign the user in (the Next.js auto sign-in), the message reads "Your account has been created. Signing you in…" (new i18n key `signup.success.signing.in`) instead of leaving the user wondering what happens next.
1 change: 1 addition & 0 deletions packages/i18n/src/models/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface I18nTranslations {
'signup.heading': string;
'signup.subheading': string;
'signup.success': string;
'signup.success.signing.in': string;

/* Email OTP */
'email.otp.heading': string;
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const translations: I18nTranslations = {
'signup.heading': 'Sign Up',
'signup.subheading': 'Create a new account to get started.',
'signup.success': 'Your account has been created successfully.',
'signup.success.signing.in': 'Your account has been created. Signing you in…',

/* Email OTP */
'email.otp.heading': 'OTP Verification',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/fr-FR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': "S'inscrire",
'signup.subheading': 'Créez un nouveau compte pour commencer.',
'signup.success': 'Votre compte a été créé avec succès.',
'signup.success.signing.in': 'Votre compte a été créé. Connexion en cours…',

/* Email OTP */
'email.otp.heading': 'Vérification OTP',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/hi-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'साइन अप',
'signup.subheading': 'शुरू करने के लिए नया खाता बनाएं।',
'signup.success': 'आपका खाता सफलतापूर्वक बना दिया गया है।',
'signup.success.signing.in': 'आपका खाता बना दिया गया है। आपको साइन इन किया जा रहा है…',

/* Email OTP */
'email.otp.heading': 'OTP सत्यापन',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'サインアップ',
'signup.subheading': 'はじめるには新しいアカウントを作成してください。',
'signup.success': 'アカウントが正常に作成されました。',
'signup.success.signing.in': 'アカウントが作成されました。サインインしています…',

/* Email OTP */
'email.otp.heading': 'OTP認証',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'Cadastra-se',
'signup.subheading': 'Crie uma nova conta para iniciar.',
'signup.success': 'Sua conta foi criada com sucesso.',
'signup.success.signing.in': 'Sua conta foi criada. Entrando…',

/* Email OTP */
'email.otp.heading': 'Verificação OTP',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/pt-PT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'Registar-se',
'signup.subheading': 'Crie uma nova conta para começar.',
'signup.success': 'A sua conta foi criada com sucesso.',
'signup.success.signing.in': 'A sua conta foi criada. A iniciar sessão…',

/* Email OTP */
'email.otp.heading': 'Verificação OTP',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/si-LK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'ලියාපදිංචි වන්න',
'signup.subheading': 'ආරම්භ කිරීමට නව ගිණුමක් සාදන්න.',
'signup.success': 'ඔබගේ ගිණුම සාර්ථකව සාදන ලදී.',
'signup.success.signing.in': 'ඔබගේ ගිණුම සාදන ලදී. ඔබව පුරනය කරමින්…',

/* Email OTP */
'email.otp.heading': 'OTP සත්‍යාපනය',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/ta-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'பதிவு செய்',
'signup.subheading': 'தொடங்க புதிய கணக்கை உருவாக்கவும்.',
'signup.success': 'உங்கள் கணக்கு வெற்றிகரமாக உருவாக்கப்பட்டது.',
'signup.success.signing.in': 'உங்கள் கணக்கு உருவாக்கப்பட்டது. உள்நுழைக்கப்படுகிறது…',

/* Email OTP */
'email.otp.heading': 'OTP சரிபார்ப்பு',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/translations/te-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const translations: I18nTranslations = {
'signup.heading': 'సైన్ అప్ చేయండి',
'signup.subheading': 'కొత్త అకౌంట్ సృష్టించండి.',
'signup.success': 'మీ ఖాతా విజయవంతంగా సృష్టించబడింది.',
'signup.success.signing.in': 'మీ ఖాతా సృష్టించబడింది. మీరు సైన్ ఇన్ చేయబడుతున్నారు…',

/* Email OTP */
'email.otp.heading': 'OTP వెరిఫికేషన్',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import {
AsgardeoRuntimeError,
EmbeddedFlowExecuteRequestPayload,
EmbeddedFlowExecuteResponse,
EmbeddedFlowStatus,
EmbeddedFlowType,
} from '@asgardeo/node';
import {BaseSignUp, BaseSignUpProps} from '@asgardeo/react';
import {FC} from 'react';
import {FC, useState} from 'react';
import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo';

/**
Expand Down Expand Up @@ -71,7 +72,10 @@ const SignUp: FC<SignUpProps> = ({
onError,
...rest
}: SignUpProps) => {
const {signUp} = useAsgardeo();
const {signUp, signInUrl} = useAsgardeo();
// Set when the registration completed without creating a session (e.g. a social sign-up): the card then
// shows a sign-in button instead of the user being redirected to a protected page.
const [needsManualSignIn, setNeedsManualSignIn] = useState<boolean>(false);

/**
* Initialize the sign-up flow.
Expand Down Expand Up @@ -106,13 +110,20 @@ const SignUp: FC<SignUpProps> = ({
);
}

return (await signUp(payload, undefined, {afterSignUpUrl})) as unknown as Promise<EmbeddedFlowExecuteResponse>;
const response: any = await signUp(payload, undefined, {afterSignUpUrl});

if (response?.flowStatus === EmbeddedFlowStatus.Complete && response?.signedIn === false) {
setNeedsManualSignIn(true);
}

return response as EmbeddedFlowExecuteResponse;
};

return (
<BaseSignUp
{...rest}
afterSignUpUrl={afterSignUpUrl}
signInUrl={needsManualSignIn ? signInUrl : undefined}
onInitialize={handleInitialize}
onSubmit={handleOnSubmit}
onError={onError}
Expand Down
25 changes: 17 additions & 8 deletions packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,23 +266,32 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
if (result?.data?.afterSignUpUrl) {
const {afterSignUpUrl, autoSignInSkippedReason, signedIn, ...flowResponse}: any = result.data;

// A URL passed by the caller (e.g. the `afterSignUpUrl` prop of `<SignUp />`) wins over the configured one.
navigateTo(router, options?.afterSignUpUrl || afterSignUpUrl);

if (signedIn) {
// A URL passed by the caller (e.g. the `afterSignUpUrl` prop of `<SignUp />`) wins over the configured one.
navigateTo(router, options?.afterSignUpUrl || afterSignUpUrl);
// A session cookie was set during sign-up; re-render server components so the signed-in state is picked up.
router.refresh();
} else if (autoSignInSkippedReason) {
// Make the fallback visible where developers look first, not only in the server log.
} else if (options?.afterSignUpUrl) {
// The caller chose where to go next; honour it even without a session.
navigateTo(router, options.afterSignUpUrl);
} else {
// No session was created (e.g. a social sign-up, or a multi-step registration), and the configured
// `afterSignUpUrl` is normally a protected page that would only bounce the user to the sign-in page.
// Stay on the sign-up form instead: it shows the success message and a link to sign in.
logger.warn(
`[AsgardeoClientProvider] The user was registered but not signed in automatically: ${autoSignInSkippedReason} ` +
'They will have to sign in manually.',
'The sign-up form shows a success message and a sign-in button instead of redirecting.',
);
}

// Hand the completed flow back to the caller (e.g. `<SignUp />`) so it can finish its lifecycle
// while the navigation is in flight, instead of receiving `undefined` and crashing.
return {...flowResponse, flowStatus: flowResponse.flowStatus ?? EmbeddedFlowStatus.Complete};
// while the navigation is in flight, instead of receiving `undefined` and crashing. `signedIn` tells
// it whether the user still has to sign in manually.
return {
...flowResponse,
flowStatus: flowResponse.flowStatus ?? EmbeddedFlowStatus.Complete,
signedIn: !!signedIn,
};
}

if (result?.error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,89 @@ describe('BaseSignUp (v1) after a server-side validation error', () => {
expect(container.textContent).not.toContain('validations.required.field.error');
});
});

describe('BaseSignUp (v1) when the registration completes without a session', () => {
afterEach(() => cleanup());

it('shows the success message and a sign-in button pointing at signInUrl', async () => {
const assign = vi.fn();
vi.stubGlobal('location', {...window.location, assign});
const onSubmit = vi.fn().mockResolvedValue({
flowId: 'flow-1',
flowStatus: 'COMPLETE',
flowType: 'REGISTRATION',
type: 'VIEW',
data: {},
});

const {container} = render(
<BaseSignUp
isInitialized
signInUrl="/sign-in"
onInitialize={vi.fn().mockResolvedValue(registrationStep())}
onSubmit={onSubmit}
/>,
);

const submit = await waitFor(() => {
const button = container.querySelector('form button[type="submit"]') as HTMLButtonElement | null;
expect(button).not.toBeNull();
return button as HTMLButtonElement;
});
fireEvent.change(container.querySelector('input[name="http://wso2.org/claims/username"]') as HTMLInputElement, {
target: {value: 'sdk-test@example.com'},
});
fireEvent.change(container.querySelector('input[name="password"]') as HTMLInputElement, {
target: {value: 'Str0ng!Passw0rd'},
});
fireEvent.click(submit);

// The form is gone, the success message is shown, and the sign-in button takes the user to signInUrl.
await waitFor(() => expect(container.textContent).toContain('signup.success'));
expect(container.querySelector('form')).toBeNull();
const signIn = Array.from(container.querySelectorAll('button')).find((button: HTMLButtonElement) =>
button.textContent?.includes('elements.buttons.signin.text'),
) as HTMLButtonElement | undefined;
expect(signIn).toBeDefined();
fireEvent.click(signIn as HTMLButtonElement);
expect(assign).toHaveBeenCalledWith('/sign-in');
vi.unstubAllGlobals();
});

it('says that the user is being signed in when the host created a session', async () => {
const onSubmit = vi.fn().mockResolvedValue({
flowId: 'flow-1',
flowStatus: 'COMPLETE',
flowType: 'REGISTRATION',
type: 'VIEW',
data: {},
signedIn: true,
});

// `signInUrl` is set as well: the button must still stay hidden because the host signed the user in.
const {container} = render(
<BaseSignUp
isInitialized
signInUrl="/sign-in"
onInitialize={vi.fn().mockResolvedValue(registrationStep())}
onSubmit={onSubmit}
/>,
);

const submit = await waitFor(() => {
const button = container.querySelector('form button[type="submit"]') as HTMLButtonElement | null;
expect(button).not.toBeNull();
return button as HTMLButtonElement;
});
fireEvent.change(container.querySelector('input[name="http://wso2.org/claims/username"]') as HTMLInputElement, {
target: {value: 'sdk-test@example.com'},
});
fireEvent.change(container.querySelector('input[name="password"]') as HTMLInputElement, {
target: {value: 'Str0ng!Passw0rd'},
});
fireEvent.click(submit);

await waitFor(() => expect(container.textContent).toContain('signup.success.signing.in'));
expect(container.textContent).not.toContain('elements.buttons.signin.text');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {useForm, FormField} from '../../../../../hooks/useForm';
import useTranslation from '../../../../../hooks/useTranslation';
import resolveFlowErrorMessage from '../../../../../utils/resolveFlowErrorMessage';
import AlertPrimitive from '../../../../primitives/Alert/Alert';
import ButtonPrimitive from '../../../../primitives/Button/Button';
// eslint-disable-next-line import/no-named-as-default
import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card';
import Logo from '../../../../primitives/Logo/Logo';
Expand Down Expand Up @@ -205,6 +206,13 @@ export interface BaseSignUpProps {
*/
showTitle?: boolean;

/**
* URL of the sign-in page. When the registration completes without signing the user in
* (for example after a social sign-up), a sign-in button pointing here is shown under the
* success message so the user is not left on a finished form.
*/
signInUrl?: string;

/**
* Size variant for the component.
*/
Expand Down Expand Up @@ -236,6 +244,7 @@ const resolveAlertVariant = (type?: string): 'success' | 'error' | 'warning' | '

const BaseSignUpContent: FC<BaseSignUpProps> = ({
afterSignUpUrl,
signInUrl,
onInitialize,
onSubmit,
onError,
Expand Down Expand Up @@ -276,6 +285,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
const [isLoading, setIsLoading] = useState(false);
const [isFlowInitialized, setIsFlowInitialized] = useState(false);
const [isFlowComplete, setIsFlowComplete] = useState(false);
const [isSignedIn, setIsSignedIn] = useState(false);
const [currentFlow, setCurrentFlow] = useState<EmbeddedFlowExecuteResponse | null>(null);

const initializationAttemptedRef: any = useRef(false);
Expand All @@ -289,8 +299,12 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
setCurrentFlow(response);
setIsFlowComplete(true);
clearMessages();
// Hosts that sign the user in right after registration (e.g. Next.js) flag it on the response,
// so the message can say what happens next instead of leaving the user on a finished form.
const isSigningIn: boolean = (response as EmbeddedFlowExecuteResponse & {signedIn?: boolean}).signedIn === true;
setIsSignedIn(isSigningIn);
addMessage({
message: t('signup.success'),
message: t(isSigningIn ? 'signup.success.signing.in' : 'signup.success'),
type: 'success',
});
onComplete?.(response);
Expand Down Expand Up @@ -857,6 +871,20 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
))}
</div>
)}
{isFlowComplete && !isSignedIn && signInUrl && (
<div className={styles.contentContainer}>
<ButtonPrimitive
type="button"
fullWidth
className={buttonClassName}
onClick={(): void => {
window.location.assign(signInUrl);
}}
>
{t('elements.buttons.signin.text')}
</ButtonPrimitive>
</div>
)}
{!isFlowComplete && (
<div className={styles.contentContainer}>
{currentFlow.data?.components && currentFlow.data.components.length > 0 ? (
Expand Down
Loading