0
Fork 0
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:
simeng-li 2024-09-10 15:45:07 +08:00 committed by GitHub
parent 8b19004102
commit 2626616775
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 165 additions and 104 deletions

View 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.

View file

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

View file

@ -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,
]
);
};

View file

@ -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(

View file

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

View file

@ -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,
]
);
};

View file

@ -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(

View file

@ -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);