mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(experience): refactor the verification code flow (migration-2) (#6408)
* refactor(experience): refactor the verificaiton code flow refactor the verification code flow * refactor(experience): migrate the social and sso flow (migration-3) (#6406) * refactor(experience): migrate the social and sso flow migrate the social and sso flow * refactor(experience): migrate profile fulfillment flow (migration-4) (#6414) * refactor(experience): migrate profile fulfillment flow migrate the profile fulfillment flow * refactor(experience): remove unused hook remove unused hook * fix(experience): fix password policy checker fix password policy checker error display * fix(experience): fix the api name fix the api name * refactor(experience): migrate mfa flow (migration-5) (#6417) * refactor(experience): migrate mfa binding flow migrate mfa binding flow * test(experience): update unit tests (migration-6) (#6420) * test(experience): update unit tests update unit tests * chore(experience): remove legacy APIs remove legacy APIs * refactor(experience): revert api prefix revert api prefix * fix(experience): update the sso connectors endpoint update the sso connectors endpoint
This commit is contained in:
parent
a0fbd7fcf1
commit
60a9d4eb7e
76 changed files with 1732 additions and 1129 deletions
|
@ -1,4 +1,4 @@
|
|||
import { type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
|
||||
import { noop } from '@silverhand/essentials';
|
||||
import { createContext } from 'react';
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {
|
|||
type IdentifierInputType,
|
||||
type IdentifierInputValue,
|
||||
} from '@/components/InputFields/SmartInputField';
|
||||
import { type VerificationIdsMap } from '@/types/guard';
|
||||
|
||||
export type UserInteractionContextType = {
|
||||
// All the enabled sso connectors
|
||||
|
@ -54,6 +55,8 @@ export type UserInteractionContextType = {
|
|||
setForgotPasswordIdentifierInputValue: React.Dispatch<
|
||||
React.SetStateAction<IdentifierInputValue | undefined>
|
||||
>;
|
||||
verificationIdsMap: VerificationIdsMap;
|
||||
setVerificationId: (type: VerificationType, id: string) => void;
|
||||
/**
|
||||
* This method only clear the identifier input values from the session storage.
|
||||
*
|
||||
|
@ -79,5 +82,7 @@ export default createContext<UserInteractionContextType>({
|
|||
setIdentifierInputValue: noop,
|
||||
forgotPasswordIdentifierInputValue: undefined,
|
||||
setForgotPasswordIdentifierInputValue: noop,
|
||||
verificationIdsMap: {},
|
||||
setVerificationId: noop,
|
||||
clearInteractionContextSessionStorage: noop,
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type IdentifierInputType,
|
||||
|
@ -38,6 +38,10 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
|||
IdentifierInputValue | undefined
|
||||
>(get(StorageKeys.ForgotPasswordIdentifierInputValue));
|
||||
|
||||
const [verificationIdsMap, setVerificationIdsMap] = useState(
|
||||
get(StorageKeys.verificationIds) ?? {}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ssoEmail) {
|
||||
remove(StorageKeys.SsoEmail);
|
||||
|
@ -74,6 +78,15 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
|||
set(StorageKeys.ForgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue);
|
||||
}, [forgotPasswordIdentifierInputValue, remove, set]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(verificationIdsMap).length === 0) {
|
||||
remove(StorageKeys.verificationIds);
|
||||
return;
|
||||
}
|
||||
|
||||
set(StorageKeys.verificationIds, verificationIdsMap);
|
||||
}, [verificationIdsMap, remove, set]);
|
||||
|
||||
const ssoConnectorsMap = useMemo(
|
||||
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
|
||||
[ssoConnectors]
|
||||
|
@ -94,8 +107,13 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
|||
const clearInteractionContextSessionStorage = useCallback(() => {
|
||||
remove(StorageKeys.IdentifierInputValue);
|
||||
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
|
||||
remove(StorageKeys.verificationIds);
|
||||
}, [remove]);
|
||||
|
||||
const setVerificationId = useCallback((type: VerificationType, id: string) => {
|
||||
setVerificationIdsMap((previous) => ({ ...previous, [type]: id }));
|
||||
}, []);
|
||||
|
||||
const userInteractionContext = useMemo<UserInteractionContextType>(
|
||||
() => ({
|
||||
ssoEmail,
|
||||
|
@ -108,6 +126,8 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
|||
setIdentifierInputValue,
|
||||
forgotPasswordIdentifierInputValue,
|
||||
setForgotPasswordIdentifierInputValue,
|
||||
verificationIdsMap,
|
||||
setVerificationId,
|
||||
clearInteractionContextSessionStorage,
|
||||
}),
|
||||
[
|
||||
|
@ -117,6 +137,8 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
|||
identifierInputValue,
|
||||
getIdentifierInputValueByTypes,
|
||||
forgotPasswordIdentifierInputValue,
|
||||
verificationIdsMap,
|
||||
setVerificationId,
|
||||
clearInteractionContextSessionStorage,
|
||||
]
|
||||
);
|
||||
|
|
1
packages/experience/src/apis/const.ts
Normal file
1
packages/experience/src/apis/const.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const kyPrefixUrl = '/';
|
|
@ -1,73 +0,0 @@
|
|||
import {
|
||||
type IdentificationApiPayload,
|
||||
InteractionEvent,
|
||||
type PasswordVerificationPayload,
|
||||
SignInIdentifier,
|
||||
type UpdateProfileApiPayload,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
const prefix = '/api/experience';
|
||||
|
||||
const experienceApiRoutes = Object.freeze({
|
||||
prefix,
|
||||
identification: `${prefix}/identification`,
|
||||
submit: `${prefix}/submit`,
|
||||
verification: `${prefix}/verification`,
|
||||
profile: `${prefix}/profile`,
|
||||
mfa: `${prefix}/profile/mfa`,
|
||||
});
|
||||
|
||||
type VerificationResponse = {
|
||||
verificationId: string;
|
||||
};
|
||||
|
||||
type SubmitInteractionResponse = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
const initInteraction = async (interactionEvent: InteractionEvent) =>
|
||||
api.put(`${experienceApiRoutes.prefix}`, {
|
||||
json: {
|
||||
interactionEvent,
|
||||
},
|
||||
});
|
||||
|
||||
const identifyUser = async (payload: IdentificationApiPayload = {}) =>
|
||||
api.post(experienceApiRoutes.identification, { json: payload });
|
||||
|
||||
const submitInteraction = async () =>
|
||||
api.post(`${experienceApiRoutes.submit}`).json<SubmitInteractionResponse>();
|
||||
|
||||
const updateProfile = async (payload: UpdateProfileApiPayload) => {
|
||||
await api.post(experienceApiRoutes.profile, { json: payload });
|
||||
};
|
||||
|
||||
export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
|
||||
const { verificationId } = await api
|
||||
.post(`${experienceApiRoutes.verification}/password`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
await identifyUser({ verificationId });
|
||||
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const registerWithUsername = async (username: string) => {
|
||||
await initInteraction(InteractionEvent.Register);
|
||||
|
||||
return updateProfile({ type: SignInIdentifier.Username, value: username });
|
||||
};
|
||||
|
||||
export const continueRegisterWithPassword = async (password: string) => {
|
||||
await updateProfile({ type: 'password', value: password });
|
||||
|
||||
await identifyUser();
|
||||
|
||||
return submitInteraction();
|
||||
};
|
14
packages/experience/src/apis/experience/const.ts
Normal file
14
packages/experience/src/apis/experience/const.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export const prefix = '/api/experience';
|
||||
|
||||
export const experienceApiRoutes = Object.freeze({
|
||||
prefix,
|
||||
identification: `${prefix}/identification`,
|
||||
submit: `${prefix}/submit`,
|
||||
verification: `${prefix}/verification`,
|
||||
profile: `${prefix}/profile`,
|
||||
mfa: `${prefix}/profile/mfa`,
|
||||
});
|
||||
|
||||
export type VerificationResponse = {
|
||||
verificationId: string;
|
||||
};
|
149
packages/experience/src/apis/experience/index.ts
Normal file
149
packages/experience/src/apis/experience/index.ts
Normal file
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
type PasswordVerificationPayload,
|
||||
SignInIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { type ContinueFlowInteractionEvent } from '@/types';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { experienceApiRoutes, type VerificationResponse } from './const';
|
||||
import {
|
||||
initInteraction,
|
||||
identifyUser,
|
||||
submitInteraction,
|
||||
updateInteractionEvent,
|
||||
_updateProfile,
|
||||
identifyAndSubmitInteraction,
|
||||
} from './interaction';
|
||||
|
||||
export {
|
||||
initInteraction,
|
||||
submitInteraction,
|
||||
identifyUser,
|
||||
identifyAndSubmitInteraction,
|
||||
} from './interaction';
|
||||
|
||||
export * from './mfa';
|
||||
export * from './social';
|
||||
|
||||
export const registerWithVerifiedIdentifier = async (verificationId: string) => {
|
||||
await updateInteractionEvent(InteractionEvent.Register);
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
};
|
||||
|
||||
export const signInWithVerifiedIdentifier = async (verificationId: string) => {
|
||||
await updateInteractionEvent(InteractionEvent.SignIn);
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
};
|
||||
|
||||
// Password APIs
|
||||
export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
|
||||
const { verificationId } = await api
|
||||
.post(`${experienceApiRoutes.verification}/password`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
};
|
||||
|
||||
export const registerWithUsername = async (username: string) => {
|
||||
await initInteraction(InteractionEvent.Register);
|
||||
|
||||
return _updateProfile({ type: SignInIdentifier.Username, value: username });
|
||||
};
|
||||
|
||||
export const continueRegisterWithPassword = async (password: string) => {
|
||||
await _updateProfile({ type: 'password', value: password });
|
||||
|
||||
return identifyAndSubmitInteraction();
|
||||
};
|
||||
|
||||
// Verification code APIs
|
||||
type VerificationCodePayload = {
|
||||
identifier: VerificationCodeIdentifier;
|
||||
code: string;
|
||||
verificationId: string;
|
||||
};
|
||||
|
||||
export const sendVerificationCode = async (
|
||||
interactionEvent: InteractionEvent,
|
||||
identifier: VerificationCodeIdentifier
|
||||
) =>
|
||||
api
|
||||
.post(`${experienceApiRoutes.verification}/verification-code`, {
|
||||
json: {
|
||||
interactionEvent,
|
||||
identifier,
|
||||
},
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
const verifyVerificationCode = async (json: VerificationCodePayload) =>
|
||||
api
|
||||
.post(`${experienceApiRoutes.verification}/verification-code/verify`, {
|
||||
json,
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
export const identifyWithVerificationCode = async (json: VerificationCodePayload) => {
|
||||
const { verificationId } = await verifyVerificationCode(json);
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
};
|
||||
|
||||
// Profile APIs
|
||||
|
||||
export const updateProfileWithVerificationCode = async (
|
||||
json: VerificationCodePayload,
|
||||
interactionEvent?: ContinueFlowInteractionEvent
|
||||
) => {
|
||||
const { verificationId } = await verifyVerificationCode(json);
|
||||
|
||||
const {
|
||||
identifier: { type },
|
||||
} = json;
|
||||
|
||||
await _updateProfile({
|
||||
type,
|
||||
verificationId,
|
||||
});
|
||||
|
||||
if (interactionEvent === InteractionEvent.Register) {
|
||||
await identifyUser();
|
||||
}
|
||||
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
type UpdateProfilePayload = {
|
||||
type: SignInIdentifier.Username | 'password';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const updateProfile = async (
|
||||
payload: UpdateProfilePayload,
|
||||
interactionEvent: ContinueFlowInteractionEvent
|
||||
) => {
|
||||
await _updateProfile(payload);
|
||||
|
||||
if (interactionEvent === InteractionEvent.Register) {
|
||||
await identifyUser();
|
||||
}
|
||||
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const resetPassword = async (password: string) => {
|
||||
await api.put(`${experienceApiRoutes.profile}/password`, {
|
||||
json: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
return submitInteraction();
|
||||
};
|
41
packages/experience/src/apis/experience/interaction.ts
Normal file
41
packages/experience/src/apis/experience/interaction.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
type InteractionEvent,
|
||||
type IdentificationApiPayload,
|
||||
type UpdateProfileApiPayload,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { experienceApiRoutes } from './const';
|
||||
|
||||
type SubmitInteractionResponse = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export const initInteraction = async (interactionEvent: InteractionEvent) =>
|
||||
api.put(`${experienceApiRoutes.prefix}`, {
|
||||
json: {
|
||||
interactionEvent,
|
||||
},
|
||||
});
|
||||
|
||||
export const identifyUser = async (payload: IdentificationApiPayload = {}) =>
|
||||
api.post(experienceApiRoutes.identification, { json: payload });
|
||||
|
||||
export const submitInteraction = async () =>
|
||||
api.post(`${experienceApiRoutes.submit}`).json<SubmitInteractionResponse>();
|
||||
|
||||
export const _updateProfile = async (payload: UpdateProfileApiPayload) =>
|
||||
api.post(experienceApiRoutes.profile, { json: payload });
|
||||
|
||||
export const updateInteractionEvent = async (interactionEvent: InteractionEvent) =>
|
||||
api.put(`${experienceApiRoutes.prefix}/interaction-event`, {
|
||||
json: {
|
||||
interactionEvent,
|
||||
},
|
||||
});
|
||||
|
||||
export const identifyAndSubmitInteraction = async (payload?: IdentificationApiPayload) => {
|
||||
await identifyUser(payload);
|
||||
return submitInteraction();
|
||||
};
|
129
packages/experience/src/apis/experience/mfa.ts
Normal file
129
packages/experience/src/apis/experience/mfa.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
MfaFactor,
|
||||
type WebAuthnRegistrationOptions,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type BindMfaPayload,
|
||||
type VerifyMfaPayload,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { experienceApiRoutes } from './const';
|
||||
import { submitInteraction } from './interaction';
|
||||
|
||||
/**
|
||||
* Mfa APIs
|
||||
*/
|
||||
const addMfa = async (type: MfaFactor, verificationId: string) =>
|
||||
api.post(`${experienceApiRoutes.mfa}`, {
|
||||
json: {
|
||||
type,
|
||||
verificationId,
|
||||
},
|
||||
});
|
||||
|
||||
type TotpSecretResponse = {
|
||||
verificationId: string;
|
||||
secret: string;
|
||||
secretQrCode: string;
|
||||
};
|
||||
export const createTotpSecret = async () =>
|
||||
api.post(`${experienceApiRoutes.verification}/totp/secret`).json<TotpSecretResponse>();
|
||||
|
||||
export const createWebAuthnRegistration = async () => {
|
||||
const { verificationId, registrationOptions } = await api
|
||||
.post(`${experienceApiRoutes.verification}/web-authn/registration`)
|
||||
.json<{ verificationId: string; registrationOptions: WebAuthnRegistrationOptions }>();
|
||||
|
||||
return {
|
||||
verificationId,
|
||||
options: registrationOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export const createWebAuthnAuthentication = async () => {
|
||||
const { verificationId, authenticationOptions } = await api
|
||||
.post(`${experienceApiRoutes.verification}/web-authn/authentication`)
|
||||
.json<{ verificationId: string; authenticationOptions: WebAuthnAuthenticationOptions }>();
|
||||
|
||||
return {
|
||||
verificationId,
|
||||
options: authenticationOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export const createBackupCode = async () =>
|
||||
api.post(`${experienceApiRoutes.verification}/backup-code/generate`).json<{
|
||||
verificationId: string;
|
||||
codes: string[];
|
||||
}>();
|
||||
|
||||
export const skipMfa = async () => {
|
||||
await api.post(`${experienceApiRoutes.mfa}/mfa-skipped`);
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const bindMfa = async (payload: BindMfaPayload, verificationId: string) => {
|
||||
switch (payload.type) {
|
||||
case MfaFactor.TOTP: {
|
||||
const { code } = payload;
|
||||
await api.post(`${experienceApiRoutes.verification}/totp/verify`, {
|
||||
json: {
|
||||
code,
|
||||
verificationId,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MfaFactor.WebAuthn: {
|
||||
await api.post(`${experienceApiRoutes.verification}/web-authn/registration/verify`, {
|
||||
json: {
|
||||
verificationId,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MfaFactor.BackupCode: {
|
||||
// No need to verify backup codes
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await addMfa(payload.type, verificationId);
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const verifyMfa = async (payload: VerifyMfaPayload, verificationId?: string) => {
|
||||
switch (payload.type) {
|
||||
case MfaFactor.TOTP: {
|
||||
const { code } = payload;
|
||||
await api.post(`${experienceApiRoutes.verification}/totp/verify`, {
|
||||
json: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MfaFactor.WebAuthn: {
|
||||
await api.post(`${experienceApiRoutes.verification}/web-authn/authentication/verify`, {
|
||||
json: {
|
||||
verificationId,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MfaFactor.BackupCode: {
|
||||
const { code } = payload;
|
||||
await api.post(`${experienceApiRoutes.verification}/backup-code/verify`, {
|
||||
json: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return submitInteraction();
|
||||
};
|
96
packages/experience/src/apis/experience/social.ts
Normal file
96
packages/experience/src/apis/experience/social.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
// Social and SSO APIs
|
||||
|
||||
import { InteractionEvent, type SocialVerificationCallbackPayload } from '@logto/schemas';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { experienceApiRoutes, type VerificationResponse } from './const';
|
||||
import {
|
||||
identifyAndSubmitInteraction,
|
||||
initInteraction,
|
||||
updateInteractionEvent,
|
||||
identifyUser,
|
||||
submitInteraction,
|
||||
_updateProfile,
|
||||
} from './interaction';
|
||||
|
||||
export const getSocialAuthorizationUrl = async (
|
||||
connectorId: string,
|
||||
state: string,
|
||||
redirectUri: string
|
||||
) => {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
|
||||
return api
|
||||
.post(`${experienceApiRoutes.verification}/social/${connectorId}/authorization-uri`, {
|
||||
json: {
|
||||
state,
|
||||
redirectUri,
|
||||
},
|
||||
})
|
||||
.json<
|
||||
VerificationResponse & {
|
||||
authorizationUri: string;
|
||||
}
|
||||
>();
|
||||
};
|
||||
|
||||
export const verifySocialVerification = async (
|
||||
connectorId: string,
|
||||
payload: SocialVerificationCallbackPayload
|
||||
) =>
|
||||
api
|
||||
.post(`${experienceApiRoutes.verification}/social/${connectorId}/verify`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
export const bindSocialRelatedUser = async (verificationId: string) => {
|
||||
await updateInteractionEvent(InteractionEvent.SignIn);
|
||||
await identifyUser({ verificationId, linkSocialIdentity: true });
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const getSsoConnectors = async (email: string) =>
|
||||
api
|
||||
.get(`${experienceApiRoutes.prefix}/sso-connectors`, {
|
||||
searchParams: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
.json<{ connectorIds: string[] }>();
|
||||
|
||||
export const getSsoAuthorizationUrl = async (connectorId: string, payload: unknown) => {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
|
||||
return api
|
||||
.post(`${experienceApiRoutes.verification}/sso/${connectorId}/authorization-uri`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<
|
||||
VerificationResponse & {
|
||||
authorizationUri: string;
|
||||
}
|
||||
>();
|
||||
};
|
||||
|
||||
export const signInWithSso = async (
|
||||
connectorId: string,
|
||||
payload: SocialVerificationCallbackPayload & { verificationId: string }
|
||||
) => {
|
||||
await api.post(`${experienceApiRoutes.verification}/sso/${connectorId}/verify`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
return identifyAndSubmitInteraction({ verificationId: payload.verificationId });
|
||||
};
|
||||
|
||||
export const signInAndLinkWithSocial = async (
|
||||
verificationId: string,
|
||||
socialVerificationid: string
|
||||
) => {
|
||||
await updateInteractionEvent(InteractionEvent.SignIn);
|
||||
await identifyUser({ verificationId });
|
||||
await _updateProfile({ type: 'social', verificationId: socialVerificationid });
|
||||
return submitInteraction();
|
||||
};
|
|
@ -1,262 +0,0 @@
|
|||
/* istanbul ignore file */
|
||||
|
||||
import {
|
||||
InteractionEvent,
|
||||
type BindMfaPayload,
|
||||
type EmailVerificationCodePayload,
|
||||
type PhoneVerificationCodePayload,
|
||||
type SignInIdentifier,
|
||||
type SocialConnectorPayload,
|
||||
type SocialEmailPayload,
|
||||
type SocialPhonePayload,
|
||||
type VerifyMfaPayload,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import api from './api';
|
||||
|
||||
export const interactionPrefix = '/api/interaction';
|
||||
|
||||
const verificationPath = `verification`;
|
||||
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export type PasswordSignInPayload = { [K in SignInIdentifier]?: string } & { password: string };
|
||||
|
||||
export const signInWithPasswordIdentifier = async (payload: PasswordSignInPayload) => {
|
||||
await api.put(`${interactionPrefix}`, {
|
||||
json: {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: payload,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const registerWithUsernamePassword = async (username: string, password?: string) => {
|
||||
await api.put(`${interactionPrefix}`, {
|
||||
json: {
|
||||
event: InteractionEvent.Register,
|
||||
profile: {
|
||||
username,
|
||||
...conditional(password && { password }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const setUserPassword = async (password: string) => {
|
||||
await api.patch(`${interactionPrefix}/profile`, {
|
||||
json: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.post(`${interactionPrefix}/submit`).json<Response | undefined>();
|
||||
|
||||
// Reset password does not have any response body
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
return result || { success: true };
|
||||
};
|
||||
|
||||
export type SendVerificationCodePayload = {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export const putInteraction = async (event: InteractionEvent) =>
|
||||
api.put(`${interactionPrefix}`, { json: { event } });
|
||||
|
||||
export const sendVerificationCode = async (payload: SendVerificationCodePayload) => {
|
||||
await api.post(`${interactionPrefix}/${verificationPath}/verification-code`, { json: payload });
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const signInWithVerificationCodeIdentifier = async (
|
||||
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload
|
||||
) => {
|
||||
await api.patch(`${interactionPrefix}/identifiers`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const addProfileWithVerificationCodeIdentifier = async (
|
||||
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload
|
||||
) => {
|
||||
await api.patch(`${interactionPrefix}/identifiers`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
const { verificationCode, ...identifier } = payload;
|
||||
|
||||
await api.patch(`${interactionPrefix}/profile`, {
|
||||
json: identifier,
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const verifyForgotPasswordVerificationCodeIdentifier = async (
|
||||
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload
|
||||
) => {
|
||||
await api.patch(`${interactionPrefix}/identifiers`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const signInWithVerifiedIdentifier = async () => {
|
||||
await api.delete(`${interactionPrefix}/profile`);
|
||||
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.SignIn,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const registerWithVerifiedIdentifier = async (payload: SendVerificationCodePayload) => {
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.Register,
|
||||
},
|
||||
});
|
||||
|
||||
await api.put(`${interactionPrefix}/profile`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const addProfile = async (payload: { username: string } | { password: string }) => {
|
||||
await api.patch(`${interactionPrefix}/profile`, { json: payload });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const getSocialAuthorizationUrl = async (
|
||||
connectorId: string,
|
||||
state: string,
|
||||
redirectUri: string
|
||||
) => {
|
||||
await putInteraction(InteractionEvent.SignIn);
|
||||
|
||||
return api
|
||||
.post(`${interactionPrefix}/${verificationPath}/social-authorization-uri`, {
|
||||
json: {
|
||||
connectorId,
|
||||
state,
|
||||
redirectUri,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
||||
|
||||
export const signInWithSocial = async (payload: SocialConnectorPayload) => {
|
||||
await api.patch(`${interactionPrefix}/identifiers`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const registerWithVerifiedSocial = async (connectorId: string) => {
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.Register,
|
||||
},
|
||||
});
|
||||
|
||||
await api.patch(`${interactionPrefix}/profile`, {
|
||||
json: {
|
||||
connectorId,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const bindSocialRelatedUser = async (payload: SocialEmailPayload | SocialPhonePayload) => {
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.SignIn,
|
||||
},
|
||||
});
|
||||
|
||||
await api.patch(`${interactionPrefix}/identifiers`, {
|
||||
json: payload,
|
||||
});
|
||||
|
||||
await api.patch(`${interactionPrefix}/profile`, {
|
||||
json: {
|
||||
connectorId: payload.connectorId,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const linkWithSocial = async (connectorId: string) => {
|
||||
// Sign-in with pre-verified email/phone identifier instead and replace the email/phone profile with connectorId.
|
||||
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.SignIn,
|
||||
},
|
||||
});
|
||||
|
||||
await api.put(`${interactionPrefix}/profile`, {
|
||||
json: {
|
||||
connectorId,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const createTotpSecret = async () =>
|
||||
api
|
||||
.post(`${interactionPrefix}/${verificationPath}/totp`)
|
||||
.json<{ secret: string; secretQrCode: string }>();
|
||||
|
||||
export const createWebAuthnRegistrationOptions = async () =>
|
||||
api
|
||||
.post(`${interactionPrefix}/${verificationPath}/webauthn-registration`)
|
||||
.json<WebAuthnRegistrationOptions>();
|
||||
|
||||
export const generateWebAuthnAuthnOptions = async () =>
|
||||
api
|
||||
.post(`${interactionPrefix}/${verificationPath}/webauthn-authentication`)
|
||||
.json<WebAuthnAuthenticationOptions>();
|
||||
|
||||
export const bindMfa = async (payload: BindMfaPayload) => {
|
||||
await api.post(`${interactionPrefix}/bind-mfa`, { json: payload });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const verifyMfa = async (payload: VerifyMfaPayload) => {
|
||||
await api.put(`${interactionPrefix}/mfa`, { json: payload });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const skipMfa = async () => {
|
||||
await api.put(`${interactionPrefix}/mfa-skipped`, { json: { mfaSkipped: true } });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
|
@ -1,53 +0,0 @@
|
|||
import { InteractionEvent } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
import { interactionPrefix } from './interaction';
|
||||
|
||||
const ssoPrefix = `${interactionPrefix}/single-sign-on`;
|
||||
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export const getSingleSignOnConnectors = async (email: string) =>
|
||||
api
|
||||
.get(`${ssoPrefix}/connectors`, {
|
||||
searchParams: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
.json<string[]>();
|
||||
|
||||
export const getSingleSignOnUrl = async (
|
||||
connectorId: string,
|
||||
state: string,
|
||||
redirectUri: string
|
||||
) => {
|
||||
const { redirectTo } = await api
|
||||
.post(`${ssoPrefix}/${connectorId}/authorization-url`, {
|
||||
json: {
|
||||
state,
|
||||
redirectUri,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
|
||||
return redirectTo;
|
||||
};
|
||||
|
||||
export const singleSignOnAuthorization = async (connectorId: string, payload: unknown) =>
|
||||
api
|
||||
.post(`${ssoPrefix}/${connectorId}/authentication`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<Response>();
|
||||
|
||||
export const singleSignOnRegistration = async (connectorId: string) => {
|
||||
await api.put(`${interactionPrefix}/event`, {
|
||||
json: {
|
||||
event: InteractionEvent.Register,
|
||||
},
|
||||
});
|
||||
|
||||
return api.post(`${ssoPrefix}/${connectorId}/registration`).json<Response>();
|
||||
};
|
|
@ -1,26 +1,51 @@
|
|||
import { InteractionEvent } from '@logto/schemas';
|
||||
import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas';
|
||||
|
||||
import { UserFlow } from '@/types';
|
||||
import { type ContinueFlowInteractionEvent, UserFlow } from '@/types';
|
||||
|
||||
import type { SendVerificationCodePayload } from './interaction';
|
||||
import { putInteraction, sendVerificationCode } from './interaction';
|
||||
import { initInteraction, sendVerificationCode } from './experience';
|
||||
|
||||
/** Move to API */
|
||||
export const sendVerificationCodeApi = async (
|
||||
type: UserFlow,
|
||||
payload: SendVerificationCodePayload
|
||||
identifier: VerificationCodeIdentifier,
|
||||
interactionEvent?: ContinueFlowInteractionEvent
|
||||
) => {
|
||||
if (type === UserFlow.ForgotPassword) {
|
||||
await putInteraction(InteractionEvent.ForgotPassword);
|
||||
switch (type) {
|
||||
case UserFlow.SignIn: {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
return sendVerificationCode(InteractionEvent.SignIn, identifier);
|
||||
}
|
||||
case UserFlow.Register: {
|
||||
await initInteraction(InteractionEvent.Register);
|
||||
return sendVerificationCode(InteractionEvent.Register, identifier);
|
||||
}
|
||||
case UserFlow.ForgotPassword: {
|
||||
await initInteraction(InteractionEvent.ForgotPassword);
|
||||
return sendVerificationCode(InteractionEvent.ForgotPassword, identifier);
|
||||
}
|
||||
case UserFlow.Continue: {
|
||||
return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const resendVerificationCodeApi = async (
|
||||
type: UserFlow,
|
||||
identifier: VerificationCodeIdentifier
|
||||
) => {
|
||||
switch (type) {
|
||||
case UserFlow.SignIn: {
|
||||
return sendVerificationCode(InteractionEvent.SignIn, identifier);
|
||||
}
|
||||
case UserFlow.Register: {
|
||||
return sendVerificationCode(InteractionEvent.Register, identifier);
|
||||
}
|
||||
case UserFlow.ForgotPassword: {
|
||||
return sendVerificationCode(InteractionEvent.ForgotPassword, identifier);
|
||||
}
|
||||
case UserFlow.Continue: {
|
||||
// Continue flow does not have its own email template, always use sign-in template for now
|
||||
return sendVerificationCode(InteractionEvent.SignIn, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === UserFlow.SignIn) {
|
||||
await putInteraction(InteractionEvent.SignIn);
|
||||
}
|
||||
|
||||
if (type === UserFlow.Register) {
|
||||
await putInteraction(InteractionEvent.Register);
|
||||
}
|
||||
|
||||
return sendVerificationCode(payload);
|
||||
};
|
||||
|
|
|
@ -8,7 +8,7 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr
|
|||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
||||
import { registerWithUsernamePassword } from '@/apis/interaction';
|
||||
import { registerWithUsername } from '@/apis/experience';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||
import { UserFlow } from '@/types';
|
||||
|
@ -34,12 +34,9 @@ jest.mock('@/apis/utils', () => ({
|
|||
sendVerificationCodeApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
registerWithUsernamePassword: jest.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
registerWithUsername: jest.fn(async () => ({})),
|
||||
getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
}));
|
||||
|
||||
const renderForm = (
|
||||
|
@ -100,7 +97,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.general_required')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
@ -121,7 +118,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.username_should_not_start_with_number')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
@ -148,7 +145,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.username_invalid_charset')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
@ -176,7 +173,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
@ -188,7 +185,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerWithUsernamePassword).toBeCalledWith('username');
|
||||
expect(registerWithUsername).toBeCalledWith('username');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -211,7 +208,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.invalid_email')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).not.toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -244,10 +241,15 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
|
||||
email: 'foo@logto.io',
|
||||
});
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.Register,
|
||||
{
|
||||
type: SignInIdentifier.Email,
|
||||
value: 'foo@logto.io',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -271,7 +273,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.invalid_phone')).not.toBeNull();
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).not.toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -303,10 +305,15 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerWithUsernamePassword).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
|
||||
phone: `${getDefaultCountryCallingCode()}8573333333`,
|
||||
});
|
||||
expect(registerWithUsername).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.Register,
|
||||
{
|
||||
type: SignInIdentifier.Phone,
|
||||
value: `${getDefaultCountryCallingCode()}8573333333`,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -344,9 +351,14 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
|
||||
email,
|
||||
});
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.Register,
|
||||
{
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -380,14 +392,21 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
expect(queryByText('action.single_sign_on')).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
|
||||
email,
|
||||
});
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.Register,
|
||||
{
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call check single sign-on connector when the identifier is email, and goes to the SSO flow', async () => {
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({
|
||||
connectorIds: mockSsoConnectors.map(({ id }) => id),
|
||||
});
|
||||
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
[SignInIdentifier.Email],
|
||||
|
|
|
@ -36,8 +36,8 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
}));
|
||||
|
||||
const username = 'foo';
|
||||
|
@ -151,12 +151,17 @@ describe('IdentifierSignInForm', () => {
|
|||
|
||||
if (verificationCode) {
|
||||
await waitFor(() => {
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, {
|
||||
[identifier]:
|
||||
identifier === SignInIdentifier.Phone
|
||||
? `${getDefaultCountryCallingCode()}${value}`
|
||||
: value,
|
||||
});
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.SignIn,
|
||||
{
|
||||
type: identifier,
|
||||
value:
|
||||
identifier === SignInIdentifier.Phone
|
||||
? `${getDefaultCountryCallingCode()}${value}`
|
||||
: value,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(mockedNavigate).not.toBeCalled();
|
||||
});
|
||||
}
|
||||
|
@ -221,7 +226,7 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
|
||||
it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => {
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]);
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] });
|
||||
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
|
@ -255,7 +260,9 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
|
||||
it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => {
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({
|
||||
connectorIds: mockSsoConnectors.map(({ id }) => id),
|
||||
});
|
||||
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
|
|
|
@ -8,13 +8,12 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr
|
|||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
||||
import { signInWithPasswordIdentifier } from '@/apis/interaction';
|
||||
import { signInWithPasswordIdentifier } from '@/apis/experience';
|
||||
import type { SignInExperienceResponse } from '@/types';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PasswordSignInForm from '.';
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({ signInWithPasswordIdentifier: jest.fn(async () => 0) }));
|
||||
jest.mock('react-device-detect', () => ({
|
||||
isMobile: true,
|
||||
}));
|
||||
|
@ -29,9 +28,10 @@ jest.mock('i18next', () => ({
|
|||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId),
|
||||
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
signInWithPasswordIdentifier: jest.fn(async () => 0),
|
||||
getSsoAuthorizationUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId),
|
||||
getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
|
@ -175,10 +175,13 @@ describe('UsernamePasswordSignInForm', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({
|
||||
[type]:
|
||||
type === SignInIdentifier.Phone
|
||||
? `${getDefaultCountryCallingCode()}${identifier}`
|
||||
: identifier,
|
||||
identifier: {
|
||||
type,
|
||||
value:
|
||||
type === SignInIdentifier.Phone
|
||||
? `${getDefaultCountryCallingCode()}${identifier}`
|
||||
: identifier,
|
||||
},
|
||||
password: 'password',
|
||||
});
|
||||
});
|
||||
|
@ -224,7 +227,7 @@ describe('UsernamePasswordSignInForm', () => {
|
|||
|
||||
// Valid email with empty response
|
||||
const email = 'foo@logto.io';
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]);
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] });
|
||||
act(() => {
|
||||
fireEvent.change(identifierInput, { target: { value: email } });
|
||||
});
|
||||
|
@ -238,7 +241,9 @@ describe('UsernamePasswordSignInForm', () => {
|
|||
// Valid email with response
|
||||
const email2 = 'foo@bar.io';
|
||||
getSingleSignOnConnectorsMock.mockClear();
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({
|
||||
connectorIds: mockSsoConnectors.map(({ id }) => id),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(identifierInput, { target: { value: email2 } });
|
||||
|
@ -282,7 +287,9 @@ describe('UsernamePasswordSignInForm', () => {
|
|||
|
||||
const email = 'foo@bar.io';
|
||||
getSingleSignOnConnectorsMock.mockClear();
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce([mockSsoConnectors[0]!.id]);
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce({
|
||||
connectorIds: [mockSsoConnectors[0]!.id],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(identifierInput, { target: { value: email } });
|
||||
|
|
|
@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react';
|
|||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction';
|
||||
import { bindSocialRelatedUser, registerWithVerifiedIdentifier } from '@/apis/experience';
|
||||
|
||||
import SocialLinkAccount from '.';
|
||||
|
||||
|
@ -15,13 +15,14 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
registerWithVerifiedSocial: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
registerWithVerifiedIdentifier: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
}));
|
||||
|
||||
describe('SocialLinkAccount', () => {
|
||||
const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' });
|
||||
const verificationId = 'foo';
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -30,7 +31,11 @@ describe('SocialLinkAccount', () => {
|
|||
it('should render bindUser Button', async () => {
|
||||
const { getByText } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount
|
||||
connectorId="github"
|
||||
relatedUser={relatedUser}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
const bindButton = getByText('action.bind');
|
||||
|
@ -39,10 +44,7 @@ describe('SocialLinkAccount', () => {
|
|||
fireEvent.click(bindButton);
|
||||
});
|
||||
|
||||
expect(bindSocialRelatedUser).toBeCalledWith({
|
||||
connectorId: 'github',
|
||||
email: 'foo@logto.io',
|
||||
});
|
||||
expect(bindSocialRelatedUser).toBeCalledWith(verificationId);
|
||||
});
|
||||
|
||||
it('should render link email with email signUp identifier', () => {
|
||||
|
@ -57,7 +59,11 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount
|
||||
connectorId="github"
|
||||
relatedUser={relatedUser}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -77,7 +83,11 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount
|
||||
connectorId="github"
|
||||
relatedUser={relatedUser}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -97,7 +107,11 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount
|
||||
connectorId="github"
|
||||
relatedUser={relatedUser}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -108,7 +122,11 @@ describe('SocialLinkAccount', () => {
|
|||
it('should call registerWithVerifiedSocial when click create button', async () => {
|
||||
const { getByText } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount
|
||||
connectorId="github"
|
||||
relatedUser={relatedUser}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
const createButton = getByText('action.create_account_without_linking');
|
||||
|
@ -117,6 +135,6 @@ describe('SocialLinkAccount', () => {
|
|||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
expect(registerWithVerifiedSocial).toBeCalledWith('github');
|
||||
expect(registerWithVerifiedIdentifier).toBeCalledWith(verificationId);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,6 +17,7 @@ import useBindSocialRelatedUser from './use-social-link-related-user';
|
|||
type Props = {
|
||||
readonly className?: string;
|
||||
readonly connectorId: string;
|
||||
readonly verificationId: string;
|
||||
readonly relatedUser: SocialRelatedUserInfo;
|
||||
};
|
||||
|
||||
|
@ -39,7 +40,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => {
|
|||
return 'action.create_account_without_linking';
|
||||
};
|
||||
|
||||
const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
||||
const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { signUpMethods } = useSieMethods();
|
||||
|
||||
|
@ -58,10 +59,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
|||
title="action.bind"
|
||||
i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }}
|
||||
onClick={() => {
|
||||
void bindSocialRelatedUser({
|
||||
connectorId,
|
||||
...(type === 'email' ? { email: value } : { phone: value }),
|
||||
});
|
||||
void bindSocialRelatedUser(verificationId);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -72,7 +70,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
|||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(connectorId);
|
||||
void registerWithSocial(verificationId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { bindSocialRelatedUser } from '@/apis/interaction';
|
||||
import { bindSocialRelatedUser } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import {
|
||||
AgreeToTermsPolicy,
|
||||
ConnectorPlatform,
|
||||
VerificationType,
|
||||
type ExperienceSocialConnector,
|
||||
} from '@logto/schemas';
|
||||
import { useCallback, useContext } from 'react';
|
||||
|
||||
import PageContext from '@/Providers/PageContextProvider/PageContext';
|
||||
import { getSocialAuthorizationUrl } from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { getSocialAuthorizationUrl } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
|
@ -20,6 +22,8 @@ const useSocial = () => {
|
|||
const handleError = useErrorHandler();
|
||||
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
const redirectTo = useGlobalRedirectTo({
|
||||
shouldClearInteractionContextSession: false,
|
||||
isReplace: false,
|
||||
|
@ -69,19 +73,23 @@ const useSocial = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!result?.redirectTo) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { verificationId, authorizationUri } = result;
|
||||
|
||||
setVerificationId(VerificationType.Social, verificationId);
|
||||
|
||||
// Invoke native social sign-in flow
|
||||
if (isNativeWebview()) {
|
||||
nativeSignInHandler(result.redirectTo, connector);
|
||||
nativeSignInHandler(authorizationUri, connector);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Invoke web social sign-in flow
|
||||
await redirectTo(result.redirectTo);
|
||||
await redirectTo(authorizationUri);
|
||||
},
|
||||
[
|
||||
agreeToTermsPolicy,
|
||||
|
@ -89,6 +97,7 @@ const useSocial = () => {
|
|||
handleError,
|
||||
nativeSignInHandler,
|
||||
redirectTo,
|
||||
setVerificationId,
|
||||
termsValidation,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -14,11 +14,16 @@ const isCodeReady = (code: string[]) => {
|
|||
return code.length === totpCodeLength && code.every(Boolean);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly flow: UserMfaFlow;
|
||||
};
|
||||
type Props<T extends UserMfaFlow> = T extends UserMfaFlow.MfaBinding
|
||||
? {
|
||||
flow: T;
|
||||
verificationId: string;
|
||||
}
|
||||
: {
|
||||
flow: T;
|
||||
};
|
||||
|
||||
const TotpCodeVerification = ({ flow }: Props) => {
|
||||
const TotpCodeVerification = <T extends UserMfaFlow>(props: Props<T>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [codeInput, setCodeInput] = useState<string[]>([]);
|
||||
|
@ -29,10 +34,7 @@ const TotpCodeVerification = ({ flow }: Props) => {
|
|||
setInputErrorMessage(undefined);
|
||||
}, []);
|
||||
|
||||
const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification(
|
||||
flow,
|
||||
errorCallback
|
||||
);
|
||||
const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification(errorCallback);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
@ -42,10 +44,11 @@ const TotpCodeVerification = ({ flow }: Props) => {
|
|||
async (code: string[]) => {
|
||||
setInputErrorMessage(undefined);
|
||||
setIsSubmitting(true);
|
||||
await onSubmit(code.join(''));
|
||||
|
||||
await onSubmit(code.join(''), props);
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[onSubmit]
|
||||
[onSubmit, props]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -5,7 +5,7 @@ import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
|||
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
|
||||
import { type UserMfaFlow } from '@/types';
|
||||
|
||||
const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void) => {
|
||||
const useTotpCodeVerification = (errorCallback?: () => void) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
|
||||
|
@ -19,14 +19,19 @@ const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void)
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (code: string) => {
|
||||
async (
|
||||
code: string,
|
||||
payload:
|
||||
| { flow: UserMfaFlow.MfaBinding; verificationId: string }
|
||||
| { flow: UserMfaFlow.MfaVerification }
|
||||
) => {
|
||||
await sendMfaPayload(
|
||||
{ flow, payload: { type: MfaFactor.TOTP, code } },
|
||||
{ payload: { type: MfaFactor.TOTP, code }, ...payload },
|
||||
invalidCodeErrorHandlers,
|
||||
errorCallback
|
||||
);
|
||||
},
|
||||
[errorCallback, flow, invalidCodeErrorHandlers, sendMfaPayload]
|
||||
[errorCallback, invalidCodeErrorHandlers, sendMfaPayload]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import resource from '@logto/phrases-experience';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import {
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import {
|
||||
verifyForgotPasswordVerificationCodeIdentifier,
|
||||
signInWithVerificationCodeIdentifier,
|
||||
addProfileWithVerificationCodeIdentifier,
|
||||
} from '@/apis/interaction';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import { identifyWithVerificationCode, updateProfileWithVerificationCode } from '@/apis/experience';
|
||||
import { resendVerificationCodeApi } from '@/apis/utils';
|
||||
import { setupI18nForTesting } from '@/jest.setup';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
|
@ -21,22 +21,39 @@ const mockedNavigate = jest.fn();
|
|||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedNavigate,
|
||||
useLocation: jest.fn(() => ({
|
||||
state: {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/utils', () => ({
|
||||
sendVerificationCodeApi: jest.fn(),
|
||||
resendVerificationCodeApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
verifyForgotPasswordVerificationCodeIdentifier: jest.fn(),
|
||||
signInWithVerificationCodeIdentifier: jest.fn(),
|
||||
addProfileWithVerificationCodeIdentifier: jest.fn(),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
identifyWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }),
|
||||
updateProfileWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }),
|
||||
}));
|
||||
|
||||
describe('<VerificationCode />', () => {
|
||||
const redirectTo = '/redirect';
|
||||
const email = 'foo@logto.io';
|
||||
const phone = '18573333333';
|
||||
const originalLocation = window.location;
|
||||
const verificationId = '123456';
|
||||
|
||||
const emailIdentifier: VerificationCodeIdentifier = {
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
};
|
||||
|
||||
const phoneIdentifier: VerificationCodeIdentifier = {
|
||||
type: SignInIdentifier.Phone,
|
||||
value: phone,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
|
@ -47,7 +64,7 @@ describe('<VerificationCode />', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -58,7 +75,11 @@ describe('<VerificationCode />', () => {
|
|||
|
||||
it('render counter', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<VerificationCode flow={UserFlow.SignIn} identifier={SignInIdentifier.Email} target={email} />
|
||||
<VerificationCode
|
||||
flow={UserFlow.SignIn}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(queryByText('description.resend_after_seconds')).not.toBeNull();
|
||||
|
@ -87,7 +108,11 @@ describe('<VerificationCode />', () => {
|
|||
});
|
||||
|
||||
const { getByText } = renderWithPageContext(
|
||||
<VerificationCode flow={UserFlow.SignIn} identifier={SignInIdentifier.Email} target={email} />
|
||||
<VerificationCode
|
||||
flow={UserFlow.SignIn}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1e3 * 60);
|
||||
|
@ -98,7 +123,7 @@ describe('<VerificationCode />', () => {
|
|||
fireEvent.click(resendButton);
|
||||
});
|
||||
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { email });
|
||||
expect(resendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, emailIdentifier);
|
||||
|
||||
// Reset i18n
|
||||
await setupI18nForTesting();
|
||||
|
@ -106,15 +131,11 @@ describe('<VerificationCode />', () => {
|
|||
|
||||
describe('sign-in', () => {
|
||||
it('fire email sign-in validate verification code event', async () => {
|
||||
(signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: 'foo.com',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.SignIn}
|
||||
identifier={SignInIdentifier.Email}
|
||||
target={email}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
@ -126,27 +147,24 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
email,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: emailIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toBeCalledWith('foo.com');
|
||||
expect(window.location.replace).toBeCalledWith(redirectTo);
|
||||
});
|
||||
});
|
||||
|
||||
it('fire phone sign-in validate verification code event', async () => {
|
||||
(signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: 'foo.com',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.SignIn}
|
||||
identifier={SignInIdentifier.Phone}
|
||||
target={phone}
|
||||
identifier={phoneIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
@ -158,29 +176,26 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
phone,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: phoneIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toBeCalledWith('foo.com');
|
||||
expect(window.location.replace).toBeCalledWith(redirectTo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('fire email register validate verification code event', async () => {
|
||||
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: 'foo.com',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.Register}
|
||||
identifier={SignInIdentifier.Email}
|
||||
target={email}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
@ -192,27 +207,24 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
email,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: emailIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toBeCalledWith('foo.com');
|
||||
expect(window.location.replace).toBeCalledWith(redirectTo);
|
||||
});
|
||||
});
|
||||
|
||||
it('fire phone register validate verification code event', async () => {
|
||||
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: 'foo.com',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.Register}
|
||||
identifier={SignInIdentifier.Phone}
|
||||
target={phone}
|
||||
identifier={phoneIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
@ -224,29 +236,26 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
phone,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: phoneIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toBeCalledWith('foo.com');
|
||||
expect(window.location.replace).toBeCalledWith(redirectTo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgot password', () => {
|
||||
it('fire email forgot-password validate verification code event', async () => {
|
||||
(verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
success: true,
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.ForgotPassword}
|
||||
identifier={SignInIdentifier.Email}
|
||||
target={email}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -259,23 +268,20 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({
|
||||
email,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: emailIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('fire phone forgot-password validate verification code event', async () => {
|
||||
(verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
success: true,
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.ForgotPassword}
|
||||
identifier={SignInIdentifier.Phone}
|
||||
target={phone}
|
||||
identifier={phoneIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -288,9 +294,10 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({
|
||||
phone,
|
||||
verificationCode: '111111',
|
||||
expect(identifyWithVerificationCode).toBeCalledWith({
|
||||
identifier: phoneIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -298,15 +305,11 @@ describe('<VerificationCode />', () => {
|
|||
|
||||
describe('continue flow', () => {
|
||||
it('set email', async () => {
|
||||
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: '/redirect',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.Continue}
|
||||
identifier={SignInIdentifier.Email}
|
||||
target={email}
|
||||
identifier={emailIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -319,27 +322,27 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
email,
|
||||
verificationCode: '111111',
|
||||
});
|
||||
expect(updateProfileWithVerificationCode).toBeCalledWith(
|
||||
{
|
||||
identifier: emailIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
},
|
||||
InteractionEvent.SignIn
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toBeCalledWith('/redirect');
|
||||
expect(window.location.replace).toBeCalledWith(redirectTo);
|
||||
});
|
||||
});
|
||||
|
||||
it('set Phone', async () => {
|
||||
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
|
||||
redirectTo: '/redirect',
|
||||
}));
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<VerificationCode
|
||||
flow={UserFlow.Continue}
|
||||
identifier={SignInIdentifier.Phone}
|
||||
target={phone}
|
||||
identifier={phoneIdentifier}
|
||||
verificationId={verificationId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -352,10 +355,14 @@ describe('<VerificationCode />', () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
|
||||
phone,
|
||||
verificationCode: '111111',
|
||||
});
|
||||
expect(updateProfileWithVerificationCode).toBeCalledWith(
|
||||
{
|
||||
identifier: phoneIdentifier,
|
||||
verificationId,
|
||||
code: '111111',
|
||||
},
|
||||
InteractionEvent.SignIn
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { type VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
@ -15,13 +15,19 @@ import { getCodeVerificationHookByFlow } from './utils';
|
|||
|
||||
type Props = {
|
||||
readonly flow: UserFlow;
|
||||
readonly identifier: SignInIdentifier.Email | SignInIdentifier.Phone;
|
||||
readonly target: string;
|
||||
readonly identifier: VerificationCodeIdentifier;
|
||||
readonly verificationId: string;
|
||||
readonly hasPasswordButton?: boolean;
|
||||
readonly className?: string;
|
||||
};
|
||||
|
||||
const VerificationCode = ({ flow, identifier, className, hasPasswordButton, target }: Props) => {
|
||||
const VerificationCode = ({
|
||||
flow,
|
||||
identifier,
|
||||
verificationId,
|
||||
className,
|
||||
hasPasswordButton,
|
||||
}: Props) => {
|
||||
const [codeInput, setCodeInput] = useState<string[]>([]);
|
||||
const [inputErrorMessage, setInputErrorMessage] = useState<string>();
|
||||
|
||||
|
@ -43,14 +49,13 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
|||
errorMessage: submitErrorMessage,
|
||||
clearErrorMessage,
|
||||
onSubmit,
|
||||
} = useVerificationCode(identifier, target, errorCallback);
|
||||
} = useVerificationCode(identifier, verificationId, errorCallback);
|
||||
|
||||
const errorMessage = inputErrorMessage ?? submitErrorMessage;
|
||||
|
||||
const { seconds, isRunning, onResendVerificationCode } = useResendVerificationCode(
|
||||
flow,
|
||||
identifier,
|
||||
target
|
||||
identifier
|
||||
);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
@ -61,15 +66,11 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
|||
|
||||
setIsSubmitting(true);
|
||||
|
||||
await onSubmit(
|
||||
identifier === SignInIdentifier.Email
|
||||
? { email: target, verificationCode: code.join('') }
|
||||
: { phone: target, verificationCode: code.join('') }
|
||||
);
|
||||
await onSubmit(code.join(''));
|
||||
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[identifier, onSubmit, target]
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -1,74 +1,88 @@
|
|||
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import { VerificationType } from '@logto/schemas';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { updateProfileWithVerificationCode } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { continueFlowStateGuard } from '@/types/guard';
|
||||
|
||||
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
|
||||
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
|
||||
import useLinkSocialConfirmModal from './use-link-social-confirm-modal';
|
||||
|
||||
const useContinueFlowCodeVerification = (
|
||||
_method: VerificationCodeIdentifier,
|
||||
target: string,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
verificationId: string,
|
||||
errorCallback?: () => void
|
||||
) => {
|
||||
const [searchParameters] = useSearchParams();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
const { state } = useLocation();
|
||||
const [, continueFlowState] = validate(state, continueFlowStateGuard);
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const interactionEvent = continueFlowState?.interactionEvent;
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier);
|
||||
const verifyVerificationCode = useApi(updateProfileWithVerificationCode);
|
||||
|
||||
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true, interactionEvent });
|
||||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
const showLinkSocialConfirmModal = useLinkSocialConfirmModal();
|
||||
const identifierExistErrorHandler = useCallback(
|
||||
async (method: VerificationCodeIdentifier, target: string) => {
|
||||
const linkSocial = searchParameters.get(SearchParameters.LinkSocial);
|
||||
|
||||
// Show bind with social confirm modal
|
||||
if (linkSocial) {
|
||||
await showLinkSocialConfirmModal(method, target, linkSocial);
|
||||
const identifierExistErrorHandler = useCallback(async () => {
|
||||
const linkSocial = searchParameters.get(SearchParameters.LinkSocial);
|
||||
const socialVerificationId = verificationIdsMap[VerificationType.Social];
|
||||
|
||||
return;
|
||||
}
|
||||
// Show bind with social confirm modal
|
||||
if (linkSocial && socialVerificationId) {
|
||||
await showLinkSocialConfirmModal(identifier, verificationId, socialVerificationId);
|
||||
|
||||
await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target);
|
||||
},
|
||||
[searchParameters, showIdentifierErrorAlert, showLinkSocialConfirmModal]
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { type, value } = identifier;
|
||||
await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
|
||||
}, [
|
||||
identifier,
|
||||
searchParameters,
|
||||
showIdentifierErrorAlert,
|
||||
showLinkSocialConfirmModal,
|
||||
verificationId,
|
||||
verificationIdsMap,
|
||||
]);
|
||||
|
||||
const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.phone_already_in_use': async () =>
|
||||
identifierExistErrorHandler(SignInIdentifier.Phone, target),
|
||||
'user.email_already_in_use': async () =>
|
||||
identifierExistErrorHandler(SignInIdentifier.Email, target),
|
||||
'user.phone_already_in_use': identifierExistErrorHandler,
|
||||
'user.email_already_in_use': identifierExistErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
}),
|
||||
[
|
||||
preSignInErrorHandler,
|
||||
generalVerificationCodeErrorHandlers,
|
||||
identifierExistErrorHandler,
|
||||
target,
|
||||
]
|
||||
[preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistErrorHandler]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
|
||||
const [error, result] = await verifyVerificationCode(payload);
|
||||
async (code: string) => {
|
||||
const [error, result] = await verifyVerificationCode(
|
||||
{
|
||||
code,
|
||||
identifier,
|
||||
verificationId,
|
||||
},
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, verifyVerificationCodeErrorHandlers);
|
||||
|
@ -84,7 +98,10 @@ const useContinueFlowCodeVerification = (
|
|||
[
|
||||
errorCallback,
|
||||
handleError,
|
||||
identifier,
|
||||
interactionEvent,
|
||||
redirectTo,
|
||||
verificationId,
|
||||
verifyVerificationCode,
|
||||
verifyVerificationCodeErrorHandlers,
|
||||
]
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { verifyForgotPasswordVerificationCodeIdentifier } from '@/apis/interaction';
|
||||
import { identifyWithVerificationCode } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
|
||||
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
|
||||
|
||||
const useForgotPasswordFlowCodeVerification = (
|
||||
method: VerificationCodeIdentifier,
|
||||
target: string,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
verificationId: string,
|
||||
errorCallback?: () => void
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const handleError = useErrorHandler();
|
||||
const verifyVerificationCode = useApi(verifyForgotPasswordVerificationCodeIdentifier);
|
||||
const verifyVerificationCode = useApi(identifyWithVerificationCode);
|
||||
|
||||
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
|
@ -28,18 +27,32 @@ const useForgotPasswordFlowCodeVerification = (
|
|||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.user_not_exist': async () =>
|
||||
identifierErrorHandler(IdentifierErrorType.IdentifierNotExist, method, target),
|
||||
identifierErrorHandler(
|
||||
IdentifierErrorType.IdentifierNotExist,
|
||||
identifier.type,
|
||||
identifier.value
|
||||
),
|
||||
'user.new_password_required_in_profile': () => {
|
||||
navigate(`/${UserFlow.ForgotPassword}/reset`, { replace: true });
|
||||
},
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
}),
|
||||
[generalVerificationCodeErrorHandlers, identifierErrorHandler, method, target, navigate]
|
||||
[
|
||||
generalVerificationCodeErrorHandlers,
|
||||
identifierErrorHandler,
|
||||
identifier.type,
|
||||
identifier.value,
|
||||
navigate,
|
||||
]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
|
||||
const [error, result] = await verifyVerificationCode(payload);
|
||||
async (code: string) => {
|
||||
const [error, result] = await verifyVerificationCode({
|
||||
code,
|
||||
identifier,
|
||||
verificationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
|
@ -52,7 +65,15 @@ const useForgotPasswordFlowCodeVerification = (
|
|||
navigate(`/${UserFlow.SignIn}`, { replace: true });
|
||||
}
|
||||
},
|
||||
[errorCallback, errorHandlers, handleError, navigate, verifyVerificationCode]
|
||||
[
|
||||
errorCallback,
|
||||
errorHandlers,
|
||||
handleError,
|
||||
identifier,
|
||||
navigate,
|
||||
verificationId,
|
||||
verifyVerificationCode,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import type { VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import useLinkSocial from '@/hooks/use-social-link-account';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
const useLinkSocialConfirmModal = () => {
|
||||
|
@ -15,22 +15,28 @@ const useLinkSocialConfirmModal = () => {
|
|||
const navigate = useNavigate();
|
||||
|
||||
return useCallback(
|
||||
async (method: VerificationCodeIdentifier, target: string, connectorId: string) => {
|
||||
async (
|
||||
identifier: VerificationCodeIdentifier,
|
||||
identifierVerificationId: string,
|
||||
socialVerificationId: string
|
||||
) => {
|
||||
const { type, value } = identifier;
|
||||
|
||||
show({
|
||||
confirmText: 'action.bind_and_continue',
|
||||
cancelText: 'action.change',
|
||||
cancelTextI18nProps: {
|
||||
method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
method: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
},
|
||||
ModalContent: t('description.link_account_id_exists', {
|
||||
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
value:
|
||||
method === SignInIdentifier.Phone
|
||||
? formatPhoneNumberWithCountryCallingCode(target)
|
||||
: target,
|
||||
type === SignInIdentifier.Phone
|
||||
? formatPhoneNumberWithCountryCallingCode(value)
|
||||
: value,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await linkWithSocial(connectorId);
|
||||
await linkWithSocial(identifierVerificationId, socialVerificationId);
|
||||
},
|
||||
onCancel: () => {
|
||||
navigate(-1);
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas';
|
||||
import { SignInIdentifier, SignInMode } from '@logto/schemas';
|
||||
import {
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
addProfileWithVerificationCodeIdentifier,
|
||||
signInWithVerifiedIdentifier,
|
||||
} from '@/apis/interaction';
|
||||
import { identifyWithVerificationCode, signInWithVerifiedIdentifier } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
|
@ -15,15 +16,14 @@ import useErrorHandler from '@/hooks/use-error-handler';
|
|||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
|
||||
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
|
||||
|
||||
const useRegisterFlowCodeVerification = (
|
||||
method: VerificationCodeIdentifier,
|
||||
target: string,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
verificationId: string,
|
||||
errorCallback?: () => void
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -34,18 +34,30 @@ const useRegisterFlowCodeVerification = (
|
|||
const { signInMode } = useSieMethods();
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const signInWithIdentifierAsync = useApi(signInWithVerifiedIdentifier);
|
||||
const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier);
|
||||
const verifyVerificationCode = useApi(identifyWithVerificationCode);
|
||||
|
||||
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const preRegisterErrorHandler = usePreSignInErrorHandler({
|
||||
replace: true,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({
|
||||
replace: true,
|
||||
});
|
||||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
|
||||
const identifierExistErrorHandler = useCallback(async () => {
|
||||
const { type, value } = identifier;
|
||||
|
||||
// Should not redirect user to sign-in if is register-only mode
|
||||
if (signInMode === SignInMode.Register) {
|
||||
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target);
|
||||
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -53,14 +65,12 @@ const useRegisterFlowCodeVerification = (
|
|||
show({
|
||||
confirmText: 'action.sign_in',
|
||||
ModalContent: t('description.create_account_id_exists', {
|
||||
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
value:
|
||||
method === SignInIdentifier.Phone
|
||||
? formatPhoneNumberWithCountryCallingCode(target)
|
||||
: target,
|
||||
type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
const [error, result] = await signInWithIdentifierAsync();
|
||||
const [error, result] = await signInWithIdentifierAsync(verificationId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
@ -78,16 +88,16 @@ const useRegisterFlowCodeVerification = (
|
|||
});
|
||||
}, [
|
||||
handleError,
|
||||
method,
|
||||
identifier,
|
||||
navigate,
|
||||
preSignInErrorHandler,
|
||||
redirectTo,
|
||||
show,
|
||||
showIdentifierErrorAlert,
|
||||
preSignInErrorHandler,
|
||||
signInMode,
|
||||
signInWithIdentifierAsync,
|
||||
t,
|
||||
target,
|
||||
verificationId,
|
||||
]);
|
||||
|
||||
const errorHandlers = useMemo<ErrorHandlers>(
|
||||
|
@ -95,20 +105,24 @@ const useRegisterFlowCodeVerification = (
|
|||
'user.email_already_in_use': identifierExistErrorHandler,
|
||||
'user.phone_already_in_use': identifierExistErrorHandler,
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
...preSignInErrorHandler,
|
||||
...preRegisterErrorHandler,
|
||||
callback: errorCallback,
|
||||
}),
|
||||
[
|
||||
identifierExistErrorHandler,
|
||||
generalVerificationCodeErrorHandlers,
|
||||
preSignInErrorHandler,
|
||||
preRegisterErrorHandler,
|
||||
errorCallback,
|
||||
]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
|
||||
const [error, result] = await verifyVerificationCode(payload);
|
||||
async (code: string) => {
|
||||
const [error, result] = await verifyVerificationCode({
|
||||
verificationId,
|
||||
identifier,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
|
@ -121,7 +135,15 @@ const useRegisterFlowCodeVerification = (
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[errorCallback, errorHandlers, handleError, redirectTo, verifyVerificationCode]
|
||||
[
|
||||
errorCallback,
|
||||
errorHandlers,
|
||||
handleError,
|
||||
identifier,
|
||||
redirectTo,
|
||||
verificationId,
|
||||
verifyVerificationCode,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { type VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useTimer } from 'react-timer-hook';
|
||||
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { resendVerificationCodeApi } from '@/apis/utils';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useToast from '@/hooks/use-toast';
|
||||
import type { UserFlow } from '@/types';
|
||||
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
|
||||
|
||||
export const timeRange = 59;
|
||||
|
||||
|
@ -18,11 +20,7 @@ const getTimeout = () => {
|
|||
return now;
|
||||
};
|
||||
|
||||
const useResendVerificationCode = (
|
||||
type: UserFlow,
|
||||
method: SignInIdentifier.Email | SignInIdentifier.Phone,
|
||||
target: string
|
||||
) => {
|
||||
const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeIdentifier) => {
|
||||
const { setToast } = useToast();
|
||||
|
||||
const { seconds, isRunning, restart } = useTimer({
|
||||
|
@ -31,11 +29,11 @@ const useResendVerificationCode = (
|
|||
});
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const sendVerificationCode = useApi(sendVerificationCodeApi);
|
||||
const sendVerificationCode = useApi(resendVerificationCodeApi);
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
const onResendVerificationCode = useCallback(async () => {
|
||||
const payload = method === SignInIdentifier.Email ? { email: target } : { phone: target };
|
||||
const [error, result] = await sendVerificationCode(type, payload);
|
||||
const [error, result] = await sendVerificationCode(flow, identifier);
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
|
@ -44,10 +42,12 @@ const useResendVerificationCode = (
|
|||
}
|
||||
|
||||
if (result) {
|
||||
// Renew the verification ID in the context
|
||||
setVerificationId(codeVerificationTypeMap[identifier.type], result.verificationId);
|
||||
setToast(t('description.passcode_sent'));
|
||||
restart(getTimeout(), true);
|
||||
}
|
||||
}, [handleError, method, restart, sendVerificationCode, setToast, target, type]);
|
||||
}, [flow, handleError, identifier, restart, sendVerificationCode, setToast, setVerificationId]);
|
||||
|
||||
return {
|
||||
seconds,
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas';
|
||||
import { SignInIdentifier, SignInMode } from '@logto/schemas';
|
||||
import {
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
registerWithVerifiedIdentifier,
|
||||
signInWithVerificationCodeIdentifier,
|
||||
} from '@/apis/interaction';
|
||||
import { identifyWithVerificationCode, registerWithVerifiedIdentifier } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
|
@ -15,38 +16,42 @@ import useErrorHandler from '@/hooks/use-error-handler';
|
|||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
|
||||
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
|
||||
|
||||
const useSignInFlowCodeVerification = (
|
||||
method: VerificationCodeIdentifier,
|
||||
target: string,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
verificationId: string,
|
||||
errorCallback?: () => void
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
const { signInMode } = useSieMethods();
|
||||
|
||||
const { signInMode, signUpMethods } = useSieMethods();
|
||||
const handleError = useErrorHandler();
|
||||
const registerWithIdentifierAsync = useApi(registerWithVerifiedIdentifier);
|
||||
const asyncSignInWithVerificationCodeIdentifier = useApi(signInWithVerificationCodeIdentifier);
|
||||
const asyncSignInWithVerificationCodeIdentifier = useApi(identifyWithVerificationCode);
|
||||
|
||||
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const preRegisterErrorHandler = usePreSignInErrorHandler({
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
|
||||
const identifierNotExistErrorHandler = useCallback(async () => {
|
||||
const { type, value } = identifier;
|
||||
|
||||
// Should not redirect user to register if is sign-in only mode or bind social flow
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, method, target);
|
||||
if (signInMode === SignInMode.SignIn || !signUpMethods.includes(type)) {
|
||||
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, type, value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -54,19 +59,15 @@ const useSignInFlowCodeVerification = (
|
|||
show({
|
||||
confirmText: 'action.create',
|
||||
ModalContent: t('description.sign_in_id_does_not_exist', {
|
||||
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
value:
|
||||
method === SignInIdentifier.Phone
|
||||
? formatPhoneNumberWithCountryCallingCode(target)
|
||||
: target,
|
||||
type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
const [error, result] = await registerWithIdentifierAsync(
|
||||
method === SignInIdentifier.Email ? { email: target } : { phone: target }
|
||||
);
|
||||
const [error, result] = await registerWithIdentifierAsync(verificationId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
await handleError(error, preRegisterErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -80,17 +81,18 @@ const useSignInFlowCodeVerification = (
|
|||
},
|
||||
});
|
||||
}, [
|
||||
identifier,
|
||||
signInMode,
|
||||
signUpMethods,
|
||||
show,
|
||||
t,
|
||||
method,
|
||||
target,
|
||||
registerWithIdentifierAsync,
|
||||
showIdentifierErrorAlert,
|
||||
navigate,
|
||||
registerWithIdentifierAsync,
|
||||
verificationId,
|
||||
handleError,
|
||||
preSignInErrorHandler,
|
||||
preRegisterErrorHandler,
|
||||
redirectTo,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const errorHandlers = useMemo<ErrorHandlers>(
|
||||
|
@ -109,12 +111,15 @@ const useSignInFlowCodeVerification = (
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
|
||||
const [error, result] = await asyncSignInWithVerificationCodeIdentifier(payload);
|
||||
async (code: string) => {
|
||||
const [error, result] = await asyncSignInWithVerificationCodeIdentifier({
|
||||
verificationId,
|
||||
identifier,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -122,7 +127,14 @@ const useSignInFlowCodeVerification = (
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncSignInWithVerificationCodeIdentifier, errorHandlers, handleError, redirectTo]
|
||||
[
|
||||
asyncSignInWithVerificationCodeIdentifier,
|
||||
errorHandlers,
|
||||
handleError,
|
||||
identifier,
|
||||
redirectTo,
|
||||
verificationId,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { experience, type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { useCallback, useState, useContext } from 'react';
|
||||
import { useCallback, useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
|
||||
import { getSsoConnectors } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
|
||||
|
@ -13,7 +13,7 @@ import useSingleSignOn from './use-single-sign-on';
|
|||
const useCheckSingleSignOn = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const request = useApi(getSingleSignOnConnectors);
|
||||
const request = useApi(getSsoConnectors);
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } =
|
||||
useContext(UserInteractionContext);
|
||||
|
@ -56,8 +56,8 @@ const useCheckSingleSignOn = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const connectors = result
|
||||
?.map((connectorId) => availableSsoConnectorsMap.get(connectorId))
|
||||
const connectors = result?.connectorIds
|
||||
.map((connectorId) => availableSsoConnectorsMap.get(connectorId))
|
||||
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
|
||||
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector));
|
||||
|
||||
|
|
|
@ -5,15 +5,11 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import {
|
||||
type MfaFlowState,
|
||||
mfaErrorDataGuard,
|
||||
backupCodeErrorDataGuard,
|
||||
type BackupCodeBindingState,
|
||||
} from '@/types/guard';
|
||||
import { type MfaFlowState, mfaErrorDataGuard } from '@/types/guard';
|
||||
import { isNativeWebview } from '@/utils/native-sdk';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useBackupCodeBinding from './use-start-backup-code-binding';
|
||||
import useStartTotpBinding from './use-start-totp-binding';
|
||||
import useStartWebAuthnProcessing from './use-start-webauthn-processing';
|
||||
import useToast from './use-toast';
|
||||
|
@ -28,6 +24,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
const { setToast } = useToast();
|
||||
const startTotpBinding = useStartTotpBinding({ replace });
|
||||
const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace });
|
||||
const startBackupCodeBinding = useBackupCodeBinding({ replace });
|
||||
|
||||
/**
|
||||
* Redirect the user to the corresponding MFA page.
|
||||
|
@ -118,30 +115,13 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
[handleMfaRedirect, setToast]
|
||||
);
|
||||
|
||||
const handleBackupCodeError = useCallback(
|
||||
(error: RequestErrorBody) => {
|
||||
const [_, data] = validate(error.data, backupCodeErrorDataGuard);
|
||||
|
||||
if (!data) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(
|
||||
{ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` },
|
||||
{ replace, state: data satisfies BackupCodeBindingState }
|
||||
);
|
||||
},
|
||||
[navigate, replace, setToast]
|
||||
);
|
||||
|
||||
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding),
|
||||
'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification),
|
||||
'session.mfa.backup_code_required': handleBackupCodeError,
|
||||
'session.mfa.backup_code_required': startBackupCodeBinding,
|
||||
}),
|
||||
[handleBackupCodeError, handleMfaError]
|
||||
[handleMfaError, startBackupCodeBinding]
|
||||
);
|
||||
|
||||
return mfaVerificationErrorHandler;
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
import { type RequestErrorBody } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import useErrorHandler, { type ErrorHandlers } from './use-error-handler';
|
||||
import usePasswordErrorMessage from './use-password-error-message';
|
||||
import { usePasswordPolicy } from './use-sie';
|
||||
|
||||
export type PasswordAction<Response> = (password: string) => Promise<Response>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the args type, but `any` is needed for type inference
|
||||
export type SuccessHandler<F> = F extends (...args: any[]) => Promise<infer Response>
|
||||
? (result?: Response) => void
|
||||
: never;
|
||||
|
||||
type UsePasswordApiInit<Response> = {
|
||||
api: PasswordAction<Response>;
|
||||
setErrorMessage: (message?: string) => void;
|
||||
errorHandlers: ErrorHandlers;
|
||||
successHandler: SuccessHandler<PasswordAction<Response>>;
|
||||
};
|
||||
|
||||
const usePasswordAction = <Response>({
|
||||
api,
|
||||
errorHandlers,
|
||||
setErrorMessage,
|
||||
successHandler,
|
||||
}: UsePasswordApiInit<Response>): [PasswordAction<void>] => {
|
||||
const asyncAction = useApi(api);
|
||||
const handleError = useErrorHandler();
|
||||
const { getErrorMessage, getErrorMessageFromBody } = usePasswordErrorMessage();
|
||||
const { policyChecker } = usePasswordPolicy();
|
||||
const passwordRejectionHandler = useCallback(
|
||||
(error: RequestErrorBody) => {
|
||||
setErrorMessage(getErrorMessageFromBody(error));
|
||||
},
|
||||
[getErrorMessageFromBody, setErrorMessage]
|
||||
);
|
||||
|
||||
const action = useCallback(
|
||||
async (password: string) => {
|
||||
// Perform fast check before sending request
|
||||
const fastCheckErrorMessage = getErrorMessage(policyChecker.fastCheck(password));
|
||||
if (fastCheckErrorMessage) {
|
||||
setErrorMessage(fastCheckErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const [error, result] = await asyncAction(password);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, {
|
||||
'password.rejected': passwordRejectionHandler,
|
||||
...errorHandlers,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
successHandler(result);
|
||||
},
|
||||
[
|
||||
asyncAction,
|
||||
errorHandlers,
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
passwordRejectionHandler,
|
||||
policyChecker,
|
||||
setErrorMessage,
|
||||
successHandler,
|
||||
]
|
||||
);
|
||||
|
||||
return [action];
|
||||
};
|
||||
|
||||
export default usePasswordAction;
|
|
@ -10,8 +10,8 @@ import useRequiredProfileErrorHandler, {
|
|||
|
||||
type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions;
|
||||
|
||||
const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => {
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial });
|
||||
const usePreSignInErrorHandler = ({ replace, ...rest }: Options = {}): ErrorHandlers => {
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, ...rest });
|
||||
const mfaErrorHandler = useMfaErrorHandler({ replace });
|
||||
|
||||
return useMemo(
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { MissingProfile } from '@logto/schemas';
|
||||
import { InteractionEvent, MissingProfile } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { UserFlow, SearchParameters, type ContinueFlowInteractionEvent } from '@/types';
|
||||
import { missingProfileErrorDataGuard } from '@/types/guard';
|
||||
import { queryStringify } from '@/utils';
|
||||
|
||||
|
@ -13,9 +13,19 @@ import useToast from './use-toast';
|
|||
export type Options = {
|
||||
replace?: boolean;
|
||||
linkSocial?: string;
|
||||
/**
|
||||
* We use this param to track the current profile fulfillment flow.
|
||||
* If is UserFlow.Register, we need to call the identify endpoint after the user completes the profile.
|
||||
* If is UserFlow.SignIn, directly call the submitInteraction endpoint.
|
||||
**/
|
||||
interactionEvent?: ContinueFlowInteractionEvent;
|
||||
};
|
||||
|
||||
const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) => {
|
||||
const useRequiredProfileErrorHandler = ({
|
||||
replace,
|
||||
linkSocial,
|
||||
interactionEvent = InteractionEvent.SignIn,
|
||||
}: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useToast();
|
||||
|
||||
|
@ -27,9 +37,6 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
|
|||
// Required as a sign up method but missing in the user profile
|
||||
const missingProfile = data?.missingProfile[0];
|
||||
|
||||
// Required as a sign up method, verified email or phone can be found in Social Identity, but registered with a different account
|
||||
const registeredSocialIdentity = data?.registeredSocialIdentity;
|
||||
|
||||
const linkSocialQueryString = linkSocial
|
||||
? `?${queryStringify({ [SearchParameters.LinkSocial]: linkSocial })}`
|
||||
: undefined;
|
||||
|
@ -41,7 +48,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
|
|||
{
|
||||
pathname: `/${UserFlow.Continue}/${missingProfile}`,
|
||||
},
|
||||
{ replace }
|
||||
{ replace, state: { interactionEvent } }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
@ -53,7 +60,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
|
|||
pathname: `/${UserFlow.Continue}/${missingProfile}`,
|
||||
search: linkSocialQueryString,
|
||||
},
|
||||
{ replace, state: { registeredSocialIdentity } }
|
||||
{ replace, state: { interactionEvent } }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
@ -65,7 +72,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
|
|||
}
|
||||
},
|
||||
}),
|
||||
[linkSocial, navigate, replace, setToast]
|
||||
[interactionEvent, linkSocial, navigate, replace, setToast]
|
||||
);
|
||||
|
||||
return requiredProfileErrorHandler;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { type BindMfaPayload, type VerifyMfaPayload } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { bindMfa, verifyMfa } from '@/apis/interaction';
|
||||
import { bindMfa, verifyMfa } from '@/apis/experience';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import useApi from './use-api';
|
||||
|
@ -13,17 +13,19 @@ export type SendMfaPayloadApiOptions =
|
|||
| {
|
||||
flow: UserMfaFlow.MfaBinding;
|
||||
payload: BindMfaPayload;
|
||||
verificationId: string;
|
||||
}
|
||||
| {
|
||||
flow: UserMfaFlow.MfaVerification;
|
||||
payload: VerifyMfaPayload;
|
||||
verificationId?: string;
|
||||
};
|
||||
|
||||
const sendMfaPayloadApi = async ({ flow, payload }: SendMfaPayloadApiOptions) => {
|
||||
const sendMfaPayloadApi = async ({ flow, payload, verificationId }: SendMfaPayloadApiOptions) => {
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
return bindMfa(payload);
|
||||
return bindMfa(payload, verificationId);
|
||||
}
|
||||
return verifyMfa(payload);
|
||||
return verifyMfa(payload, verificationId);
|
||||
};
|
||||
|
||||
const useSendMfaPayload = () => {
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
/* Replace legacy useSendVerificationCode hook with this one after the refactor */
|
||||
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useCallback, useContext, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import { type VerificationCodeIdentifier, type UserFlow } from '@/types';
|
||||
import {
|
||||
UserFlow,
|
||||
type ContinueFlowInteractionEvent,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@/types';
|
||||
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
|
||||
|
||||
const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
@ -15,6 +22,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
|
|||
|
||||
const handleError = useErrorHandler();
|
||||
const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage('');
|
||||
|
@ -26,10 +34,15 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
|
|||
};
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ identifier, value }: Payload) => {
|
||||
const [error, result] = await asyncSendVerificationCode(flow, {
|
||||
[identifier]: value,
|
||||
});
|
||||
async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => {
|
||||
const [error, result] = await asyncSendVerificationCode(
|
||||
flow,
|
||||
{
|
||||
type: identifier,
|
||||
value,
|
||||
},
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, {
|
||||
|
@ -44,6 +57,9 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
|
|||
}
|
||||
|
||||
if (result) {
|
||||
// Store the verification ID in the context so that we can use it in the next step
|
||||
setVerificationId(codeVerificationTypeMap[identifier], result.verificationId);
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname: `/${flow}/verification-code`,
|
||||
|
@ -51,11 +67,17 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
|
|||
},
|
||||
{
|
||||
replace: replaceCurrentPage,
|
||||
// Append the interaction event to the state so that we can use it in the next step
|
||||
...conditional(
|
||||
flow === UserFlow.Continue && {
|
||||
state: { interactionEvent },
|
||||
}
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage]
|
||||
[asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage, setVerificationId]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
import { useCallback } from 'react';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { identifierInputValueGuard, ssoConnectorMetadataGuard } from '@/types/guard';
|
||||
import {
|
||||
identifierInputValueGuard,
|
||||
ssoConnectorMetadataGuard,
|
||||
verificationIdsMapGuard,
|
||||
} from '@/types/guard';
|
||||
|
||||
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
||||
|
||||
|
@ -13,6 +17,7 @@ export enum StorageKeys {
|
|||
SsoConnectors = 'sso-connectors',
|
||||
IdentifierInputValue = 'identifier-input-value',
|
||||
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
|
||||
verificationIds = 'verification-ids',
|
||||
}
|
||||
|
||||
const valueGuard = Object.freeze({
|
||||
|
@ -20,6 +25,7 @@ const valueGuard = Object.freeze({
|
|||
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
|
||||
[StorageKeys.IdentifierInputValue]: identifierInputValueGuard,
|
||||
[StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard,
|
||||
[StorageKeys.verificationIds]: verificationIdsMapGuard,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
|
||||
} satisfies { [key in StorageKeys]: s.Struct<any> });
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@ import {
|
|||
experience,
|
||||
type SsoConnectorMetadata,
|
||||
} from '@logto/schemas';
|
||||
import { useEffect, useCallback, useContext } from 'react';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
|
||||
import { getSsoConnectors } from '@/apis/experience';
|
||||
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSingleSignOn from '@/hooks/use-single-sign-on';
|
||||
|
@ -28,7 +28,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
|
|||
|
||||
const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
|
||||
|
||||
const request = useApi(getSingleSignOnConnectors, { silent: true });
|
||||
const request = useApi(getSsoConnectors, { silent: true });
|
||||
|
||||
const singleSignOn = useSingleSignOn();
|
||||
|
||||
|
@ -43,7 +43,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
const connectors = result
|
||||
const connectors = result.connectorIds
|
||||
.map((connectorId) => availableSsoConnectorsMap.get(connectorId))
|
||||
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
|
||||
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector));
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { useCallback } from 'react';
|
||||
import { VerificationType } from '@logto/schemas';
|
||||
import { useCallback, useContext } from 'react';
|
||||
|
||||
import { getSingleSignOnUrl } from '@/apis/single-sign-on';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { getSsoAuthorizationUrl } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk';
|
||||
|
@ -10,11 +12,12 @@ import useGlobalRedirectTo from './use-global-redirect-to';
|
|||
|
||||
const useSingleSignOn = () => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl);
|
||||
const asyncInvokeSingleSignOn = useApi(getSsoAuthorizationUrl);
|
||||
const redirectTo = useGlobalRedirectTo({
|
||||
shouldClearInteractionContextSession: false,
|
||||
isReplace: false,
|
||||
});
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
/**
|
||||
* Native IdP Sign In Flow
|
||||
|
@ -45,11 +48,10 @@ const useSingleSignOn = () => {
|
|||
const state = generateState();
|
||||
storeState(state, connectorId);
|
||||
|
||||
const [error, redirectUrl] = await asyncInvokeSingleSignOn(
|
||||
connectorId,
|
||||
const [error, result] = await asyncInvokeSingleSignOn(connectorId, {
|
||||
state,
|
||||
`${window.location.origin}/callback/${connectorId}`
|
||||
);
|
||||
redirectUri: `${window.location.origin}/callback/${connectorId}`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
|
@ -57,19 +59,23 @@ const useSingleSignOn = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!redirectUrl) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { authorizationUri, verificationId } = result;
|
||||
|
||||
setVerificationId(VerificationType.EnterpriseSso, verificationId);
|
||||
|
||||
// Invoke Native Sign In flow
|
||||
if (isNativeWebview()) {
|
||||
nativeSignInHandler(redirectUrl, connectorId);
|
||||
nativeSignInHandler(authorizationUri, connectorId);
|
||||
}
|
||||
|
||||
// Invoke Web Sign In flow
|
||||
await redirectTo(redirectUrl);
|
||||
await redirectTo(authorizationUri);
|
||||
},
|
||||
[asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo]
|
||||
[asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo, setVerificationId]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { skipMfa } from '@/apis/interaction';
|
||||
import { skipMfa } from '@/apis/experience';
|
||||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
|
|
|
@ -1,22 +1,27 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { linkWithSocial } from '@/apis/interaction';
|
||||
import { signInAndLinkWithSocial } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import useErrorHandler from './use-error-handler';
|
||||
import useGlobalRedirectTo from './use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
|
||||
const useLinkSocial = () => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncLinkWithSocial = useApi(linkWithSocial);
|
||||
const asyncLinkWithSocial = useApi(signInAndLinkWithSocial);
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
return useCallback(
|
||||
async (connectorId: string) => {
|
||||
const [error, result] = await asyncLinkWithSocial(connectorId);
|
||||
async (identifierVerificationId: string, socialVerificationId: string) => {
|
||||
const [error, result] = await asyncLinkWithSocial(
|
||||
identifierVerificationId,
|
||||
socialVerificationId
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -25,7 +30,7 @@ const useLinkSocial = () => {
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncLinkWithSocial, handleError, redirectTo]
|
||||
[asyncLinkWithSocial, handleError, preSignInErrorHandler, redirectTo]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
import { InteractionEvent } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { registerWithVerifiedSocial } from '@/apis/interaction';
|
||||
import { registerWithVerifiedIdentifier } from '@/apis/experience';
|
||||
|
||||
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';
|
||||
|
||||
const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
||||
const useSocialRegister = (connectorId: string, replace?: boolean) => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial);
|
||||
const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier);
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace });
|
||||
const preRegisterErrorHandler = usePreSignInErrorHandler({
|
||||
linkSocial: connectorId,
|
||||
replace,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
async (connectorId: string) => {
|
||||
const [error, result] = await asyncRegisterWithSocial(connectorId);
|
||||
async (verificationId: string) => {
|
||||
const [error, result] = await asyncRegisterWithSocial(verificationId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
await handleError(error, preRegisterErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -28,7 +33,7 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncRegisterWithSocial, handleError, preSignInErrorHandler, redirectTo]
|
||||
[asyncRegisterWithSocial, handleError, preRegisterErrorHandler, redirectTo]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import { MfaFactor, VerificationType } from '@logto/schemas';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { createBackupCode } from '@/apis/experience';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type BackupCodeBindingState } from '@/types/guard';
|
||||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
|
||||
type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useBackupCodeBinding = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const generateBackUpCodes = useApi(createBackupCode);
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
return useCallback(async () => {
|
||||
const [error, result] = await generateBackUpCodes();
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { verificationId, codes } = result;
|
||||
setVerificationId(VerificationType.BackupCode, verificationId);
|
||||
|
||||
navigate(
|
||||
{ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` },
|
||||
{ replace, state: { codes } satisfies BackupCodeBindingState }
|
||||
);
|
||||
}, [generateBackUpCodes, handleError, navigate, replace, setVerificationId]);
|
||||
};
|
||||
|
||||
export default useBackupCodeBinding;
|
|
@ -1,8 +1,9 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { MfaFactor, VerificationType } from '@logto/schemas';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createTotpSecret } from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { createTotpSecret } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
@ -15,6 +16,7 @@ type Options = {
|
|||
const useStartTotpBinding = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const asyncCreateTotpSecret = useApi(createTotpSecret);
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
|
@ -27,18 +29,20 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { secret, secretQrCode } = result ?? {};
|
||||
|
||||
if (secret && secretQrCode) {
|
||||
if (result) {
|
||||
const { secret, secretQrCode, verificationId } = result;
|
||||
const state: TotpBindingState = {
|
||||
secret,
|
||||
secretQrCode,
|
||||
...flowState,
|
||||
};
|
||||
|
||||
setVerificationId(VerificationType.TOTP, verificationId);
|
||||
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state });
|
||||
}
|
||||
},
|
||||
[asyncCreateTotpSecret, handleError, navigate, replace]
|
||||
[asyncCreateTotpSecret, handleError, navigate, replace, setVerificationId]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { MfaFactor, VerificationType } from '@logto/schemas';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
createWebAuthnRegistrationOptions,
|
||||
generateWebAuthnAuthnOptions,
|
||||
} from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { createWebAuthnRegistration, createWebAuthnAuthentication } from '@/apis/experience';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type WebAuthnState, type MfaFlowState } from '@/types/guard';
|
||||
|
||||
|
@ -18,13 +16,14 @@ type Options = {
|
|||
|
||||
const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions);
|
||||
const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions);
|
||||
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistration);
|
||||
const asyncGenerateAuthnOptions = useApi(createWebAuthnAuthentication);
|
||||
const handleError = useErrorHandler();
|
||||
const { setVerificationId } = useContext(UserInteractionContext);
|
||||
|
||||
return useCallback(
|
||||
async (flow: UserMfaFlow, flowState: MfaFlowState) => {
|
||||
const [error, options] =
|
||||
const [error, result] =
|
||||
flow === UserMfaFlow.MfaBinding
|
||||
? await asyncCreateRegistrationOptions()
|
||||
: await asyncGenerateAuthnOptions();
|
||||
|
@ -34,7 +33,10 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (options) {
|
||||
if (result) {
|
||||
const { verificationId, options } = result;
|
||||
setVerificationId(VerificationType.WebAuthn, verificationId);
|
||||
|
||||
const state: WebAuthnState = {
|
||||
options,
|
||||
...flowState,
|
||||
|
@ -43,7 +45,14 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
|
|||
navigate({ pathname: `/${flow}/${MfaFactor.WebAuthn}` }, { replace, state });
|
||||
}
|
||||
},
|
||||
[asyncCreateRegistrationOptions, asyncGenerateAuthnOptions, handleError, navigate, replace]
|
||||
[
|
||||
asyncCreateRegistrationOptions,
|
||||
asyncGenerateAuthnOptions,
|
||||
handleError,
|
||||
navigate,
|
||||
replace,
|
||||
setVerificationId,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ const useWebAuthnOperation = () => {
|
|||
* Therefore, we should avoid asynchronous operations before invoking the WebAuthn API or the os may consider the WebAuthn authorization is not initiated by the user.
|
||||
* So, we need to prepare the necessary WebAuthn options before calling the WebAuthn API, this is why we don't generate the options in this function.
|
||||
*/
|
||||
async (options: WebAuthnOptions) => {
|
||||
async (options: WebAuthnOptions, verificationId: string) => {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
setToast(t('mfa.webauthn_not_supported'));
|
||||
return;
|
||||
|
@ -63,19 +63,26 @@ const useWebAuthnOperation = () => {
|
|||
}
|
||||
);
|
||||
|
||||
if (response) {
|
||||
/**
|
||||
* Assert type manually to get the correct type
|
||||
*/
|
||||
void sendMfaPayload(
|
||||
isAuthenticationResponseJSON(response)
|
||||
? {
|
||||
flow: UserMfaFlow.MfaVerification,
|
||||
payload: { ...response, type: MfaFactor.WebAuthn },
|
||||
}
|
||||
: { flow: UserMfaFlow.MfaBinding, payload: { ...response, type: MfaFactor.WebAuthn } }
|
||||
);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert type manually to get the correct type
|
||||
*/
|
||||
void sendMfaPayload(
|
||||
isAuthenticationResponseJSON(response)
|
||||
? {
|
||||
flow: UserMfaFlow.MfaVerification,
|
||||
payload: { ...response, type: MfaFactor.WebAuthn },
|
||||
verificationId,
|
||||
}
|
||||
: {
|
||||
flow: UserMfaFlow.MfaBinding,
|
||||
payload: { ...response, type: MfaFactor.WebAuthn },
|
||||
verificationId,
|
||||
}
|
||||
);
|
||||
},
|
||||
[sendMfaPayload, setToast, t]
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
import { InteractionEvent, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
@ -37,10 +37,17 @@ jest.mock('@/apis/utils', () => ({
|
|||
}));
|
||||
|
||||
describe('continue with email or phone', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderPage = (missingProfile: VerificationCodeProfileType) =>
|
||||
renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SetEmailOrPhone missingProfile={missingProfile} />
|
||||
<SetEmailOrPhone
|
||||
missingProfile={missingProfile}
|
||||
interactionEvent={InteractionEvent.Register}
|
||||
/>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -75,7 +82,7 @@ describe('continue with email or phone', () => {
|
|||
] satisfies Array<[VerificationCodeProfileType, VerificationCodeIdentifier, string]>)(
|
||||
'should send verification code properly',
|
||||
async (type, identifier, input) => {
|
||||
const { getByLabelText, getByText, container } = renderPage(type);
|
||||
const { getByText, container } = renderPage(type);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.continue');
|
||||
|
@ -92,9 +99,14 @@ describe('continue with email or phone', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Continue, {
|
||||
[identifier]: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
|
||||
});
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.Continue,
|
||||
{
|
||||
type: identifier,
|
||||
value: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
|
||||
},
|
||||
InteractionEvent.Register
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useContext } from 'react';
|
|||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import type { ContinueFlowInteractionEvent, VerificationCodeIdentifier } from '@/types';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import IdentifierProfileForm from '../IdentifierProfileForm';
|
||||
|
@ -17,7 +17,7 @@ export type VerificationCodeProfileType = Exclude<MissingProfile, 'username' | '
|
|||
|
||||
type Props = {
|
||||
readonly missingProfile: VerificationCodeProfileType;
|
||||
readonly notification?: TFuncKey;
|
||||
readonly interactionEvent: ContinueFlowInteractionEvent;
|
||||
};
|
||||
|
||||
export const pageContent: Record<
|
||||
|
@ -59,7 +59,7 @@ const formSettings: Record<
|
|||
},
|
||||
};
|
||||
|
||||
const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
|
||||
const SetEmailOrPhone = ({ missingProfile, interactionEvent }: Props) => {
|
||||
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue);
|
||||
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||
|
||||
|
@ -71,11 +71,11 @@ const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
|
|||
|
||||
setIdentifierInputValue({ type: identifier, value });
|
||||
|
||||
return onSubmit({ identifier, value });
|
||||
return onSubmit({ identifier, value }, interactionEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout {...pageContent[missingProfile]} notification={notification}>
|
||||
<SecondaryPageLayout {...pageContent[missingProfile]}>
|
||||
<IdentifierProfileForm
|
||||
autoFocus
|
||||
errorMessage={errorMessage}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { InteractionEvent } from '@logto/schemas';
|
||||
import { act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import { addProfile } from '@/apis/interaction';
|
||||
import { updateProfile } from '@/apis/experience';
|
||||
|
||||
import SetPassword from '.';
|
||||
|
||||
|
@ -14,15 +15,15 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
addProfile: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
updateProfile: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
}));
|
||||
|
||||
describe('SetPassword', () => {
|
||||
it('render set-password page properly without confirm password field', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SetPassword />
|
||||
<SetPassword interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
|
@ -41,7 +42,7 @@ describe('SetPassword', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SetPassword />
|
||||
<SetPassword interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
|
@ -60,7 +61,7 @@ describe('SetPassword', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SetPassword />
|
||||
<SetPassword interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
const submitButton = getByText('action.save_password');
|
||||
|
@ -95,7 +96,7 @@ describe('SetPassword', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SetPassword />
|
||||
<SetPassword interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
const submitButton = getByText('action.save_password');
|
||||
|
@ -115,7 +116,13 @@ describe('SetPassword', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ password: '1234!@#$' });
|
||||
expect(updateProfile).toBeCalledWith(
|
||||
{
|
||||
type: 'password',
|
||||
value: '1234!@#$',
|
||||
},
|
||||
InteractionEvent.Register
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,17 +2,26 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import { addProfile } from '@/apis/interaction';
|
||||
import { updateProfile } from '@/apis/experience';
|
||||
import SetPasswordForm from '@/containers/SetPassword';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
|
||||
import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker';
|
||||
import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { usePasswordPolicy } from '@/hooks/use-sie';
|
||||
import { type ContinueFlowInteractionEvent } from '@/types';
|
||||
|
||||
const SetPassword = () => {
|
||||
type Props = {
|
||||
readonly interactionEvent: ContinueFlowInteractionEvent;
|
||||
};
|
||||
|
||||
const SetPassword = ({ interactionEvent }: Props) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage(undefined);
|
||||
}, []);
|
||||
|
@ -21,7 +30,12 @@ const SetPassword = () => {
|
|||
const { show } = usePromiseConfirmModal();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
|
||||
const addPassword = useApi(updateProfile);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ interactionEvent, replace: true });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
@ -30,25 +44,36 @@ const SetPassword = () => {
|
|||
navigate(-1);
|
||||
},
|
||||
...preSignInErrorHandler,
|
||||
...passwordRejectionErrorHandler,
|
||||
}),
|
||||
[navigate, preSignInErrorHandler, show]
|
||||
[navigate, passwordRejectionErrorHandler, preSignInErrorHandler, show]
|
||||
);
|
||||
const successHandler: SuccessHandler<typeof addProfile> = useCallback(
|
||||
async (result) => {
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (password: string) => {
|
||||
const success = await checkPassword(password);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [error, result] = await addPassword(
|
||||
{ type: 'password', value: password },
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.redirectTo) {
|
||||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[redirectTo]
|
||||
[addPassword, checkPassword, errorHandlers, interactionEvent, handleError, redirectTo]
|
||||
);
|
||||
|
||||
const [action] = usePasswordAction({
|
||||
api: async (password) => addProfile({ password }),
|
||||
setErrorMessage,
|
||||
errorHandlers,
|
||||
successHandler,
|
||||
});
|
||||
|
||||
const {
|
||||
policy: {
|
||||
length: { min, max },
|
||||
|
@ -68,7 +93,7 @@ const SetPassword = () => {
|
|||
errorMessage={errorMessage}
|
||||
maxLength={max}
|
||||
clearErrorMessage={clearErrorMessage}
|
||||
onSubmit={action}
|
||||
onSubmit={onSubmitHandler}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
import { act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { addProfile } from '@/apis/interaction';
|
||||
import { updateProfile } from '@/apis/experience';
|
||||
|
||||
import SetUsername from '.';
|
||||
|
||||
|
@ -19,15 +20,15 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
addProfile: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
updateProfile: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
}));
|
||||
|
||||
describe('SetUsername', () => {
|
||||
it('render SetUsername page properly', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SetUsername />
|
||||
<SetUsername interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
|
||||
|
@ -37,7 +38,7 @@ describe('SetUsername', () => {
|
|||
it('should submit properly', async () => {
|
||||
const { getByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SetUsername />
|
||||
<SetUsername interactionEvent={InteractionEvent.Register} />
|
||||
</SettingsProvider>
|
||||
);
|
||||
const submitButton = getByText('action.continue');
|
||||
|
@ -52,7 +53,10 @@ describe('SetUsername', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ username: 'username' });
|
||||
expect(updateProfile).toBeCalledWith(
|
||||
{ type: SignInIdentifier.Username, value: 'username' },
|
||||
InteractionEvent.Register
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import type { TFuncKey } from 'i18next';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { type ContinueFlowInteractionEvent } from '@/types';
|
||||
|
||||
import IdentifierProfileForm from '../IdentifierProfileForm';
|
||||
|
||||
import useSetUsername from './use-set-username';
|
||||
|
||||
type Props = {
|
||||
readonly notification?: TFuncKey;
|
||||
readonly interactionEvent: ContinueFlowInteractionEvent;
|
||||
};
|
||||
|
||||
const SetUsername = (props: Props) => {
|
||||
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername();
|
||||
const SetUsername = ({ interactionEvent }: Props) => {
|
||||
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(interactionEvent);
|
||||
|
||||
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||
|
||||
|
@ -32,7 +32,6 @@ const SetUsername = (props: Props) => {
|
|||
<SecondaryPageLayout
|
||||
title="description.enter_username"
|
||||
description="description.enter_username_description"
|
||||
{...props}
|
||||
>
|
||||
<IdentifierProfileForm
|
||||
autoFocus
|
||||
|
|
|
@ -1,24 +1,28 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { addProfile } from '@/apis/interaction';
|
||||
import { updateProfile } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { type ContinueFlowInteractionEvent } from '@/types';
|
||||
|
||||
const useSetUsername = () => {
|
||||
const useSetUsername = (interactionEvent: ContinueFlowInteractionEvent) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage('');
|
||||
}, []);
|
||||
|
||||
const asyncAddProfile = useApi(addProfile);
|
||||
const asyncAddProfile = useApi(updateProfile);
|
||||
const handleError = useErrorHandler();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({
|
||||
interactionEvent,
|
||||
});
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
@ -32,7 +36,10 @@ const useSetUsername = () => {
|
|||
|
||||
const onSubmit = useCallback(
|
||||
async (username: string) => {
|
||||
const [error, result] = await asyncAddProfile({ username });
|
||||
const [error, result] = await asyncAddProfile(
|
||||
{ type: SignInIdentifier.Username, value: username },
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
|
@ -44,7 +51,7 @@ const useSetUsername = () => {
|
|||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncAddProfile, errorHandlers, handleError, redirectTo]
|
||||
[asyncAddProfile, errorHandlers, handleError, interactionEvent, redirectTo]
|
||||
);
|
||||
|
||||
return { errorMessage, clearErrorMessage, onSubmit };
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { MissingProfile } from '@logto/schemas';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { continueFlowStateGuard } from '@/types/guard';
|
||||
|
||||
import SetEmailOrPhone from './SetEmailOrPhone';
|
||||
import SetPassword from './SetPassword';
|
||||
|
@ -13,13 +15,22 @@ type Parameters = {
|
|||
|
||||
const Continue = () => {
|
||||
const { method = '' } = useParams<Parameters>();
|
||||
const { state } = useLocation();
|
||||
|
||||
const [, continueFlowState] = validate(state, continueFlowStateGuard);
|
||||
|
||||
if (!continueFlowState) {
|
||||
return <ErrorPage title="error.invalid_session" rawMessage="flow state not found" />;
|
||||
}
|
||||
|
||||
const { interactionEvent } = continueFlowState;
|
||||
|
||||
if (method === MissingProfile.password) {
|
||||
return <SetPassword />;
|
||||
return <SetPassword interactionEvent={interactionEvent} />;
|
||||
}
|
||||
|
||||
if (method === MissingProfile.username) {
|
||||
return <SetUsername />;
|
||||
return <SetUsername interactionEvent={interactionEvent} />;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -27,7 +38,7 @@ const Continue = () => {
|
|||
method === MissingProfile.phone ||
|
||||
method === MissingProfile.emailOrPhone
|
||||
) {
|
||||
return <SetEmailOrPhone missingProfile={method} />;
|
||||
return <SetEmailOrPhone missingProfile={method} interactionEvent={interactionEvent} />;
|
||||
}
|
||||
|
||||
return <ErrorPage />;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { putInteraction, sendVerificationCode } from '@/apis/interaction';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import { UserFlow, type VerificationCodeIdentifier } from '@/types';
|
||||
|
||||
import ForgotPasswordForm from '.';
|
||||
|
@ -21,9 +21,8 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
sendVerificationCode: jest.fn(() => ({ success: true })),
|
||||
putInteraction: jest.fn(() => ({ success: true })),
|
||||
jest.mock('@/apis/utils', () => ({
|
||||
sendVerificationCodeApi: jest.fn().mockResolvedValue({ verificationId: '123' }),
|
||||
}));
|
||||
|
||||
describe('ForgotPasswordForm', () => {
|
||||
|
@ -48,6 +47,8 @@ describe('ForgotPasswordForm', () => {
|
|||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
|
@ -85,8 +86,14 @@ describe('ForgotPasswordForm', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(putInteraction).toBeCalledWith(InteractionEvent.ForgotPassword);
|
||||
expect(sendVerificationCode).toBeCalledWith({ email });
|
||||
expect(sendVerificationCodeApi).toBeCalledWith(
|
||||
UserFlow.ForgotPassword,
|
||||
{
|
||||
type: identifier,
|
||||
value,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.ForgotPassword}/verification-code`,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import Button from '@/components/Button';
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
|
||||
|
@ -20,11 +21,13 @@ const BackupCodeBinding = () => {
|
|||
const { copyText, downloadText } = useTextHandler();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[MfaFactor.BackupCode];
|
||||
|
||||
const { state } = useLocation();
|
||||
const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard);
|
||||
|
||||
if (!backupCodeBindingState) {
|
||||
if (!backupCodeBindingState || !verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
|
@ -72,6 +75,7 @@ const BackupCodeBinding = () => {
|
|||
await sendMfaPayload({
|
||||
flow: UserMfaFlow.MfaBinding,
|
||||
payload: { type: MfaFactor.BackupCode },
|
||||
verificationId,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
}}
|
||||
|
|
|
@ -4,7 +4,11 @@ import SectionLayout from '@/Layout/SectionLayout';
|
|||
import TotpCodeVerification from '@/containers/TotpCodeVerification';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
const VerificationSection = () => {
|
||||
type Props = {
|
||||
readonly verificationId: string;
|
||||
};
|
||||
|
||||
const VerificationSection = ({ verificationId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
@ -16,7 +20,7 @@ const VerificationSection = () => {
|
|||
}}
|
||||
description="mfa.enter_one_time_code_link_description"
|
||||
>
|
||||
<TotpCodeVerification flow={UserMfaFlow.MfaBinding} />
|
||||
<TotpCodeVerification flow={UserMfaFlow.MfaBinding} verificationId={verificationId} />
|
||||
</SectionLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { VerificationType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import Divider from '@/components/Divider';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useSkipMfa from '@/hooks/use-skip-mfa';
|
||||
|
@ -17,9 +20,12 @@ import styles from './index.module.scss';
|
|||
const TotpBinding = () => {
|
||||
const { state } = useLocation();
|
||||
const [, totpBindingState] = validate(state, totpBindingStateGuard);
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.TOTP];
|
||||
|
||||
const skipMfa = useSkipMfa();
|
||||
|
||||
if (!totpBindingState) {
|
||||
if (!totpBindingState || !verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
|
@ -33,7 +39,7 @@ const TotpBinding = () => {
|
|||
<div className={styles.container}>
|
||||
<SecretSection {...totpBindingState} />
|
||||
<Divider />
|
||||
<VerificationSection />
|
||||
<VerificationSection verificationId={verificationId} />
|
||||
{availableFactors.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { VerificationType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useSkipMfa from '@/hooks/use-skip-mfa';
|
||||
|
@ -18,11 +20,14 @@ import styles from './index.module.scss';
|
|||
const WebAuthnBinding = () => {
|
||||
const { state } = useLocation();
|
||||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.WebAuthn];
|
||||
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
const skipMfa = useSkipMfa();
|
||||
const [isCreatingPasskey, setIsCreatingPasskey] = useState(false);
|
||||
|
||||
if (!webAuthnState) {
|
||||
if (!webAuthnState || !verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
|
@ -43,7 +48,7 @@ const WebAuthnBinding = () => {
|
|||
isLoading={isCreatingPasskey}
|
||||
onClick={async () => {
|
||||
setIsCreatingPasskey(true);
|
||||
await handleWebAuthn(options);
|
||||
await handleWebAuthn(options, verificationId);
|
||||
setIsCreatingPasskey(false);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { useState } from 'react';
|
||||
import { VerificationType } from '@logto/schemas';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import Button from '@/components/Button';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
|
||||
|
@ -17,10 +19,13 @@ import styles from './index.module.scss';
|
|||
const WebAuthnVerification = () => {
|
||||
const { state } = useLocation();
|
||||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.WebAuthn];
|
||||
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
if (!webAuthnState) {
|
||||
if (!webAuthnState || !verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
|
@ -42,7 +47,7 @@ const WebAuthnVerification = () => {
|
|||
isLoading={isVerifying}
|
||||
onClick={async () => {
|
||||
setIsVerifying(true);
|
||||
await handleWebAuthn(options);
|
||||
await handleWebAuthn(options, verificationId);
|
||||
setIsVerifying(false);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom';
|
|||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import { setUserPassword } from '@/apis/interaction';
|
||||
import { continueRegisterWithPassword } from '@/apis/experience';
|
||||
|
||||
import RegisterPassword from '.';
|
||||
|
||||
|
@ -17,8 +17,8 @@ jest.mock('react-router-dom', () => ({
|
|||
useLocation: jest.fn(() => ({ state: { username: 'username' } })),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
setUserPassword: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
continueRegisterWithPassword: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
}));
|
||||
|
||||
const useLocationMock = useLocation as jest.Mock;
|
||||
|
@ -148,7 +148,7 @@ describe('<RegisterPassword />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setUserPassword).toBeCalledWith('1234asdf');
|
||||
expect(continueRegisterWithPassword).toBeCalledWith('1234asdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
|
|||
import { Routes, Route } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { setUserPassword } from '@/apis/interaction';
|
||||
import { resetPassword } from '@/apis/experience';
|
||||
|
||||
import ResetPassword from '.';
|
||||
|
||||
|
@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
setUserPassword: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
|
||||
}));
|
||||
|
||||
describe('ForgotPassword', () => {
|
||||
|
@ -73,7 +73,7 @@ describe('ForgotPassword', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setUserPassword).toBeCalledWith('1234!@#$');
|
||||
expect(resetPassword).toBeCalledWith('1234!@#$');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,11 +4,13 @@ import { useNavigate } from 'react-router-dom';
|
|||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { setUserPassword } from '@/apis/interaction';
|
||||
import { resetPassword } from '@/apis/experience';
|
||||
import SetPassword from '@/containers/SetPassword';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
|
||||
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker';
|
||||
import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler';
|
||||
import { usePasswordPolicy } from '@/hooks/use-sie';
|
||||
import useToast from '@/hooks/use-toast';
|
||||
|
||||
|
@ -22,6 +24,13 @@ const ResetPassword = () => {
|
|||
const navigate = useNavigate();
|
||||
const { show } = usePromiseConfirmModal();
|
||||
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||
|
||||
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
|
||||
const asyncResetPassword = useApi(resetPassword);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.verification_session_not_found': async (error) => {
|
||||
|
@ -31,28 +40,42 @@ const ResetPassword = () => {
|
|||
'user.same_password': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
...passwordRejectionErrorHandler,
|
||||
}),
|
||||
[navigate, setErrorMessage, show]
|
||||
[navigate, passwordRejectionErrorHandler, show]
|
||||
);
|
||||
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
|
||||
(result) => {
|
||||
if (result) {
|
||||
// Clear the forgot password identifier input value
|
||||
setForgotPasswordIdentifierInputValue(undefined);
|
||||
|
||||
setToast(t('description.password_changed'));
|
||||
navigate('/sign-in', { replace: true });
|
||||
const onSubmitHandler = useCallback(
|
||||
async (password: string) => {
|
||||
const success = await checkPassword(password);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[navigate, setForgotPasswordIdentifierInputValue, setToast, t]
|
||||
);
|
||||
|
||||
const [action] = usePasswordAction({
|
||||
api: setUserPassword,
|
||||
setErrorMessage,
|
||||
errorHandlers,
|
||||
successHandler,
|
||||
});
|
||||
const [error] = await asyncResetPassword(password);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the forgot password identifier input value
|
||||
setForgotPasswordIdentifierInputValue(undefined);
|
||||
setToast(t('description.password_changed'));
|
||||
navigate('/sign-in', { replace: true });
|
||||
},
|
||||
[
|
||||
asyncResetPassword,
|
||||
checkPassword,
|
||||
errorHandlers,
|
||||
handleError,
|
||||
navigate,
|
||||
setForgotPasswordIdentifierInputValue,
|
||||
setToast,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const {
|
||||
policy: {
|
||||
|
@ -73,7 +96,7 @@ const ResetPassword = () => {
|
|||
errorMessage={errorMessage}
|
||||
maxLength={max}
|
||||
clearErrorMessage={clearErrorMessage}
|
||||
onSubmit={action}
|
||||
onSubmit={onSubmitHandler}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
|
|
|
@ -4,17 +4,17 @@ import { fireEvent, waitFor, act } from '@testing-library/react';
|
|||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import {
|
||||
signInWithPasswordIdentifier,
|
||||
putInteraction,
|
||||
initInteraction,
|
||||
sendVerificationCode,
|
||||
} from '@/apis/interaction';
|
||||
} from '@/apis/experience';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import PasswordForm from '.';
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })),
|
||||
sendVerificationCode: jest.fn(() => ({ success: true })),
|
||||
putInteraction: jest.fn(() => ({ success: true })),
|
||||
initInteraction: jest.fn(() => ({ success: true })),
|
||||
}));
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
@ -90,8 +90,11 @@ describe('PasswordSignInForm', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
|
||||
expect(sendVerificationCode).toBeCalledWith({ [identifier]: value });
|
||||
expect(initInteraction).toBeCalledWith(InteractionEvent.SignIn);
|
||||
expect(sendVerificationCode).toBeCalledWith(InteractionEvent.SignIn, {
|
||||
type: identifier,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
|
@ -13,7 +12,6 @@ import SignInPassword from '.';
|
|||
describe('SignInPassword', () => {
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { set, remove } = result.current;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const email = 'email@logto.io';
|
||||
const phone = '18571111111';
|
||||
const username = 'foo';
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, VerificationType } from '@logto/schemas';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||
|
||||
import SocialRegister from '.';
|
||||
|
||||
|
@ -14,13 +17,24 @@ jest.mock('react-router-dom', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
const verificationIdsMap = { [VerificationType.Social]: 'foo' };
|
||||
|
||||
describe('SocialRegister', () => {
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { set } = result.current;
|
||||
|
||||
beforeAll(() => {
|
||||
set(StorageKeys.verificationIds, verificationIdsMap);
|
||||
});
|
||||
|
||||
it('render', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: ['/social/link/github'] }
|
||||
);
|
||||
|
@ -40,9 +54,11 @@ describe('SocialRegister', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: ['/social/link/github'] }
|
||||
);
|
||||
|
@ -62,9 +78,11 @@ describe('SocialRegister', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: ['/social/link/github'] }
|
||||
);
|
||||
|
@ -84,9 +102,11 @@ describe('SocialRegister', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/social/link/:connectorId" element={<SocialRegister />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: ['/social/link/github'] }
|
||||
);
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, VerificationType } from '@logto/schemas';
|
||||
import type { TFuncKey } from 'i18next';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { is } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import SocialLinkAccountContainer from '@/containers/SocialLinkAccount';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
|
@ -36,6 +38,8 @@ const SocialLinkAccount = () => {
|
|||
const { connectorId } = useParams<Parameters>();
|
||||
const { state } = useLocation();
|
||||
const { signUpMethods } = useSieMethods();
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.Social];
|
||||
|
||||
if (!is(state, socialAccountNotExistErrorDataGuard)) {
|
||||
return <ErrorPage rawMessage="Missing relate account info" />;
|
||||
|
@ -45,11 +49,19 @@ const SocialLinkAccount = () => {
|
|||
return <ErrorPage rawMessage="Connector not found" />;
|
||||
}
|
||||
|
||||
if (!verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
|
||||
}
|
||||
|
||||
const { relatedUser } = state;
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title={getPageTitle(signUpMethods)}>
|
||||
<SocialLinkAccountContainer connectorId={connectorId} relatedUser={relatedUser} />
|
||||
<SocialLinkAccountContainer
|
||||
connectorId={connectorId}
|
||||
verificationId={verificationId}
|
||||
relatedUser={relatedUser}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { waitFor } from '@testing-library/react';
|
||||
import { VerificationType } from '@logto/schemas';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { Navigate, Route, Routes, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSsoConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
||||
import { socialConnectors } from '@/__mocks__/social-connectors';
|
||||
import { signInWithSocial } from '@/apis/interaction';
|
||||
import { singleSignOnAuthorization } from '@/apis/single-sign-on';
|
||||
import { verifySocialVerification, signInWithSso } from '@/apis/experience';
|
||||
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||
import { type SignInExperienceResponse } from '@/types';
|
||||
import { generateState, storeState } from '@/utils/social-connectors';
|
||||
|
||||
|
@ -17,12 +19,10 @@ jest.mock('i18next', () => ({
|
|||
language: 'en',
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/interaction', () => ({
|
||||
signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
singleSignOnAuthorization: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
|
||||
jest.mock('@/apis/experience', () => ({
|
||||
verifySocialVerification: jest.fn().mockResolvedValue({ verificationId: 'foo' }),
|
||||
identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
|
||||
signInWithSso: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
|
@ -34,7 +34,19 @@ jest.mock('react-router-dom', () => ({
|
|||
const mockUseSearchParameters = useSearchParams as jest.Mock;
|
||||
const mockNavigate = Navigate as jest.Mock;
|
||||
|
||||
const verificationIdsMap = {
|
||||
[VerificationType.Social]: 'foo',
|
||||
[VerificationType.EnterpriseSso]: 'bar',
|
||||
};
|
||||
|
||||
describe('SocialCallbackPage with code', () => {
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { set } = result.current;
|
||||
|
||||
beforeAll(() => {
|
||||
set(StorageKeys.verificationIds, verificationIdsMap);
|
||||
});
|
||||
|
||||
describe('fallback', () => {
|
||||
it('should redirect to /sign-in if connectorId is not found', async () => {
|
||||
mockUseSearchParameters.mockReturnValue([new URLSearchParams('code=foo'), jest.fn()]);
|
||||
|
@ -49,7 +61,7 @@ describe('SocialCallbackPage with code', () => {
|
|||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithSocial).not.toBeCalled();
|
||||
expect(verifySocialVerification).not.toBeCalled();
|
||||
expect(mockNavigate.mock.calls[0][0].to).toBe('/sign-in');
|
||||
});
|
||||
});
|
||||
|
@ -68,20 +80,22 @@ describe('SocialCallbackPage with code', () => {
|
|||
|
||||
renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: [`/callback/social/${connectorId}`] }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithSocial).toBeCalled();
|
||||
expect(verifySocialVerification).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('callback with invalid state should not call signInWithSocial', async () => {
|
||||
(signInWithSocial as jest.Mock).mockClear();
|
||||
(verifySocialVerification as jest.Mock).mockClear();
|
||||
|
||||
mockUseSearchParameters.mockReturnValue([
|
||||
new URLSearchParams(`state=bar&code=foo`),
|
||||
|
@ -90,15 +104,17 @@ describe('SocialCallbackPage with code', () => {
|
|||
|
||||
renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: [`/callback/social/${connectorId}`] }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithSocial).not.toBeCalled();
|
||||
expect(verifySocialVerification).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -121,20 +137,22 @@ describe('SocialCallbackPage with code', () => {
|
|||
|
||||
renderWithPageContext(
|
||||
<SettingsProvider settings={sieSettings}>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: [`/callback/social/${connectorId}`] }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(singleSignOnAuthorization).toBeCalled();
|
||||
expect(signInWithSso).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('callback with invalid state should not call singleSignOnAuthorization', async () => {
|
||||
(singleSignOnAuthorization as jest.Mock).mockClear();
|
||||
it('callback with invalid state should not call signInWithSso', async () => {
|
||||
(signInWithSso as jest.Mock).mockClear();
|
||||
|
||||
mockUseSearchParameters.mockReturnValue([
|
||||
new URLSearchParams(`state=bar&code=foo`),
|
||||
|
@ -143,15 +161,17 @@ describe('SocialCallbackPage with code', () => {
|
|||
|
||||
renderWithPageContext(
|
||||
<SettingsProvider settings={sieSettings}>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
<UserInteractionContextProvider>
|
||||
<Routes>
|
||||
<Route path="/callback/social/:connectorId" element={<SocialCallback />} />
|
||||
</Routes>
|
||||
</UserInteractionContextProvider>
|
||||
</SettingsProvider>,
|
||||
{ initialEntries: [`/callback/social/${connectorId}`] }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(singleSignOnAuthorization).not.toBeCalled();
|
||||
expect(signInWithSso).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { singleSignOnAuthorization, singleSignOnRegistration } from '@/apis/single-sign-on';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { registerWithVerifiedIdentifier, signInWithSso } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
|
@ -15,13 +16,13 @@ import { validateState } from '@/utils/social-connectors';
|
|||
|
||||
const useSingleSignOnRegister = () => {
|
||||
const handleError = useErrorHandler();
|
||||
const request = useApi(singleSignOnRegistration);
|
||||
const request = useApi(registerWithVerifiedIdentifier);
|
||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const navigate = useNavigate();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
return useCallback(
|
||||
async (connectorId: string) => {
|
||||
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.
|
||||
|
@ -32,7 +33,7 @@ const useSingleSignOnRegister = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const [error, result] = await request(connectorId);
|
||||
const [error, result] = await request(verificationId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
|
@ -66,19 +67,24 @@ const useSingleSignOnListener = (connectorId: string) => {
|
|||
const { setToast } = useToast();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
const { signInMode } = useSieMethods();
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.EnterpriseSso];
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization);
|
||||
const singleSignOnAuthorizationRequest = useApi(signInWithSso);
|
||||
const registerSingleSignOnIdentity = useSingleSignOnRegister();
|
||||
|
||||
const singleSignOnHandler = useCallback(
|
||||
async (connectorId: string, data: Record<string, unknown>) => {
|
||||
async (connectorId: string, verificationId: string, data: Record<string, unknown>) => {
|
||||
const [error, result] = await singleSignOnAuthorizationRequest(connectorId, {
|
||||
...data,
|
||||
// For connector validation use
|
||||
redirectUri: `${window.location.origin}/callback/${connectorId}`,
|
||||
verificationId,
|
||||
connectorData: {
|
||||
...data,
|
||||
// For connector validation use
|
||||
redirectUri: `${window.location.origin}/callback/${connectorId}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
@ -92,7 +98,7 @@ const useSingleSignOnListener = (connectorId: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
await registerSingleSignOnIdentity(connectorId);
|
||||
await registerSingleSignOnIdentity(verificationId);
|
||||
},
|
||||
// Redirect to sign-in page if error is not handled by the error handlers
|
||||
global: async (error) => {
|
||||
|
@ -138,7 +144,14 @@ const useSingleSignOnListener = (connectorId: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
void singleSignOnHandler(connectorId, rest);
|
||||
// Validate the verificationId
|
||||
if (!verificationId) {
|
||||
setToast(t('error.invalid_session'));
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
void singleSignOnHandler(connectorId, verificationId, rest);
|
||||
}, [
|
||||
connectorId,
|
||||
isConsumed,
|
||||
|
@ -148,6 +161,7 @@ const useSingleSignOnListener = (connectorId: string) => {
|
|||
setToast,
|
||||
singleSignOnHandler,
|
||||
t,
|
||||
verificationId,
|
||||
]);
|
||||
|
||||
return { loading };
|
||||
|
|
|
@ -1,12 +1,23 @@
|
|||
import { GoogleConnector } from '@logto/connector-kit';
|
||||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AgreeToTermsPolicy,
|
||||
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';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { putInteraction, signInWithSocial } from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import {
|
||||
identifyAndSubmitInteraction,
|
||||
initInteraction,
|
||||
verifySocialVerification,
|
||||
} from '@/apis/experience';
|
||||
import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
|
@ -28,26 +39,37 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||
const [isConsumed, setIsConsumed] = useState(false);
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const { verificationIdsMap, setVerificationId } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.Social];
|
||||
|
||||
// Google One Tap will mutate the verificationId after the initial render
|
||||
// We need to store a up to date reference of the verificationId
|
||||
const verificationIdRef = useRef(verificationId);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const handleError = useErrorHandler();
|
||||
const bindSocialRelatedUser = useBindSocialRelatedUser();
|
||||
const registerWithSocial = useSocialRegister(connectorId, true);
|
||||
const asyncSignInWithSocial = useApi(signInWithSocial);
|
||||
const asyncPutInteraction = useApi(putInteraction);
|
||||
const verifySocial = useApi(verifySocialVerification);
|
||||
const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction);
|
||||
const asyncInitInteraction = useApi(initInteraction);
|
||||
|
||||
const accountNotExistErrorHandler = useCallback(
|
||||
async (error: RequestErrorBody) => {
|
||||
const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard);
|
||||
const { relatedUser } = data ?? {};
|
||||
const verificationId = verificationIdRef.current;
|
||||
|
||||
// Redirect to sign-in page if the verificationId is not set properly
|
||||
if (!verificationId) {
|
||||
setToast(t('error.invalid_session'));
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (relatedUser) {
|
||||
if (socialSignInSettings.automaticAccountLinking) {
|
||||
const { type, value } = relatedUser;
|
||||
await bindSocialRelatedUser({
|
||||
connectorId,
|
||||
...(type === 'email' ? { email: value } : { phone: value }),
|
||||
});
|
||||
await bindSocialRelatedUser(verificationId);
|
||||
} else {
|
||||
navigate(`/social/link/${connectorId}`, {
|
||||
replace: true,
|
||||
|
@ -59,17 +81,30 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
}
|
||||
|
||||
// Register with social
|
||||
await registerWithSocial(connectorId);
|
||||
await registerWithSocial(verificationId);
|
||||
},
|
||||
[
|
||||
bindSocialRelatedUser,
|
||||
connectorId,
|
||||
navigate,
|
||||
registerWithSocial,
|
||||
setToast,
|
||||
socialSignInSettings.automaticAccountLinking,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const globalErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
// Redirect to sign-in page if error is not handled by the error handlers
|
||||
global: async (error) => {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
},
|
||||
}),
|
||||
[navigate, setToast]
|
||||
);
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
|
||||
|
@ -95,14 +130,11 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
await accountNotExistErrorHandler(error);
|
||||
},
|
||||
...preSignInErrorHandler,
|
||||
// Redirect to sign-in page if error is not handled by the error handlers
|
||||
global: async (error) => {
|
||||
setToast(error.message);
|
||||
navigate('/' + experience.routes.signIn);
|
||||
},
|
||||
...globalErrorHandler,
|
||||
}),
|
||||
[
|
||||
preSignInErrorHandler,
|
||||
globalErrorHandler,
|
||||
signInMode,
|
||||
agreeToTermsPolicy,
|
||||
termsValidation,
|
||||
|
@ -112,15 +144,15 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
]
|
||||
);
|
||||
|
||||
const signInWithSocialHandler = useCallback(
|
||||
const verifySocialCallbackData = useCallback(
|
||||
async (connectorId: string, data: Record<string, unknown>) => {
|
||||
// When the callback is called from Google One Tap, the interaction event was not set yet.
|
||||
if (data[GoogleConnector.oneTapParams.csrfToken]) {
|
||||
await asyncPutInteraction(InteractionEvent.SignIn);
|
||||
await asyncInitInteraction(InteractionEvent.SignIn);
|
||||
}
|
||||
|
||||
const [error, result] = await asyncSignInWithSocial({
|
||||
connectorId,
|
||||
const [error, result] = await verifySocial(connectorId, {
|
||||
verificationId: verificationIdRef.current,
|
||||
connectorData: {
|
||||
// For validation use only
|
||||
redirectUri: `${window.location.origin}/callback/${connectorId}`,
|
||||
|
@ -128,6 +160,35 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
},
|
||||
});
|
||||
|
||||
if (error || !result) {
|
||||
setLoading(false);
|
||||
await handleError(error, globalErrorHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
const { verificationId } = result;
|
||||
|
||||
// VerificationId might not be available in the UserInteractionContext (Google one tap)
|
||||
// Always update the verificationId here
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
verificationIdRef.current = verificationId;
|
||||
setVerificationId(VerificationType.Social, verificationId);
|
||||
|
||||
return verificationId;
|
||||
},
|
||||
[asyncInitInteraction, globalErrorHandler, handleError, setVerificationId, verifySocial]
|
||||
);
|
||||
|
||||
const signInWithSocialHandler = useCallback(
|
||||
async (connectorId: string, data: Record<string, unknown>) => {
|
||||
const verificationId = await verifySocialCallbackData(connectorId, data);
|
||||
|
||||
// Exception occurred during verification drop the process
|
||||
if (!verificationId) {
|
||||
return;
|
||||
}
|
||||
const [error, result] = await asyncSignInWithSocial({ verificationId });
|
||||
|
||||
if (error) {
|
||||
setLoading(false);
|
||||
await handleError(error, signInWithSocialErrorHandlers);
|
||||
|
@ -139,7 +200,7 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers]
|
||||
[asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers, verifySocialCallbackData]
|
||||
);
|
||||
|
||||
// Social Sign-in Callback Handler
|
||||
|
@ -152,18 +213,25 @@ const useSocialSignInListener = (connectorId: string) => {
|
|||
|
||||
const { state, ...rest } = parseQueryParameters(searchParameters);
|
||||
|
||||
const isGoogleOneTap = validateGoogleOneTapCsrfToken(
|
||||
rest[GoogleConnector.oneTapParams.csrfToken]
|
||||
);
|
||||
|
||||
// Cleanup the search parameters once it's consumed
|
||||
setSearchParameters({}, { replace: true });
|
||||
|
||||
if (
|
||||
!validateState(state, connectorId) &&
|
||||
!validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken])
|
||||
) {
|
||||
if (!validateState(state, connectorId) && !isGoogleOneTap) {
|
||||
setToast(t('error.invalid_connector_auth'));
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verificationIdRef.current && !isGoogleOneTap) {
|
||||
setToast(t('error.invalid_session'));
|
||||
navigate('/' + experience.routes.signIn);
|
||||
return;
|
||||
}
|
||||
|
||||
void signInWithSocialHandler(connectorId, rest);
|
||||
}, [
|
||||
connectorId,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, VerificationType } from '@logto/schemas';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { remove } from 'tiny-cookie';
|
||||
|
@ -16,6 +16,7 @@ describe('VerificationCode Page', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Email, value: 'foo@logto.io' });
|
||||
set(StorageKeys.verificationIds, { [VerificationType.EmailVerificationCode]: 'foo' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, type VerificationCodeIdentifier } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useContext } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
@ -6,22 +6,33 @@ import { validate } from 'superstruct';
|
|||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
|
||||
import VerificationCodeContainer from '@/containers/VerificationCode';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserFlow } from '@/types';
|
||||
import { userFlowGuard } from '@/types/guard';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
|
||||
|
||||
type Parameters = {
|
||||
flow: string;
|
||||
};
|
||||
|
||||
const isValidVerificationCodeIdentifier = (
|
||||
identifierInputValue: IdentifierInputValue | undefined
|
||||
): identifierInputValue is VerificationCodeIdentifier =>
|
||||
Boolean(
|
||||
identifierInputValue?.type &&
|
||||
identifierInputValue.type !== SignInIdentifier.Username &&
|
||||
identifierInputValue.value
|
||||
);
|
||||
|
||||
const VerificationCode = () => {
|
||||
const { flow } = useParams<Parameters>();
|
||||
const { signInMethods } = useSieMethods();
|
||||
|
||||
const { identifierInputValue, forgotPasswordIdentifierInputValue } =
|
||||
const { identifierInputValue, forgotPasswordIdentifierInputValue, verificationIdsMap } =
|
||||
useContext(UserInteractionContext);
|
||||
|
||||
const [, userFlow] = validate(flow, userFlowGuard);
|
||||
|
@ -33,19 +44,24 @@ const VerificationCode = () => {
|
|||
const cachedIdentifierInputValue =
|
||||
flow === UserFlow.ForgotPassword ? forgotPasswordIdentifierInputValue : identifierInputValue;
|
||||
|
||||
const { type, value } = cachedIdentifierInputValue ?? {};
|
||||
|
||||
if (!type || type === SignInIdentifier.Username || !value) {
|
||||
if (!isValidVerificationCodeIdentifier(cachedIdentifierInputValue)) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const methodSettings = signInMethods.find((method) => method.identifier === type);
|
||||
const { type, value } = cachedIdentifierInputValue;
|
||||
|
||||
// SignIn Method not enabled
|
||||
const methodSettings = signInMethods.find((method) => method.identifier === type);
|
||||
if (!methodSettings && flow !== UserFlow.ForgotPassword) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
// VerificationId not found
|
||||
const verificationId = verificationIdsMap[codeVerificationTypeMap[type]];
|
||||
if (!verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
title={`description.verify_${type}`}
|
||||
|
@ -58,8 +74,8 @@ const VerificationCode = () => {
|
|||
>
|
||||
<VerificationCodeContainer
|
||||
flow={userFlow}
|
||||
identifier={type}
|
||||
target={value}
|
||||
identifier={cachedIdentifierInputValue}
|
||||
verificationId={verificationId}
|
||||
hasPasswordButton={userFlow === UserFlow.SignIn && methodSettings?.password}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
|
|
31
packages/experience/src/types/guard.test.ts
Normal file
31
packages/experience/src/types/guard.test.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { VerificationType } from '@logto/schemas';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { verificationIdsMapGuard } from './guard';
|
||||
|
||||
describe('guard', () => {
|
||||
it.each(Object.values(VerificationType))('verificationIdsMapGuard: %s', (type) => {
|
||||
expect(() => {
|
||||
s.assert({ [type]: 'verificationId' }, verificationIdsMapGuard);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw with invalid key', () => {
|
||||
expect(() => {
|
||||
s.assert({ invalidKey: 'verificationId' }, verificationIdsMapGuard);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should successfully parse the value', () => {
|
||||
const record = {
|
||||
[VerificationType.EmailVerificationCode]: 'verificationId',
|
||||
[VerificationType.PhoneVerificationCode]: 'verificationId',
|
||||
[VerificationType.Social]: 'verificationId',
|
||||
};
|
||||
|
||||
const [error, value] = verificationIdsMapGuard.validate(record);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toEqual(record);
|
||||
});
|
||||
});
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
SignInIdentifier,
|
||||
MissingProfile,
|
||||
InteractionEvent,
|
||||
MfaFactor,
|
||||
MissingProfile,
|
||||
SignInIdentifier,
|
||||
type SsoConnectorMetadata,
|
||||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
|
@ -81,12 +83,10 @@ export const totpBindingStateGuard = s.assign(
|
|||
|
||||
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
|
||||
|
||||
export const backupCodeErrorDataGuard = s.object({
|
||||
export const backupCodeBindingStateGuard = s.object({
|
||||
codes: s.array(s.string()),
|
||||
});
|
||||
|
||||
export const backupCodeBindingStateGuard = backupCodeErrorDataGuard;
|
||||
|
||||
export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>;
|
||||
|
||||
export const webAuthnStateGuard = s.assign(
|
||||
|
@ -130,3 +130,29 @@ export const identifierInputValueGuard: s.Describe<IdentifierInputValue> = s.obj
|
|||
* Type guard for the `identifier` search param config on the identifier sign-in/register page.
|
||||
*/
|
||||
export const identifierSearchParamGuard = s.array(identifierEnumGuard);
|
||||
|
||||
type StringGuard = ReturnType<typeof s.string>;
|
||||
// eslint-disable-next-line no-restricted-syntax -- Object.fromEntries can not infer the key type
|
||||
const mapGuard = Object.fromEntries(
|
||||
Object.values(VerificationType).map((type) => [type, s.string()])
|
||||
) as { [key in VerificationType]: StringGuard };
|
||||
|
||||
/**
|
||||
* Defines the type guard for the verification ids map.
|
||||
*/
|
||||
export const verificationIdsMapGuard = s.partial(mapGuard);
|
||||
export type VerificationIdsMap = s.Infer<typeof verificationIdsMapGuard>;
|
||||
|
||||
/**
|
||||
* Define the interaction event state guard.
|
||||
*
|
||||
* This is used to pass the current interaction event state to the continue flow page.
|
||||
*
|
||||
* - If is in the sign in flow, directly call the submitInteraction endpoint after the user completes the profile.
|
||||
* - If is in the register flow, we need to call the identify endpoint first after the user completes the profile.
|
||||
*/
|
||||
export const continueFlowStateGuard = s.object({
|
||||
interactionEvent: s.enums([InteractionEvent.SignIn, InteractionEvent.Register]),
|
||||
});
|
||||
|
||||
export type InteractionFlowState = s.Infer<typeof continueFlowStateGuard>;
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
WebAuthnRegistrationOptions,
|
||||
WebAuthnAuthenticationOptions,
|
||||
FullSignInExperience,
|
||||
InteractionEvent,
|
||||
} from '@logto/schemas';
|
||||
|
||||
export enum UserFlow {
|
||||
|
@ -45,3 +46,5 @@ export type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType exten
|
|||
: never;
|
||||
|
||||
export type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions;
|
||||
|
||||
export type ContinueFlowInteractionEvent = InteractionEvent.Register | InteractionEvent.SignIn;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* Remove this once we have a better way to get the sign in experience through SSR
|
||||
*/
|
||||
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, VerificationType } from '@logto/schemas';
|
||||
import { isObject } from '@silverhand/essentials';
|
||||
import i18next from 'i18next';
|
||||
|
||||
|
@ -68,3 +68,8 @@ export const parseHtmlTitle = (path: string) => {
|
|||
|
||||
return 'Logto';
|
||||
};
|
||||
|
||||
export const codeVerificationTypeMap = Object.freeze({
|
||||
[SignInIdentifier.Email]: VerificationType.EmailVerificationCode,
|
||||
[SignInIdentifier.Phone]: VerificationType.PhoneVerificationCode,
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue