diff --git a/.changeset/signup-complete-without-session.md b/.changeset/signup-complete-without-session.md new file mode 100644 index 000000000..e1820373c --- /dev/null +++ b/.changeset/signup-complete-without-session.md @@ -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 `` 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. diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index d38c62afd..252bb7e93 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -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; diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index 9017fc0e3..d317b491a 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -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', diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index 64a682aba..99a55b351 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -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', diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index fc9d81b39..bd701ec92 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -79,6 +79,7 @@ const translations: I18nTranslations = { 'signup.heading': 'साइन अप', 'signup.subheading': 'शुरू करने के लिए नया खाता बनाएं।', 'signup.success': 'आपका खाता सफलतापूर्वक बना दिया गया है।', + 'signup.success.signing.in': 'आपका खाता बना दिया गया है। आपको साइन इन किया जा रहा है…', /* Email OTP */ 'email.otp.heading': 'OTP सत्यापन', diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 2e5b3212b..bfb69dbd2 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -79,6 +79,7 @@ const translations: I18nTranslations = { 'signup.heading': 'サインアップ', 'signup.subheading': 'はじめるには新しいアカウントを作成してください。', 'signup.success': 'アカウントが正常に作成されました。', + 'signup.success.signing.in': 'アカウントが作成されました。サインインしています…', /* Email OTP */ 'email.otp.heading': 'OTP認証', diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index e9cc79315..c9eaedfdd 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -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', diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index 0b04af1bb..3b7dfac97 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -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', diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index 18368d844..60f561a77 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -79,6 +79,7 @@ const translations: I18nTranslations = { 'signup.heading': 'ලියාපදිංචි වන්න', 'signup.subheading': 'ආරම්භ කිරීමට නව ගිණුමක් සාදන්න.', 'signup.success': 'ඔබගේ ගිණුම සාර්ථකව සාදන ලදී.', + 'signup.success.signing.in': 'ඔබගේ ගිණුම සාදන ලදී. ඔබව පුරනය කරමින්…', /* Email OTP */ 'email.otp.heading': 'OTP සත්‍යාපනය', diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index ac574fbea..e035e016e 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -79,6 +79,7 @@ const translations: I18nTranslations = { 'signup.heading': 'பதிவு செய்', 'signup.subheading': 'தொடங்க புதிய கணக்கை உருவாக்கவும்.', 'signup.success': 'உங்கள் கணக்கு வெற்றிகரமாக உருவாக்கப்பட்டது.', + 'signup.success.signing.in': 'உங்கள் கணக்கு உருவாக்கப்பட்டது. உள்நுழைக்கப்படுகிறது…', /* Email OTP */ 'email.otp.heading': 'OTP சரிபார்ப்பு', diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index eb311d519..4bcf903cc 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -79,6 +79,7 @@ const translations: I18nTranslations = { 'signup.heading': 'సైన్ అప్ చేయండి', 'signup.subheading': 'కొత్త అకౌంట్ సృష్టించండి.', 'signup.success': 'మీ ఖాతా విజయవంతంగా సృష్టించబడింది.', + 'signup.success.signing.in': 'మీ ఖాతా సృష్టించబడింది. మీరు సైన్ ఇన్ చేయబడుతున్నారు…', /* Email OTP */ 'email.otp.heading': 'OTP వెరిఫికేషన్', diff --git a/packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx b/packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx index 6b7c8095d..2e74f2eb1 100644 --- a/packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx +++ b/packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx @@ -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'; /** @@ -71,7 +72,10 @@ const SignUp: FC = ({ 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(false); /** * Initialize the sign-up flow. @@ -106,13 +110,20 @@ const SignUp: FC = ({ ); } - return (await signUp(payload, undefined, {afterSignUpUrl})) as unknown as Promise; + const response: any = await signUp(payload, undefined, {afterSignUpUrl}); + + if (response?.flowStatus === EmbeddedFlowStatus.Complete && response?.signedIn === false) { + setNeedsManualSignIn(true); + } + + return response as EmbeddedFlowExecuteResponse; }; return ( > if (result?.data?.afterSignUpUrl) { const {afterSignUpUrl, autoSignInSkippedReason, signedIn, ...flowResponse}: any = result.data; - // A URL passed by the caller (e.g. the `afterSignUpUrl` prop of ``) wins over the configured one. - navigateTo(router, options?.afterSignUpUrl || afterSignUpUrl); - if (signedIn) { + // A URL passed by the caller (e.g. the `afterSignUpUrl` prop of ``) 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. ``) 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) { diff --git a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.test.tsx b/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.test.tsx index 5c37dd7e8..d19272109 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.test.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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'); + }); +}); diff --git a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx index f2b2cd81a..be9654e9f 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx @@ -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'; @@ -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. */ @@ -236,6 +244,7 @@ const resolveAlertVariant = (type?: string): 'success' | 'error' | 'warning' | ' const BaseSignUpContent: FC = ({ afterSignUpUrl, + signInUrl, onInitialize, onSubmit, onError, @@ -276,6 +285,7 @@ const BaseSignUpContent: FC = ({ const [isLoading, setIsLoading] = useState(false); const [isFlowInitialized, setIsFlowInitialized] = useState(false); const [isFlowComplete, setIsFlowComplete] = useState(false); + const [isSignedIn, setIsSignedIn] = useState(false); const [currentFlow, setCurrentFlow] = useState(null); const initializationAttemptedRef: any = useRef(false); @@ -289,8 +299,12 @@ const BaseSignUpContent: FC = ({ 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); @@ -857,6 +871,20 @@ const BaseSignUpContent: FC = ({ ))} )} + {isFlowComplete && !isSignedIn && signInUrl && ( +
+ { + window.location.assign(signInUrl); + }} + > + {t('elements.buttons.signin.text')} + +
+ )} {!isFlowComplete && (
{currentFlow.data?.components && currentFlow.data.components.length > 0 ? (