mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
fix(experience): allow link social account on sign-in only mode (#6560)
* fix(experience): allow link social account on sign-in only mode allow link social account, when registration is disabled; * chore: add changeset add changeset * chore: fix typos fix typos
This commit is contained in:
parent
8b19004102
commit
2626616775
8 changed files with 165 additions and 104 deletions
30
.changeset/proud-books-itch.md
Normal file
30
.changeset/proud-books-itch.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
"@logto/experience-legacy": minor
|
||||
"@logto/experience": minor
|
||||
---
|
||||
|
||||
allow link new social identity to an existing user account when registration is disabled.
|
||||
|
||||
### Previous behavior
|
||||
|
||||
Sign-in with a social identity that does not have an existing user account will throw an `identity_not_exist` error. When the registration is disabled, the error message will be shown, the user will not be able to create a new account or link the social identity to an existing account via verified email or phone number.
|
||||
|
||||
### Expected behavior
|
||||
|
||||
When the registration is disabled, if a related user account is found, the user should be able to link the social identity to an existing account via a verified email or phone number.
|
||||
|
||||
### Updates
|
||||
|
||||
When the registration is disabled:
|
||||
|
||||
- Show `identity_not_exist` error message if no related user account is found.
|
||||
- Automatically link the social identity to the existing account if a related user account is found and social automatic account linking is enabled.
|
||||
- Redirect the user to the social link account page if a related user account is found and social automatic account linking is disabled.
|
||||
- Hide the register button on the social link account page if the registration is disabled.
|
||||
|
||||
When the registration is enabled:
|
||||
|
||||
- Automatically register a new account with the social identity if no related user account is found.
|
||||
- Automatically link the social identity to the existing account if a related user account is found and social automatic account linking is enabled.
|
||||
- Redirect the user to the social link account page if a related user account is found and social automatic account linking is disabled.
|
||||
- Show the register new account button on the social link account page.
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, SignInMode } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import type { TFuncKey } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -41,7 +41,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => {
|
|||
|
||||
const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { signUpMethods } = useSieMethods();
|
||||
const { signUpMethods, signInMode } = useSieMethods();
|
||||
|
||||
const bindSocialRelatedUser = useBindSocialRelatedUser();
|
||||
const registerWithSocial = useSocialRegister(connectorId);
|
||||
|
@ -65,17 +65,19 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.hint}>
|
||||
<div>
|
||||
<DynamicT forKey="description.skip_social_linking" />
|
||||
{signInMode !== SignInMode.SignIn && (
|
||||
<div className={styles.hint}>
|
||||
<div>
|
||||
<DynamicT forKey="description.skip_social_linking" />
|
||||
</div>
|
||||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(connectorId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(connectorId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { AgreeToTermsPolicy, experience } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerWithVerifiedSocial } from '@/apis/interaction';
|
||||
|
||||
|
@ -6,16 +8,29 @@ import useApi from './use-api';
|
|||
import useErrorHandler from './use-error-handler';
|
||||
import useGlobalRedirectTo from './use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
import useTerms from './use-terms';
|
||||
|
||||
const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial);
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace });
|
||||
|
||||
return useCallback(
|
||||
async (connectorId: string) => {
|
||||
/**
|
||||
* Agree to terms and conditions first before proceeding
|
||||
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
|
||||
* Therefore, skip the check for `Manual` policy.
|
||||
*/
|
||||
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
const [error, result] = await asyncRegisterWithSocial(connectorId);
|
||||
|
||||
if (error) {
|
||||
|
@ -28,7 +43,15 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncRegisterWithSocial, handleError, preSignInErrorHandler, redirectTo]
|
||||
[
|
||||
agreeToTermsPolicy,
|
||||
asyncRegisterWithSocial,
|
||||
handleError,
|
||||
navigate,
|
||||
preSignInErrorHandler,
|
||||
redirectTo,
|
||||
termsValidation,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { GoogleConnector } from '@logto/connector-kit';
|
||||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas';
|
||||
import { InteractionEvent, SignInMode, experience } from '@logto/schemas';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
@ -14,7 +14,6 @@ import useErrorHandler from '@/hooks/use-error-handler';
|
|||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import useSocialRegister from '@/hooks/use-social-register';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import useToast from '@/hooks/use-toast';
|
||||
import { socialAccountNotExistErrorDataGuard } from '@/types/guard';
|
||||
import { parseQueryParameters } from '@/utils';
|
||||
|
@ -25,7 +24,6 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
const { setToast } = useToast();
|
||||
const { signInMode, socialSignInSettings } = useSieMethods();
|
||||
const { t } = useTranslation();
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const [isConsumed, setIsConsumed] = useState(false);
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
|
||||
|
@ -42,13 +40,16 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
const { relatedUser } = data ?? {};
|
||||
|
||||
if (relatedUser) {
|
||||
// If automatic account linking is enabled, bind the related user directly
|
||||
if (socialSignInSettings.automaticAccountLinking) {
|
||||
const { type, value } = relatedUser;
|
||||
|
||||
await bindSocialRelatedUser({
|
||||
connectorId,
|
||||
...(type === 'email' ? { email: value } : { phone: value }),
|
||||
});
|
||||
} else {
|
||||
// Redirect to the social link page
|
||||
navigate(`/social/link/${connectorId}`, {
|
||||
replace: true,
|
||||
state: { relatedUser },
|
||||
|
@ -58,6 +59,13 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Should not let user register new social account under sign-in only mode
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register with social
|
||||
await registerWithSocial(connectorId);
|
||||
},
|
||||
|
@ -66,6 +74,8 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
connectorId,
|
||||
navigate,
|
||||
registerWithSocial,
|
||||
setToast,
|
||||
signInMode,
|
||||
socialSignInSettings.automaticAccountLinking,
|
||||
]
|
||||
);
|
||||
|
@ -74,26 +84,7 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
|
||||
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.identity_not_exist': async (error) => {
|
||||
// Should not let user register new social account under sign-in only mode
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agree to terms and conditions first before proceeding
|
||||
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
|
||||
* Therefore, skip the check for `Manual` policy.
|
||||
*/
|
||||
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
await accountNotExistErrorHandler(error);
|
||||
},
|
||||
'user.identity_not_exist': accountNotExistErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
// Redirect to sign-in page if error is not handled by the error handlers
|
||||
global: async (error) => {
|
||||
|
@ -101,15 +92,7 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
navigate('/' + experience.routes.signIn);
|
||||
},
|
||||
}),
|
||||
[
|
||||
preSignInErrorHandler,
|
||||
signInMode,
|
||||
agreeToTermsPolicy,
|
||||
termsValidation,
|
||||
accountNotExistErrorHandler,
|
||||
setToast,
|
||||
navigate,
|
||||
]
|
||||
[preSignInErrorHandler, accountNotExistErrorHandler, setToast, navigate]
|
||||
);
|
||||
|
||||
const signInWithSocialHandler = useCallback(
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, SignInMode } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import type { TFuncKey } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -42,7 +42,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => {
|
|||
|
||||
const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { signUpMethods } = useSieMethods();
|
||||
const { signUpMethods, signInMode } = useSieMethods();
|
||||
|
||||
const bindSocialRelatedUser = useBindSocialRelatedUser();
|
||||
const registerWithSocial = useSocialRegister(connectorId);
|
||||
|
@ -63,17 +63,19 @@ const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.hint}>
|
||||
<div>
|
||||
<DynamicT forKey="description.skip_social_linking" />
|
||||
{signInMode !== SignInMode.SignIn && (
|
||||
<div className={styles.hint}>
|
||||
<div>
|
||||
<DynamicT forKey="description.skip_social_linking" />
|
||||
</div>
|
||||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(verificationId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(verificationId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { InteractionEvent } from '@logto/schemas';
|
||||
import { AgreeToTermsPolicy, experience, InteractionEvent } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerWithVerifiedIdentifier } from '@/apis/experience';
|
||||
|
||||
|
@ -7,11 +8,14 @@ import useApi from './use-api';
|
|||
import useErrorHandler from './use-error-handler';
|
||||
import useGlobalRedirectTo from './use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
import useTerms from './use-terms';
|
||||
|
||||
const useSocialRegister = (connectorId: string, replace?: boolean) => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier);
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const preRegisterErrorHandler = usePreSignInErrorHandler({
|
||||
linkSocial: connectorId,
|
||||
|
@ -21,6 +25,16 @@ const useSocialRegister = (connectorId: string, replace?: boolean) => {
|
|||
|
||||
return useCallback(
|
||||
async (verificationId: string) => {
|
||||
/**
|
||||
* Agree to terms and conditions first before proceeding
|
||||
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
|
||||
* Therefore, skip the check for `Manual` policy.
|
||||
*/
|
||||
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
const [error, result] = await asyncRegisterWithSocial(verificationId);
|
||||
|
||||
if (error) {
|
||||
|
@ -33,7 +47,15 @@ const useSocialRegister = (connectorId: string, replace?: boolean) => {
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncRegisterWithSocial, handleError, preRegisterErrorHandler, redirectTo]
|
||||
[
|
||||
agreeToTermsPolicy,
|
||||
asyncRegisterWithSocial,
|
||||
handleError,
|
||||
navigate,
|
||||
preRegisterErrorHandler,
|
||||
redirectTo,
|
||||
termsValidation,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
import { GoogleConnector } from '@logto/connector-kit';
|
||||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import {
|
||||
AgreeToTermsPolicy,
|
||||
InteractionEvent,
|
||||
SignInMode,
|
||||
VerificationType,
|
||||
experience,
|
||||
} from '@logto/schemas';
|
||||
import { InteractionEvent, SignInMode, VerificationType, experience } from '@logto/schemas';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
@ -25,7 +19,6 @@ import useErrorHandler from '@/hooks/use-error-handler';
|
|||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import useSocialRegister from '@/hooks/use-social-register';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import useToast from '@/hooks/use-toast';
|
||||
import { socialAccountNotExistErrorDataGuard } from '@/types/guard';
|
||||
import { parseQueryParameters } from '@/utils';
|
||||
|
@ -36,7 +29,6 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
const { setToast } = useToast();
|
||||
const { signInMode, socialSignInSettings } = useSieMethods();
|
||||
const { t } = useTranslation();
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const [isConsumed, setIsConsumed] = useState(false);
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const { verificationIdsMap, setVerificationId } = useContext(UserInteractionContext);
|
||||
|
@ -80,6 +72,13 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Should not let user register new social account under sign-in only mode
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register with social
|
||||
await registerWithSocial(verificationId);
|
||||
},
|
||||
|
@ -89,6 +88,7 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
navigate,
|
||||
registerWithSocial,
|
||||
setToast,
|
||||
signInMode,
|
||||
socialSignInSettings.automaticAccountLinking,
|
||||
t,
|
||||
]
|
||||
|
@ -106,39 +106,11 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
|
||||
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.identity_not_exist': async (error) => {
|
||||
// Should not let user register new social account under sign-in only mode
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agree to terms and conditions first before proceeding
|
||||
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
|
||||
* Therefore, skip the check for `Manual` policy.
|
||||
*/
|
||||
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
await accountNotExistErrorHandler(error);
|
||||
},
|
||||
'user.identity_not_exist': accountNotExistErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
global: globalErrorHandler,
|
||||
}),
|
||||
[
|
||||
preSignInErrorHandler,
|
||||
globalErrorHandler,
|
||||
signInMode,
|
||||
agreeToTermsPolicy,
|
||||
termsValidation,
|
||||
accountNotExistErrorHandler,
|
||||
setToast,
|
||||
navigate,
|
||||
]
|
||||
[preSignInErrorHandler, globalErrorHandler, accountNotExistErrorHandler]
|
||||
);
|
||||
|
||||
const verifySocialCallbackData = useCallback(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { AgreeToTermsPolicy, SignInIdentifier } from '@logto/schemas';
|
||||
import { AgreeToTermsPolicy, SignInIdentifier, SignInMode } from '@logto/schemas';
|
||||
|
||||
import { createUser, deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
|
@ -55,7 +55,38 @@ describe('automatic account linking', () => {
|
|||
await deleteUser(user.id);
|
||||
});
|
||||
|
||||
it('should automatically link account with terms of use and privacy policy', async () => {
|
||||
it('should automatically link account even if the registration is disabled', async () => {
|
||||
await updateSignInExperience({
|
||||
termsOfUseUrl: null,
|
||||
privacyPolicyUrl: null,
|
||||
socialSignIn: { automaticAccountLinking: true },
|
||||
signInMode: SignInMode.SignIn,
|
||||
});
|
||||
|
||||
const socialUserId = 'foo_' + randomString();
|
||||
const user = await createUser({ primaryEmail: generateEmail() });
|
||||
const experience = new ExpectExperience(await browser.newPage());
|
||||
|
||||
await experience.navigateTo(demoAppUrl.href);
|
||||
await experience.toProcessSocialSignIn({
|
||||
socialUserId,
|
||||
socialEmail: user.primaryEmail!,
|
||||
});
|
||||
|
||||
experience.toMatchUrl(demoAppUrl);
|
||||
await experience.toMatchElement('div', { text: `User ID: ${user.id}` });
|
||||
await experience.toClick('div[role=button]', /sign out/i);
|
||||
await experience.page.close();
|
||||
|
||||
await deleteUser(user.id);
|
||||
|
||||
// Reset the sign-in experience
|
||||
await updateSignInExperience({
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
});
|
||||
});
|
||||
|
||||
it('should automatically link account without verify terms of use', async () => {
|
||||
await updateSignInExperience({
|
||||
termsOfUseUrl: 'https://example.com/terms',
|
||||
privacyPolicyUrl: 'https://example.com/privacy',
|
||||
|
@ -72,10 +103,6 @@ describe('automatic account linking', () => {
|
|||
socialEmail: user.primaryEmail!,
|
||||
});
|
||||
|
||||
// Should have popped up the terms of use and privacy policy dialog
|
||||
await experience.toMatchElement('div', { text: /terms of use/i });
|
||||
await experience.toClick('button', /agree/i);
|
||||
|
||||
experience.toMatchUrl(demoAppUrl);
|
||||
await experience.toMatchElement('div', { text: `User ID: ${user.id}` });
|
||||
await experience.toClick('div[role=button]', /sign out/i);
|
||||
|
|
Loading…
Reference in a new issue