0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core,ui): display registered social identity message on continue (#2974)

This commit is contained in:
simeng-li 2023-01-18 15:58:30 +08:00 committed by GitHub
parent b87b9def62
commit 4944062f1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 198 additions and 102 deletions

View file

@ -33,6 +33,7 @@ describe('validateMandatoryUserProfile', () => {
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
signInExperience: mockSignInExperience,
};
const interaction: IdentifierVerifiedInteractionResult = {
event: InteractionEvent.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
@ -138,7 +139,7 @@ describe('validateMandatoryUserProfile', () => {
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.email] }
{ missingProfile: [MissingProfile.email], registeredSocialIdentity: { email: 'email' } }
)
);
});
@ -213,7 +214,7 @@ describe('validateMandatoryUserProfile', () => {
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.phone] }
{ missingProfile: [MissingProfile.phone], registeredSocialIdentity: { phone: '123456' } }
)
);
});

View file

@ -12,6 +12,7 @@ import type {
SocialIdentifier,
VerifiedSignInInteractionResult,
VerifiedRegisterInteractionResult,
Identifier,
} from '../types/index.js';
import { isUserPasswordSet } from '../utils/index.js';
import { mergeIdentifiers } from '../utils/interaction.js';
@ -92,73 +93,115 @@ const validateRegisterMandatoryUserProfile = (profile?: Profile) => {
);
};
// Fill the missing email or phone from the social identity if any
const fillMissingProfileWithSocialIdentity = async (
missingProfile: Set<MissingProfile>,
interaction: MandatoryProfileValidationInteraction,
userQueries: Queries['users']
): Promise<[Set<MissingProfile>, MandatoryProfileValidationInteraction]> => {
const { identifiers = [], profile } = interaction;
const getSocialUserInfo = (identifiers: Identifier[] = []) => {
const socialIdentifier = identifiers.find(
(identifier): identifier is SocialIdentifier => identifier.key === 'social'
);
if (!socialIdentifier) {
return [missingProfile, interaction];
}
return socialIdentifier?.userInfo;
};
const {
userInfo: { email, phone },
} = socialIdentifier;
if (
(missingProfile.has(MissingProfile.email) || missingProfile.has(MissingProfile.emailOrPhone)) &&
email &&
// Email taken
!(await userQueries.hasUserWithEmail(email))
) {
missingProfile.delete(MissingProfile.email);
missingProfile.delete(MissingProfile.emailOrPhone);
// Assign social verified email to the interaction
return [
missingProfile,
const assertMissingProfile = (missingProfileSet: Set<MissingProfile>) => {
assertThat(
missingProfileSet.size === 0,
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{
...interaction,
identifiers: mergeIdentifiers({ key: 'emailVerified', value: email }, identifiers),
profile: {
...profile,
email,
},
},
];
missingProfile: Array.from(missingProfileSet),
}
)
);
};
// Fill the missing email or phone from the social identity if any
const fillMissingProfileWithSocialIdentity = async (
missingProfileSet: Set<MissingProfile>,
interaction: MandatoryProfileValidationInteraction,
userQueries: Queries['users']
): Promise<MandatoryProfileValidationInteraction> => {
const { identifiers, profile } = interaction;
const socialUserInfo = getSocialUserInfo(identifiers);
if (!socialUserInfo) {
assertMissingProfile(missingProfileSet);
return interaction;
}
const { email, phone } = socialUserInfo;
// Email Required
if (
(missingProfile.has(MissingProfile.phone) || missingProfile.has(MissingProfile.emailOrPhone)) &&
phone &&
// Phone taken
!(await userQueries.hasUserWithPhone(phone))
(missingProfileSet.has(MissingProfile.email) ||
missingProfileSet.has(MissingProfile.emailOrPhone)) &&
email
) {
missingProfile.delete(MissingProfile.phone);
missingProfile.delete(MissingProfile.emailOrPhone);
// Check email not taken
assertThat(
!(await userQueries.hasUserWithEmail(email)),
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{
missingProfile: Array.from(missingProfileSet),
registeredSocialIdentity: { email },
}
)
);
// Assign social verified phone to the interaction
return [
missingProfile,
{
...interaction,
identifiers: mergeIdentifiers({ key: 'phoneVerified', value: phone }, identifiers),
profile: {
...profile,
phone,
},
// Assign social verified email to the interaction and remove from missingProfile
missingProfileSet.delete(MissingProfile.email);
missingProfileSet.delete(MissingProfile.emailOrPhone);
assertMissingProfile(missingProfileSet);
return {
...interaction,
identifiers: mergeIdentifiers({ key: 'emailVerified', value: email }, identifiers),
profile: {
...profile,
email,
},
];
};
}
return [missingProfile, interaction];
// Phone required
if (
(missingProfileSet.has(MissingProfile.phone) ||
missingProfileSet.has(MissingProfile.emailOrPhone)) &&
phone
) {
// Check Phone not taken
assertThat(
!(await userQueries.hasUserWithPhone(phone)),
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{
missingProfile: Array.from(missingProfileSet),
registeredSocialIdentity: { phone },
}
)
);
// Assign social verified phone to the interaction and remove from missingProfile
missingProfileSet.delete(MissingProfile.phone);
missingProfileSet.delete(MissingProfile.emailOrPhone);
assertMissingProfile(missingProfileSet);
return {
...interaction,
identifiers: mergeIdentifiers({ key: 'phoneVerified', value: phone }, identifiers),
profile: {
...profile,
phone,
},
};
}
assertMissingProfile(missingProfileSet);
return interaction;
};
export default async function validateMandatoryUserProfile(
@ -177,22 +220,14 @@ export default async function validateMandatoryUserProfile(
const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile });
const [updatedMissingProfileSet, updatedInteraction] = await fillMissingProfileWithSocialIdentity(
const updatedInteraction = await fillMissingProfileWithSocialIdentity(
missingProfileSet,
interaction,
userQueries
);
assertThat(
updatedMissingProfileSet.size === 0,
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: Array.from(missingProfileSet) }
)
);
if (event === InteractionEvent.Register) {
validateRegisterMandatoryUserProfile(profile);
validateRegisterMandatoryUserProfile(updatedInteraction.profile);
}
return updatedInteraction;

View file

@ -1,9 +1,15 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import { maskEmail } from '@/utils/format';
import EmailForm from './EmailForm';
import * as styles from './index.module.scss';
type Props = {
className?: string;
@ -17,15 +23,29 @@ const EmailContinue = (props: Props) => {
UserFlow.continue,
SignInIdentifier.Email
);
const { t } = useTranslation();
const { state } = useLocation();
const hasSocialIdentity = is(state, registeredSocialIdentityStateGuard);
return (
<EmailForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
<>
<EmailForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
{hasSocialIdentity && state.registeredSocialIdentity?.email && (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.email'),
value: maskEmail(state.registeredSocialIdentity.email),
})}
</div>
)}
</>
);
};

View file

@ -24,3 +24,8 @@
margin-top: _.unit(-3);
}
}
.description {
margin-top: _.unit(6);
@include _.text-hint;
}

View file

@ -1,9 +1,14 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import PhoneForm from './PhoneForm';
import * as styles from './index.module.scss';
type Props = {
className?: string;
@ -18,14 +23,29 @@ const PhoneContinue = (props: Props) => {
SignInIdentifier.Phone
);
const { t } = useTranslation();
const { state } = useLocation();
const hasSocialIdentity = is(state, registeredSocialIdentityStateGuard);
return (
<PhoneForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
<>
<PhoneForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
{hasSocialIdentity && state.registeredSocialIdentity?.email && (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.phone_number'),
value: state.registeredSocialIdentity.email,
})}
</div>
)}
</>
);
};

View file

@ -24,3 +24,8 @@
margin-top: _.unit(-3);
}
}
.description {
margin-top: _.unit(6);
@include _.text-hint;
}

View file

@ -19,11 +19,7 @@ const useIdentifierErrorAlert = () => {
// Have to wrap up in a useCallback hook otherwise the handler updates on every cycle
return useCallback(
async (
errorType: IdentifierErrorType,
identifierType: VerificationCodeIdentifier,
identifier: string
) => {
async (errorType: IdentifierErrorType, method: VerificationCodeIdentifier, value: string) => {
await show({
type: 'alert',
ModalContent: t(
@ -31,17 +27,17 @@ const useIdentifierErrorAlert = () => {
? 'description.create_account_id_exists_alert'
: 'description.sign_in_id_does_not_exist_alert',
{
type: t(
`description.${identifierType === SignInIdentifier.Email ? 'email' : 'phone_number'}`
),
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value:
identifierType === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(identifier)
: identifier,
method === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(value)
: value,
}
),
cancelText: 'action.change',
cancelTextI18nProps: { method: identifierType },
cancelTextI18nProps: {
method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
},
});
navigate(-1);
},

View file

@ -24,6 +24,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
'user.missing_profile': (error) => {
const [, data] = validate(error.data, missingProfileErrorDataGuard);
const missingProfile = data?.missingProfile[0];
const registeredSocialIdentity = data?.registeredSocialIdentity;
const linkSocialQueryString = linkSocial
? `?${queryStringify({ [SearchParameters.linkSocial]: linkSocial })}`
@ -46,7 +47,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
pathname: `/${UserFlow.continue}/${missingProfile}`,
search: linkSocialQueryString,
},
{ replace }
{ replace, state: { registeredSocialIdentity } }
);
break;
case MissingProfile.emailOrPhone:

View file

@ -19,7 +19,7 @@ import useSocialRegister from './use-social-register';
const useSocialSignInListener = (connectorId?: string) => {
const { setToast } = useContext(PageContext);
const { signInMode, signUpMethods } = useSieMethods();
const { signInMode } = useSieMethods();
const { t } = useTranslation();
const navigate = useNavigate();

View file

@ -7,7 +7,7 @@ import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import PasswordSignInForm from '@/containers/PasswordSignInForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { verificationCodeStateGuard } from '@/types/guard';
import { emailOrPhoneStateGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import { isEmailOrPhoneMethod } from '@/utils/sign-in-experience';
@ -31,7 +31,7 @@ const SignInPassword = () => {
return <ErrorPage />;
}
const invalidState = !is(state, verificationCodeStateGuard);
const invalidState = !is(state, emailOrPhoneStateGuard);
const value = !invalidState && state[methodSetting.identifier];
if (!value) {

View file

@ -7,11 +7,7 @@ import VerificationCodeContainer from '@/containers/VerificationCode';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
import {
verificationCodeStateGuard,
verificationCodeMethodGuard,
userFlowGuard,
} from '@/types/guard';
import { emailOrPhoneStateGuard, verificationCodeMethodGuard, userFlowGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
type Parameters = {
@ -25,7 +21,7 @@ const VerificationCode = () => {
const { state } = useLocation();
const invalidMethod = !is(method, verificationCodeMethodGuard);
const invalidState = !is(state, verificationCodeStateGuard);
const invalidState = !is(state, emailOrPhoneStateGuard);
const [, flow] = validate(type, userFlowGuard);

View file

@ -3,7 +3,7 @@ import * as s from 'superstruct';
import { UserFlow } from '.';
export const verificationCodeStateGuard = s.object({
export const emailOrPhoneStateGuard = s.object({
email: s.optional(s.string()),
phone: s.optional(s.string()),
});
@ -37,6 +37,13 @@ export const usernameGuard = s.object({
username: s.string(),
});
const registeredSocialIdentity = s.optional(
s.object({
email: s.optional(s.string()),
phone: s.optional(s.string()),
})
);
export const missingProfileErrorDataGuard = s.object({
missingProfile: s.array(
s.union([
@ -47,6 +54,11 @@ export const missingProfileErrorDataGuard = s.object({
s.literal(MissingProfile.emailOrPhone),
])
),
registeredSocialIdentity,
});
export const registeredSocialIdentityStateGuard = s.object({
registeredSocialIdentity,
});
export const socialAccountNotExistErrorDataGuard = s.object({

View file

@ -4,6 +4,9 @@ import type {
AppearanceMode,
SignInIdentifier,
} from '@logto/schemas';
import type * as s from 'superstruct';
import type { emailOrPhoneStateGuard } from './guard';
export enum UserFlow {
signIn = 'sign-in',
@ -25,6 +28,8 @@ export type Theme = 'dark' | 'light';
export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone;
export type EmailOrPhoneState = s.Infer<typeof emailOrPhoneStateGuard>;
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors
export type SignInExperienceResponse = Omit<SignInExperience, 'socialSignInConnectorTargets'> & {
socialConnectors: ConnectorMetadata[];