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:
parent
b87b9def62
commit
4944062f1f
13 changed files with 198 additions and 102 deletions
|
@ -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' } }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -24,3 +24,8 @@
|
|||
margin-top: _.unit(-3);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(6);
|
||||
@include _.text-hint;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -24,3 +24,8 @@
|
|||
margin-top: _.unit(-3);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(6);
|
||||
@include _.text-hint;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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[];
|
||||
|
|
Loading…
Add table
Reference in a new issue