From 4944062f1fc2b995c9add7c5436ef9395714ec27 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 18 Jan 2023 15:58:30 +0800 Subject: [PATCH] feat(core,ui): display registered social identity message on continue (#2974) --- .../mandatory-user-profile-validation.test.ts | 5 +- .../mandatory-user-profile-validation.ts | 161 +++++++++++------- .../containers/EmailForm/EmailContinue.tsx | 34 +++- .../containers/EmailForm/index.module.scss | 5 + .../containers/PhoneForm/PhoneContinue.tsx | 34 +++- .../containers/PhoneForm/index.module.scss | 5 + .../use-identifier-error-alert.ts | 20 +-- .../use-required-profile-error-handler.ts | 3 +- .../src/hooks/use-social-sign-in-listener.ts | 2 +- .../ui/src/pages/SignInPassword/index.tsx | 4 +- .../ui/src/pages/VerificationCode/index.tsx | 8 +- packages/ui/src/types/guard.ts | 14 +- packages/ui/src/types/index.ts | 5 + 13 files changed, 198 insertions(+), 102 deletions(-) diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts index a7c0fc3f9..3008fc565 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -33,6 +33,7 @@ describe('validateMandatoryUserProfile', () => { interactionDetails: {} as Awaited>, 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' } } ) ); }); diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts index 6830f80c2..354851093 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -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, - interaction: MandatoryProfileValidationInteraction, - userQueries: Queries['users'] -): Promise<[Set, 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) => { + 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, + interaction: MandatoryProfileValidationInteraction, + userQueries: Queries['users'] +): Promise => { + 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; diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.tsx index 121c4eba6..b7039c456 100644 --- a/packages/ui/src/containers/EmailForm/EmailContinue.tsx +++ b/packages/ui/src/containers/EmailForm/EmailContinue.tsx @@ -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 ( - + <> + + {hasSocialIdentity && state.registeredSocialIdentity?.email && ( +
+ {t('description.social_identity_exist', { + type: t('description.email'), + value: maskEmail(state.registeredSocialIdentity.email), + })} +
+ )} + ); }; diff --git a/packages/ui/src/containers/EmailForm/index.module.scss b/packages/ui/src/containers/EmailForm/index.module.scss index 4c7c856ee..3825b035c 100644 --- a/packages/ui/src/containers/EmailForm/index.module.scss +++ b/packages/ui/src/containers/EmailForm/index.module.scss @@ -24,3 +24,8 @@ margin-top: _.unit(-3); } } + +.description { + margin-top: _.unit(6); + @include _.text-hint; +} diff --git a/packages/ui/src/containers/PhoneForm/PhoneContinue.tsx b/packages/ui/src/containers/PhoneForm/PhoneContinue.tsx index 426f736f8..09ab34069 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneContinue.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneContinue.tsx @@ -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 ( - + <> + + {hasSocialIdentity && state.registeredSocialIdentity?.email && ( +
+ {t('description.social_identity_exist', { + type: t('description.phone_number'), + value: state.registeredSocialIdentity.email, + })} +
+ )} + ); }; diff --git a/packages/ui/src/containers/PhoneForm/index.module.scss b/packages/ui/src/containers/PhoneForm/index.module.scss index 456e4fe34..22fdd93d2 100644 --- a/packages/ui/src/containers/PhoneForm/index.module.scss +++ b/packages/ui/src/containers/PhoneForm/index.module.scss @@ -24,3 +24,8 @@ margin-top: _.unit(-3); } } + +.description { + margin-top: _.unit(6); + @include _.text-hint; +} diff --git a/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts b/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts index fd857f405..d92038ea3 100644 --- a/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts +++ b/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts @@ -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); }, diff --git a/packages/ui/src/hooks/use-required-profile-error-handler.ts b/packages/ui/src/hooks/use-required-profile-error-handler.ts index 4e3079dee..32bc33706 100644 --- a/packages/ui/src/hooks/use-required-profile-error-handler.ts +++ b/packages/ui/src/hooks/use-required-profile-error-handler.ts @@ -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: diff --git a/packages/ui/src/hooks/use-social-sign-in-listener.ts b/packages/ui/src/hooks/use-social-sign-in-listener.ts index 636edc98c..da7e4a731 100644 --- a/packages/ui/src/hooks/use-social-sign-in-listener.ts +++ b/packages/ui/src/hooks/use-social-sign-in-listener.ts @@ -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(); diff --git a/packages/ui/src/pages/SignInPassword/index.tsx b/packages/ui/src/pages/SignInPassword/index.tsx index 1f077640b..25295dff0 100644 --- a/packages/ui/src/pages/SignInPassword/index.tsx +++ b/packages/ui/src/pages/SignInPassword/index.tsx @@ -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 ; } - const invalidState = !is(state, verificationCodeStateGuard); + const invalidState = !is(state, emailOrPhoneStateGuard); const value = !invalidState && state[methodSetting.identifier]; if (!value) { diff --git a/packages/ui/src/pages/VerificationCode/index.tsx b/packages/ui/src/pages/VerificationCode/index.tsx index d0124016a..caf51319a 100644 --- a/packages/ui/src/pages/VerificationCode/index.tsx +++ b/packages/ui/src/pages/VerificationCode/index.tsx @@ -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); diff --git a/packages/ui/src/types/guard.ts b/packages/ui/src/types/guard.ts index fca4b45a9..77fb68e96 100644 --- a/packages/ui/src/types/guard.ts +++ b/packages/ui/src/types/guard.ts @@ -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({ diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index e94ddee01..de4c51e85 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -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; + // Omit socialSignInConnectorTargets since it is being translated into socialConnectors export type SignInExperienceResponse = Omit & { socialConnectors: ConnectorMetadata[];