0
Fork 0
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:
simeng-li 2024-08-21 10:30:28 +08:00
parent a0fbd7fcf1
commit 60a9d4eb7e
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
76 changed files with 1732 additions and 1129 deletions

View file

@ -1,4 +1,4 @@
import { type SsoConnectorMetadata } from '@logto/schemas'; import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
import { noop } from '@silverhand/essentials'; import { noop } from '@silverhand/essentials';
import { createContext } from 'react'; import { createContext } from 'react';
@ -6,6 +6,7 @@ import {
type IdentifierInputType, type IdentifierInputType,
type IdentifierInputValue, type IdentifierInputValue,
} from '@/components/InputFields/SmartInputField'; } from '@/components/InputFields/SmartInputField';
import { type VerificationIdsMap } from '@/types/guard';
export type UserInteractionContextType = { export type UserInteractionContextType = {
// All the enabled sso connectors // All the enabled sso connectors
@ -54,6 +55,8 @@ export type UserInteractionContextType = {
setForgotPasswordIdentifierInputValue: React.Dispatch< setForgotPasswordIdentifierInputValue: React.Dispatch<
React.SetStateAction<IdentifierInputValue | undefined> React.SetStateAction<IdentifierInputValue | undefined>
>; >;
verificationIdsMap: VerificationIdsMap;
setVerificationId: (type: VerificationType, id: string) => void;
/** /**
* This method only clear the identifier input values from the session storage. * This method only clear the identifier input values from the session storage.
* *
@ -79,5 +82,7 @@ export default createContext<UserInteractionContextType>({
setIdentifierInputValue: noop, setIdentifierInputValue: noop,
forgotPasswordIdentifierInputValue: undefined, forgotPasswordIdentifierInputValue: undefined,
setForgotPasswordIdentifierInputValue: noop, setForgotPasswordIdentifierInputValue: noop,
verificationIdsMap: {},
setVerificationId: noop,
clearInteractionContextSessionStorage: noop, clearInteractionContextSessionStorage: noop,
}); });

View file

@ -1,5 +1,5 @@
import { type SsoConnectorMetadata } from '@logto/schemas'; import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react'; import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { import {
type IdentifierInputType, type IdentifierInputType,
@ -38,6 +38,10 @@ const UserInteractionContextProvider = ({ children }: Props) => {
IdentifierInputValue | undefined IdentifierInputValue | undefined
>(get(StorageKeys.ForgotPasswordIdentifierInputValue)); >(get(StorageKeys.ForgotPasswordIdentifierInputValue));
const [verificationIdsMap, setVerificationIdsMap] = useState(
get(StorageKeys.verificationIds) ?? {}
);
useEffect(() => { useEffect(() => {
if (!ssoEmail) { if (!ssoEmail) {
remove(StorageKeys.SsoEmail); remove(StorageKeys.SsoEmail);
@ -74,6 +78,15 @@ const UserInteractionContextProvider = ({ children }: Props) => {
set(StorageKeys.ForgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue); set(StorageKeys.ForgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue);
}, [forgotPasswordIdentifierInputValue, remove, set]); }, [forgotPasswordIdentifierInputValue, remove, set]);
useEffect(() => {
if (Object.keys(verificationIdsMap).length === 0) {
remove(StorageKeys.verificationIds);
return;
}
set(StorageKeys.verificationIds, verificationIdsMap);
}, [verificationIdsMap, remove, set]);
const ssoConnectorsMap = useMemo( const ssoConnectorsMap = useMemo(
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])), () => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
[ssoConnectors] [ssoConnectors]
@ -94,8 +107,13 @@ const UserInteractionContextProvider = ({ children }: Props) => {
const clearInteractionContextSessionStorage = useCallback(() => { const clearInteractionContextSessionStorage = useCallback(() => {
remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.IdentifierInputValue);
remove(StorageKeys.ForgotPasswordIdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue);
remove(StorageKeys.verificationIds);
}, [remove]); }, [remove]);
const setVerificationId = useCallback((type: VerificationType, id: string) => {
setVerificationIdsMap((previous) => ({ ...previous, [type]: id }));
}, []);
const userInteractionContext = useMemo<UserInteractionContextType>( const userInteractionContext = useMemo<UserInteractionContextType>(
() => ({ () => ({
ssoEmail, ssoEmail,
@ -108,6 +126,8 @@ const UserInteractionContextProvider = ({ children }: Props) => {
setIdentifierInputValue, setIdentifierInputValue,
forgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue,
setForgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue,
verificationIdsMap,
setVerificationId,
clearInteractionContextSessionStorage, clearInteractionContextSessionStorage,
}), }),
[ [
@ -117,6 +137,8 @@ const UserInteractionContextProvider = ({ children }: Props) => {
identifierInputValue, identifierInputValue,
getIdentifierInputValueByTypes, getIdentifierInputValueByTypes,
forgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue,
verificationIdsMap,
setVerificationId,
clearInteractionContextSessionStorage, clearInteractionContextSessionStorage,
] ]
); );

View file

@ -0,0 +1 @@
export const kyPrefixUrl = '/';

View file

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

View 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;
};

View 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();
};

View 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();
};

View 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();
};

View 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();
};

View file

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

View file

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

View file

@ -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 { initInteraction, sendVerificationCode } from './experience';
import { putInteraction, sendVerificationCode } from './interaction';
/** Move to API */ /** Move to API */
export const sendVerificationCodeApi = async ( export const sendVerificationCodeApi = async (
type: UserFlow, type: UserFlow,
payload: SendVerificationCodePayload identifier: VerificationCodeIdentifier,
interactionEvent?: ContinueFlowInteractionEvent
) => { ) => {
if (type === UserFlow.ForgotPassword) { switch (type) {
await putInteraction(InteractionEvent.ForgotPassword); 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);
}; };

View file

@ -8,7 +8,7 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { registerWithUsernamePassword } from '@/apis/interaction'; import { registerWithUsername } from '@/apis/experience';
import { sendVerificationCodeApi } from '@/apis/utils'; import { sendVerificationCodeApi } from '@/apis/utils';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
@ -34,12 +34,9 @@ jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(), sendVerificationCodeApi: jest.fn(),
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
registerWithUsernamePassword: jest.fn(async () => ({})), registerWithUsername: jest.fn(async () => ({})),
})); getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
jest.mock('@/apis/single-sign-on', () => ({
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
})); }));
const renderForm = ( const renderForm = (
@ -100,7 +97,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('error.general_required')).not.toBeNull(); expect(queryByText('error.general_required')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled();
}); });
}); });
@ -121,7 +118,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('error.username_should_not_start_with_number')).not.toBeNull(); expect(queryByText('error.username_should_not_start_with_number')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
}); });
act(() => { act(() => {
@ -148,7 +145,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('error.username_invalid_charset')).not.toBeNull(); expect(queryByText('error.username_invalid_charset')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
}); });
act(() => { act(() => {
@ -176,7 +173,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull(); expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
}); });
act(() => { act(() => {
@ -188,7 +185,7 @@ describe('<IdentifierRegisterForm />', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(registerWithUsernamePassword).toBeCalledWith('username'); expect(registerWithUsername).toBeCalledWith('username');
}); });
}); });
}); });
@ -211,7 +208,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('error.invalid_email')).not.toBeNull(); expect(queryByText('error.invalid_email')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled();
}); });
@ -244,10 +241,15 @@ describe('<IdentifierRegisterForm />', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { expect(sendVerificationCodeApi).toBeCalledWith(
email: 'foo@logto.io', UserFlow.Register,
}); {
type: SignInIdentifier.Email,
value: 'foo@logto.io',
},
undefined
);
}); });
}); });
} }
@ -271,7 +273,7 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByText('error.invalid_phone')).not.toBeNull(); expect(queryByText('error.invalid_phone')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled();
}); });
@ -303,10 +305,15 @@ describe('<IdentifierRegisterForm />', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(registerWithUsernamePassword).not.toBeCalled(); expect(registerWithUsername).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { expect(sendVerificationCodeApi).toBeCalledWith(
phone: `${getDefaultCountryCallingCode()}8573333333`, UserFlow.Register,
}); {
type: SignInIdentifier.Phone,
value: `${getDefaultCountryCallingCode()}8573333333`,
},
undefined
);
}); });
}); });
} }
@ -344,9 +351,14 @@ describe('<IdentifierRegisterForm />', () => {
await waitFor(() => { await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled(); expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { expect(sendVerificationCodeApi).toBeCalledWith(
email, UserFlow.Register,
}); {
type: SignInIdentifier.Email,
value: email,
},
undefined
);
}); });
}); });
@ -380,14 +392,21 @@ describe('<IdentifierRegisterForm />', () => {
expect(queryByText('action.single_sign_on')).toBeNull(); expect(queryByText('action.single_sign_on')).toBeNull();
await waitFor(() => { await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { expect(sendVerificationCodeApi).toBeCalledWith(
email, 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 () => { 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( const { getByText, container, queryByText } = renderForm(
[SignInIdentifier.Email], [SignInIdentifier.Email],

View file

@ -36,8 +36,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/single-sign-on', () => ({ jest.mock('@/apis/experience', () => ({
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
})); }));
const username = 'foo'; const username = 'foo';
@ -151,12 +151,17 @@ describe('IdentifierSignInForm', () => {
if (verificationCode) { if (verificationCode) {
await waitFor(() => { await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { expect(sendVerificationCodeApi).toBeCalledWith(
[identifier]: UserFlow.SignIn,
identifier === SignInIdentifier.Phone {
? `${getDefaultCountryCallingCode()}${value}` type: identifier,
: value, value:
}); identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
undefined
);
expect(mockedNavigate).not.toBeCalled(); 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 () => { 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( const { getByText, container, queryByText } = renderForm(
mockSignInMethodSettingsTestCases[0]!, 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 () => { 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( const { getByText, container, queryByText } = renderForm(
mockSignInMethodSettingsTestCases[0]!, mockSignInMethodSettingsTestCases[0]!,

View file

@ -8,13 +8,12 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { signInWithPasswordIdentifier } from '@/apis/interaction'; import { signInWithPasswordIdentifier } from '@/apis/experience';
import type { SignInExperienceResponse } from '@/types'; import type { SignInExperienceResponse } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code'; import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PasswordSignInForm from '.'; import PasswordSignInForm from '.';
jest.mock('@/apis/interaction', () => ({ signInWithPasswordIdentifier: jest.fn(async () => 0) }));
jest.mock('react-device-detect', () => ({ jest.mock('react-device-detect', () => ({
isMobile: true, isMobile: true,
})); }));
@ -29,9 +28,10 @@ jest.mock('i18next', () => ({
t: (key: string) => key, t: (key: string) => key,
})); }));
jest.mock('@/apis/single-sign-on', () => ({ jest.mock('@/apis/experience', () => ({
getSingleSignOnUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId), signInWithPasswordIdentifier: jest.fn(async () => 0),
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), getSsoAuthorizationUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId),
getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
})); }));
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
@ -175,10 +175,13 @@ describe('UsernamePasswordSignInForm', () => {
await waitFor(() => { await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ expect(signInWithPasswordIdentifier).toBeCalledWith({
[type]: identifier: {
type === SignInIdentifier.Phone type,
? `${getDefaultCountryCallingCode()}${identifier}` value:
: identifier, type === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${identifier}`
: identifier,
},
password: 'password', password: 'password',
}); });
}); });
@ -224,7 +227,7 @@ describe('UsernamePasswordSignInForm', () => {
// Valid email with empty response // Valid email with empty response
const email = 'foo@logto.io'; const email = 'foo@logto.io';
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]); getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] });
act(() => { act(() => {
fireEvent.change(identifierInput, { target: { value: email } }); fireEvent.change(identifierInput, { target: { value: email } });
}); });
@ -238,7 +241,9 @@ describe('UsernamePasswordSignInForm', () => {
// Valid email with response // Valid email with response
const email2 = 'foo@bar.io'; const email2 = 'foo@bar.io';
getSingleSignOnConnectorsMock.mockClear(); getSingleSignOnConnectorsMock.mockClear();
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); getSingleSignOnConnectorsMock.mockResolvedValueOnce({
connectorIds: mockSsoConnectors.map(({ id }) => id),
});
act(() => { act(() => {
fireEvent.change(identifierInput, { target: { value: email2 } }); fireEvent.change(identifierInput, { target: { value: email2 } });
@ -282,7 +287,9 @@ describe('UsernamePasswordSignInForm', () => {
const email = 'foo@bar.io'; const email = 'foo@bar.io';
getSingleSignOnConnectorsMock.mockClear(); getSingleSignOnConnectorsMock.mockClear();
getSingleSignOnConnectorsMock.mockResolvedValueOnce([mockSsoConnectors[0]!.id]); getSingleSignOnConnectorsMock.mockResolvedValueOnce({
connectorIds: [mockSsoConnectors[0]!.id],
});
act(() => { act(() => {
fireEvent.change(identifierInput, { target: { value: email } }); fireEvent.change(identifierInput, { target: { value: email } });

View file

@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto'; import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction'; import { bindSocialRelatedUser, registerWithVerifiedIdentifier } from '@/apis/experience';
import SocialLinkAccount from '.'; import SocialLinkAccount from '.';
@ -15,13 +15,14 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate, useNavigate: () => mockNavigate,
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
registerWithVerifiedSocial: jest.fn(async () => ({ redirectTo: '/' })), registerWithVerifiedIdentifier: jest.fn(async () => ({ redirectTo: '/' })),
bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })), bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('SocialLinkAccount', () => { describe('SocialLinkAccount', () => {
const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' }); const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' });
const verificationId = 'foo';
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -30,7 +31,11 @@ describe('SocialLinkAccount', () => {
it('should render bindUser Button', async () => { it('should render bindUser Button', async () => {
const { getByText } = renderWithPageContext( const { getByText } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} /> <SocialLinkAccount
connectorId="github"
relatedUser={relatedUser}
verificationId={verificationId}
/>
</SettingsProvider> </SettingsProvider>
); );
const bindButton = getByText('action.bind'); const bindButton = getByText('action.bind');
@ -39,10 +44,7 @@ describe('SocialLinkAccount', () => {
fireEvent.click(bindButton); fireEvent.click(bindButton);
}); });
expect(bindSocialRelatedUser).toBeCalledWith({ expect(bindSocialRelatedUser).toBeCalledWith(verificationId);
connectorId: 'github',
email: 'foo@logto.io',
});
}); });
it('should render link email with email signUp identifier', () => { 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> </SettingsProvider>
); );
@ -77,7 +83,11 @@ describe('SocialLinkAccount', () => {
}, },
}} }}
> >
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} /> <SocialLinkAccount
connectorId="github"
relatedUser={relatedUser}
verificationId={verificationId}
/>
</SettingsProvider> </SettingsProvider>
); );
@ -97,7 +107,11 @@ describe('SocialLinkAccount', () => {
}, },
}} }}
> >
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} /> <SocialLinkAccount
connectorId="github"
relatedUser={relatedUser}
verificationId={verificationId}
/>
</SettingsProvider> </SettingsProvider>
); );
@ -108,7 +122,11 @@ describe('SocialLinkAccount', () => {
it('should call registerWithVerifiedSocial when click create button', async () => { it('should call registerWithVerifiedSocial when click create button', async () => {
const { getByText } = renderWithPageContext( const { getByText } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} /> <SocialLinkAccount
connectorId="github"
relatedUser={relatedUser}
verificationId={verificationId}
/>
</SettingsProvider> </SettingsProvider>
); );
const createButton = getByText('action.create_account_without_linking'); const createButton = getByText('action.create_account_without_linking');
@ -117,6 +135,6 @@ describe('SocialLinkAccount', () => {
fireEvent.click(createButton); fireEvent.click(createButton);
}); });
expect(registerWithVerifiedSocial).toBeCalledWith('github'); expect(registerWithVerifiedIdentifier).toBeCalledWith(verificationId);
}); });
}); });

View file

@ -17,6 +17,7 @@ import useBindSocialRelatedUser from './use-social-link-related-user';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
readonly connectorId: string; readonly connectorId: string;
readonly verificationId: string;
readonly relatedUser: SocialRelatedUserInfo; readonly relatedUser: SocialRelatedUserInfo;
}; };
@ -39,7 +40,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => {
return 'action.create_account_without_linking'; return 'action.create_account_without_linking';
}; };
const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { signUpMethods } = useSieMethods(); const { signUpMethods } = useSieMethods();
@ -58,10 +59,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
title="action.bind" title="action.bind"
i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }} i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }}
onClick={() => { onClick={() => {
void bindSocialRelatedUser({ void bindSocialRelatedUser(verificationId);
connectorId,
...(type === 'email' ? { email: value } : { phone: value }),
});
}} }}
/> />
@ -72,7 +70,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
<TextLink <TextLink
text={actionText} text={actionText}
onClick={() => { onClick={() => {
void registerWithSocial(connectorId); void registerWithSocial(verificationId);
}} }}
/> />
</div> </div>

View file

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { bindSocialRelatedUser } from '@/apis/interaction'; import { bindSocialRelatedUser } from '@/apis/experience';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';

View file

@ -1,12 +1,14 @@
import { import {
AgreeToTermsPolicy, AgreeToTermsPolicy,
ConnectorPlatform, ConnectorPlatform,
VerificationType,
type ExperienceSocialConnector, type ExperienceSocialConnector,
} from '@logto/schemas'; } from '@logto/schemas';
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext'; 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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
@ -20,6 +22,8 @@ const useSocial = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl); const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
const { termsValidation, agreeToTermsPolicy } = useTerms(); const { termsValidation, agreeToTermsPolicy } = useTerms();
const { setVerificationId } = useContext(UserInteractionContext);
const redirectTo = useGlobalRedirectTo({ const redirectTo = useGlobalRedirectTo({
shouldClearInteractionContextSession: false, shouldClearInteractionContextSession: false,
isReplace: false, isReplace: false,
@ -69,19 +73,23 @@ const useSocial = () => {
return; return;
} }
if (!result?.redirectTo) { if (!result) {
return; return;
} }
const { verificationId, authorizationUri } = result;
setVerificationId(VerificationType.Social, verificationId);
// Invoke native social sign-in flow // Invoke native social sign-in flow
if (isNativeWebview()) { if (isNativeWebview()) {
nativeSignInHandler(result.redirectTo, connector); nativeSignInHandler(authorizationUri, connector);
return; return;
} }
// Invoke web social sign-in flow // Invoke web social sign-in flow
await redirectTo(result.redirectTo); await redirectTo(authorizationUri);
}, },
[ [
agreeToTermsPolicy, agreeToTermsPolicy,
@ -89,6 +97,7 @@ const useSocial = () => {
handleError, handleError,
nativeSignInHandler, nativeSignInHandler,
redirectTo, redirectTo,
setVerificationId,
termsValidation, termsValidation,
] ]
); );

View file

@ -14,11 +14,16 @@ const isCodeReady = (code: string[]) => {
return code.length === totpCodeLength && code.every(Boolean); return code.length === totpCodeLength && code.every(Boolean);
}; };
type Props = { type Props<T extends UserMfaFlow> = T extends UserMfaFlow.MfaBinding
readonly flow: UserMfaFlow; ? {
}; flow: T;
verificationId: string;
}
: {
flow: T;
};
const TotpCodeVerification = ({ flow }: Props) => { const TotpCodeVerification = <T extends UserMfaFlow>(props: Props<T>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [codeInput, setCodeInput] = useState<string[]>([]); const [codeInput, setCodeInput] = useState<string[]>([]);
@ -29,10 +34,7 @@ const TotpCodeVerification = ({ flow }: Props) => {
setInputErrorMessage(undefined); setInputErrorMessage(undefined);
}, []); }, []);
const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification( const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification(errorCallback);
flow,
errorCallback
);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -42,10 +44,11 @@ const TotpCodeVerification = ({ flow }: Props) => {
async (code: string[]) => { async (code: string[]) => {
setInputErrorMessage(undefined); setInputErrorMessage(undefined);
setIsSubmitting(true); setIsSubmitting(true);
await onSubmit(code.join(''));
await onSubmit(code.join(''), props);
setIsSubmitting(false); setIsSubmitting(false);
}, },
[onSubmit] [onSubmit, props]
); );
return ( return (

View file

@ -5,7 +5,7 @@ import { type ErrorHandlers } from '@/hooks/use-error-handler';
import useSendMfaPayload from '@/hooks/use-send-mfa-payload'; import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
import { type UserMfaFlow } from '@/types'; import { type UserMfaFlow } from '@/types';
const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void) => { const useTotpCodeVerification = (errorCallback?: () => void) => {
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
const sendMfaPayload = useSendMfaPayload(); const sendMfaPayload = useSendMfaPayload();
@ -19,14 +19,19 @@ const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void)
); );
const onSubmit = useCallback( const onSubmit = useCallback(
async (code: string) => { async (
code: string,
payload:
| { flow: UserMfaFlow.MfaBinding; verificationId: string }
| { flow: UserMfaFlow.MfaVerification }
) => {
await sendMfaPayload( await sendMfaPayload(
{ flow, payload: { type: MfaFactor.TOTP, code } }, { payload: { type: MfaFactor.TOTP, code }, ...payload },
invalidCodeErrorHandlers, invalidCodeErrorHandlers,
errorCallback errorCallback
); );
}, },
[errorCallback, flow, invalidCodeErrorHandlers, sendMfaPayload] [errorCallback, invalidCodeErrorHandlers, sendMfaPayload]
); );
return { return {

View file

@ -1,14 +1,14 @@
import resource from '@logto/phrases-experience'; 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 { act, fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { import { identifyWithVerificationCode, updateProfileWithVerificationCode } from '@/apis/experience';
verifyForgotPasswordVerificationCodeIdentifier, import { resendVerificationCodeApi } from '@/apis/utils';
signInWithVerificationCodeIdentifier,
addProfileWithVerificationCodeIdentifier,
} from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils';
import { setupI18nForTesting } from '@/jest.setup'; import { setupI18nForTesting } from '@/jest.setup';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
@ -21,22 +21,39 @@ const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
useLocation: jest.fn(() => ({
state: {
interactionEvent: InteractionEvent.SignIn,
},
})),
})); }));
jest.mock('@/apis/utils', () => ({ jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(), sendVerificationCodeApi: jest.fn(),
resendVerificationCodeApi: jest.fn(),
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
verifyForgotPasswordVerificationCodeIdentifier: jest.fn(), identifyWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }),
signInWithVerificationCodeIdentifier: jest.fn(), updateProfileWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }),
addProfileWithVerificationCodeIdentifier: jest.fn(),
})); }));
describe('<VerificationCode />', () => { describe('<VerificationCode />', () => {
const redirectTo = '/redirect';
const email = 'foo@logto.io'; const email = 'foo@logto.io';
const phone = '18573333333'; const phone = '18573333333';
const originalLocation = window.location; const originalLocation = window.location;
const verificationId = '123456';
const emailIdentifier: VerificationCodeIdentifier = {
type: SignInIdentifier.Email,
value: email,
};
const phoneIdentifier: VerificationCodeIdentifier = {
type: SignInIdentifier.Phone,
value: phone,
};
beforeAll(() => { beforeAll(() => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods // eslint-disable-next-line @silverhand/fp/no-mutating-methods
@ -47,7 +64,7 @@ describe('<VerificationCode />', () => {
}); });
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.clearAllMocks();
}); });
afterAll(() => { afterAll(() => {
@ -58,7 +75,11 @@ describe('<VerificationCode />', () => {
it('render counter', () => { it('render counter', () => {
const { queryByText } = renderWithPageContext( 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(); expect(queryByText('description.resend_after_seconds')).not.toBeNull();
@ -87,7 +108,11 @@ describe('<VerificationCode />', () => {
}); });
const { getByText } = renderWithPageContext( const { getByText } = renderWithPageContext(
<VerificationCode flow={UserFlow.SignIn} identifier={SignInIdentifier.Email} target={email} /> <VerificationCode
flow={UserFlow.SignIn}
identifier={emailIdentifier}
verificationId={verificationId}
/>
); );
act(() => { act(() => {
jest.advanceTimersByTime(1e3 * 60); jest.advanceTimersByTime(1e3 * 60);
@ -98,7 +123,7 @@ describe('<VerificationCode />', () => {
fireEvent.click(resendButton); fireEvent.click(resendButton);
}); });
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { email }); expect(resendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, emailIdentifier);
// Reset i18n // Reset i18n
await setupI18nForTesting(); await setupI18nForTesting();
@ -106,15 +131,11 @@ describe('<VerificationCode />', () => {
describe('sign-in', () => { describe('sign-in', () => {
it('fire email sign-in validate verification code event', async () => { it('fire email sign-in validate verification code event', async () => {
(signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.SignIn} flow={UserFlow.SignIn}
identifier={SignInIdentifier.Email} identifier={emailIdentifier}
target={email} verificationId={verificationId}
/> />
); );
const inputs = container.querySelectorAll('input'); const inputs = container.querySelectorAll('input');
@ -126,27 +147,24 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
email, identifier: emailIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
await waitFor(() => { 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 () => { it('fire phone sign-in validate verification code event', async () => {
(signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.SignIn} flow={UserFlow.SignIn}
identifier={SignInIdentifier.Phone} identifier={phoneIdentifier}
target={phone} verificationId={verificationId}
/> />
); );
const inputs = container.querySelectorAll('input'); const inputs = container.querySelectorAll('input');
@ -158,29 +176,26 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
phone, identifier: phoneIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
await waitFor(() => { await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com'); expect(window.location.replace).toBeCalledWith(redirectTo);
}); });
}); });
}); });
describe('register', () => { describe('register', () => {
it('fire email register validate verification code event', async () => { it('fire email register validate verification code event', async () => {
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.Register} flow={UserFlow.Register}
identifier={SignInIdentifier.Email} identifier={emailIdentifier}
target={email} verificationId={verificationId}
/> />
); );
const inputs = container.querySelectorAll('input'); const inputs = container.querySelectorAll('input');
@ -192,27 +207,24 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
email, identifier: emailIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
await waitFor(() => { await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com'); expect(window.location.replace).toBeCalledWith(redirectTo);
}); });
}); });
it('fire phone register validate verification code event', async () => { it('fire phone register validate verification code event', async () => {
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.Register} flow={UserFlow.Register}
identifier={SignInIdentifier.Phone} identifier={phoneIdentifier}
target={phone} verificationId={verificationId}
/> />
); );
const inputs = container.querySelectorAll('input'); const inputs = container.querySelectorAll('input');
@ -224,29 +236,26 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
phone, identifier: phoneIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
await waitFor(() => { await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com'); expect(window.location.replace).toBeCalledWith(redirectTo);
}); });
}); });
}); });
describe('forgot password', () => { describe('forgot password', () => {
it('fire email forgot-password validate verification code event', async () => { it('fire email forgot-password validate verification code event', async () => {
(verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.ForgotPassword} flow={UserFlow.ForgotPassword}
identifier={SignInIdentifier.Email} identifier={emailIdentifier}
target={email} verificationId={verificationId}
/> />
); );
@ -259,23 +268,20 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
email, identifier: emailIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
}); });
it('fire phone forgot-password validate verification code event', async () => { it('fire phone forgot-password validate verification code event', async () => {
(verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.ForgotPassword} flow={UserFlow.ForgotPassword}
identifier={SignInIdentifier.Phone} identifier={phoneIdentifier}
target={phone} verificationId={verificationId}
/> />
); );
@ -288,9 +294,10 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({ expect(identifyWithVerificationCode).toBeCalledWith({
phone, identifier: phoneIdentifier,
verificationCode: '111111', verificationId,
code: '111111',
}); });
}); });
}); });
@ -298,15 +305,11 @@ describe('<VerificationCode />', () => {
describe('continue flow', () => { describe('continue flow', () => {
it('set email', async () => { it('set email', async () => {
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: '/redirect',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.Continue} flow={UserFlow.Continue}
identifier={SignInIdentifier.Email} identifier={emailIdentifier}
target={email} verificationId={verificationId}
/> />
); );
@ -319,27 +322,27 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ expect(updateProfileWithVerificationCode).toBeCalledWith(
email, {
verificationCode: '111111', identifier: emailIdentifier,
}); verificationId,
code: '111111',
},
InteractionEvent.SignIn
);
}); });
await waitFor(() => { await waitFor(() => {
expect(window.location.replace).toBeCalledWith('/redirect'); expect(window.location.replace).toBeCalledWith(redirectTo);
}); });
}); });
it('set Phone', async () => { it('set Phone', async () => {
(addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
redirectTo: '/redirect',
}));
const { container } = renderWithPageContext( const { container } = renderWithPageContext(
<VerificationCode <VerificationCode
flow={UserFlow.Continue} flow={UserFlow.Continue}
identifier={SignInIdentifier.Phone} identifier={phoneIdentifier}
target={phone} verificationId={verificationId}
/> />
); );
@ -352,10 +355,14 @@ describe('<VerificationCode />', () => {
} }
await waitFor(() => { await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ expect(updateProfileWithVerificationCode).toBeCalledWith(
phone, {
verificationCode: '111111', identifier: phoneIdentifier,
}); verificationId,
code: '111111',
},
InteractionEvent.SignIn
);
}); });
await waitFor(() => { await waitFor(() => {

View file

@ -1,4 +1,4 @@
import { SignInIdentifier } from '@logto/schemas'; import { type VerificationCodeIdentifier } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
@ -15,13 +15,19 @@ import { getCodeVerificationHookByFlow } from './utils';
type Props = { type Props = {
readonly flow: UserFlow; readonly flow: UserFlow;
readonly identifier: SignInIdentifier.Email | SignInIdentifier.Phone; readonly identifier: VerificationCodeIdentifier;
readonly target: string; readonly verificationId: string;
readonly hasPasswordButton?: boolean; readonly hasPasswordButton?: boolean;
readonly className?: string; 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 [codeInput, setCodeInput] = useState<string[]>([]);
const [inputErrorMessage, setInputErrorMessage] = useState<string>(); const [inputErrorMessage, setInputErrorMessage] = useState<string>();
@ -43,14 +49,13 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
errorMessage: submitErrorMessage, errorMessage: submitErrorMessage,
clearErrorMessage, clearErrorMessage,
onSubmit, onSubmit,
} = useVerificationCode(identifier, target, errorCallback); } = useVerificationCode(identifier, verificationId, errorCallback);
const errorMessage = inputErrorMessage ?? submitErrorMessage; const errorMessage = inputErrorMessage ?? submitErrorMessage;
const { seconds, isRunning, onResendVerificationCode } = useResendVerificationCode( const { seconds, isRunning, onResendVerificationCode } = useResendVerificationCode(
flow, flow,
identifier, identifier
target
); );
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -61,15 +66,11 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
setIsSubmitting(true); setIsSubmitting(true);
await onSubmit( await onSubmit(code.join(''));
identifier === SignInIdentifier.Email
? { email: target, verificationCode: code.join('') }
: { phone: target, verificationCode: code.join('') }
);
setIsSubmitting(false); setIsSubmitting(false);
}, },
[identifier, onSubmit, target] [onSubmit]
); );
useEffect(() => { useEffect(() => {

View file

@ -1,74 +1,88 @@
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; import type { VerificationCodeIdentifier } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas'; import { VerificationType } from '@logto/schemas';
import { useCallback, useMemo } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; 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 useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import type { VerificationCodeIdentifier } from '@/types';
import { SearchParameters } from '@/types'; import { SearchParameters } from '@/types';
import { continueFlowStateGuard } from '@/types/guard';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
import useLinkSocialConfirmModal from './use-link-social-confirm-modal'; import useLinkSocialConfirmModal from './use-link-social-confirm-modal';
const useContinueFlowCodeVerification = ( const useContinueFlowCodeVerification = (
_method: VerificationCodeIdentifier, identifier: VerificationCodeIdentifier,
target: string, verificationId: string,
errorCallback?: () => void errorCallback?: () => void
) => { ) => {
const [searchParameters] = useSearchParams(); const [searchParameters] = useSearchParams();
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const { state } = useLocation();
const [, continueFlowState] = validate(state, continueFlowStateGuard);
const { verificationIdsMap } = useContext(UserInteractionContext);
const interactionEvent = continueFlowState?.interactionEvent;
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier); const verifyVerificationCode = useApi(updateProfileWithVerificationCode);
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } = const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
useGeneralVerificationCodeErrorHandler(); useGeneralVerificationCodeErrorHandler();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true, interactionEvent });
const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showIdentifierErrorAlert = useIdentifierErrorAlert();
const showLinkSocialConfirmModal = useLinkSocialConfirmModal(); const showLinkSocialConfirmModal = useLinkSocialConfirmModal();
const identifierExistErrorHandler = useCallback(
async (method: VerificationCodeIdentifier, target: string) => {
const linkSocial = searchParameters.get(SearchParameters.LinkSocial);
// Show bind with social confirm modal const identifierExistErrorHandler = useCallback(async () => {
if (linkSocial) { const linkSocial = searchParameters.get(SearchParameters.LinkSocial);
await showLinkSocialConfirmModal(method, target, 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); return;
}, }
[searchParameters, showIdentifierErrorAlert, showLinkSocialConfirmModal] const { type, value } = identifier;
); await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
}, [
identifier,
searchParameters,
showIdentifierErrorAlert,
showLinkSocialConfirmModal,
verificationId,
verificationIdsMap,
]);
const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo( const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.phone_already_in_use': async () => 'user.phone_already_in_use': identifierExistErrorHandler,
identifierExistErrorHandler(SignInIdentifier.Phone, target), 'user.email_already_in_use': identifierExistErrorHandler,
'user.email_already_in_use': async () =>
identifierExistErrorHandler(SignInIdentifier.Email, target),
...preSignInErrorHandler, ...preSignInErrorHandler,
...generalVerificationCodeErrorHandlers, ...generalVerificationCodeErrorHandlers,
}), }),
[ [preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistErrorHandler]
preSignInErrorHandler,
generalVerificationCodeErrorHandlers,
identifierExistErrorHandler,
target,
]
); );
const onSubmit = useCallback( const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { async (code: string) => {
const [error, result] = await verifyVerificationCode(payload); const [error, result] = await verifyVerificationCode(
{
code,
identifier,
verificationId,
},
interactionEvent
);
if (error) { if (error) {
await handleError(error, verifyVerificationCodeErrorHandlers); await handleError(error, verifyVerificationCodeErrorHandlers);
@ -84,7 +98,10 @@ const useContinueFlowCodeVerification = (
[ [
errorCallback, errorCallback,
handleError, handleError,
identifier,
interactionEvent,
redirectTo, redirectTo,
verificationId,
verifyVerificationCode, verifyVerificationCode,
verifyVerificationCodeErrorHandlers, verifyVerificationCodeErrorHandlers,
] ]

View file

@ -1,25 +1,24 @@
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; import type { VerificationCodeIdentifier } from '@logto/schemas';
import { useMemo, useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { verifyForgotPasswordVerificationCodeIdentifier } from '@/apis/interaction'; import { identifyWithVerificationCode } from '@/apis/experience';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
const useForgotPasswordFlowCodeVerification = ( const useForgotPasswordFlowCodeVerification = (
method: VerificationCodeIdentifier, identifier: VerificationCodeIdentifier,
target: string, verificationId: string,
errorCallback?: () => void errorCallback?: () => void
) => { ) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const verifyVerificationCode = useApi(verifyForgotPasswordVerificationCodeIdentifier); const verifyVerificationCode = useApi(identifyWithVerificationCode);
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } = const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
useGeneralVerificationCodeErrorHandler(); useGeneralVerificationCodeErrorHandler();
@ -28,18 +27,32 @@ const useForgotPasswordFlowCodeVerification = (
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.user_not_exist': async () => 'user.user_not_exist': async () =>
identifierErrorHandler(IdentifierErrorType.IdentifierNotExist, method, target), identifierErrorHandler(
IdentifierErrorType.IdentifierNotExist,
identifier.type,
identifier.value
),
'user.new_password_required_in_profile': () => { 'user.new_password_required_in_profile': () => {
navigate(`/${UserFlow.ForgotPassword}/reset`, { replace: true }); navigate(`/${UserFlow.ForgotPassword}/reset`, { replace: true });
}, },
...generalVerificationCodeErrorHandlers, ...generalVerificationCodeErrorHandlers,
}), }),
[generalVerificationCodeErrorHandlers, identifierErrorHandler, method, target, navigate] [
generalVerificationCodeErrorHandlers,
identifierErrorHandler,
identifier.type,
identifier.value,
navigate,
]
); );
const onSubmit = useCallback( const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { async (code: string) => {
const [error, result] = await verifyVerificationCode(payload); const [error, result] = await verifyVerificationCode({
code,
identifier,
verificationId,
});
if (error) { if (error) {
await handleError(error, errorHandlers); await handleError(error, errorHandlers);
@ -52,7 +65,15 @@ const useForgotPasswordFlowCodeVerification = (
navigate(`/${UserFlow.SignIn}`, { replace: true }); navigate(`/${UserFlow.SignIn}`, { replace: true });
} }
}, },
[errorCallback, errorHandlers, handleError, navigate, verifyVerificationCode] [
errorCallback,
errorHandlers,
handleError,
identifier,
navigate,
verificationId,
verifyVerificationCode,
]
); );
return { return {

View file

@ -1,11 +1,11 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import type { VerificationCodeIdentifier } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useLinkSocial from '@/hooks/use-social-link-account'; import useLinkSocial from '@/hooks/use-social-link-account';
import type { VerificationCodeIdentifier } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useLinkSocialConfirmModal = () => { const useLinkSocialConfirmModal = () => {
@ -15,22 +15,28 @@ const useLinkSocialConfirmModal = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return useCallback( return useCallback(
async (method: VerificationCodeIdentifier, target: string, connectorId: string) => { async (
identifier: VerificationCodeIdentifier,
identifierVerificationId: string,
socialVerificationId: string
) => {
const { type, value } = identifier;
show({ show({
confirmText: 'action.bind_and_continue', confirmText: 'action.bind_and_continue',
cancelText: 'action.change', cancelText: 'action.change',
cancelTextI18nProps: { 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', { 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: value:
method === SignInIdentifier.Phone type === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(target) ? formatPhoneNumberWithCountryCallingCode(value)
: target, : value,
}), }),
onConfirm: async () => { onConfirm: async () => {
await linkWithSocial(connectorId); await linkWithSocial(identifierVerificationId, socialVerificationId);
}, },
onCancel: () => { onCancel: () => {
navigate(-1); navigate(-1);

View file

@ -1,13 +1,14 @@
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; import {
import { SignInIdentifier, SignInMode } from '@logto/schemas'; InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { identifyWithVerificationCode, signInWithVerifiedIdentifier } from '@/apis/experience';
addProfileWithVerificationCodeIdentifier,
signInWithVerifiedIdentifier,
} from '@/apis/interaction';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; 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 useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import type { VerificationCodeIdentifier } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
const useRegisterFlowCodeVerification = ( const useRegisterFlowCodeVerification = (
method: VerificationCodeIdentifier, identifier: VerificationCodeIdentifier,
target: string, verificationId: string,
errorCallback?: () => void errorCallback?: () => void
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -34,18 +34,30 @@ const useRegisterFlowCodeVerification = (
const { signInMode } = useSieMethods(); const { signInMode } = useSieMethods();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const signInWithIdentifierAsync = useApi(signInWithVerifiedIdentifier); const signInWithIdentifierAsync = useApi(signInWithVerifiedIdentifier);
const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier); const verifyVerificationCode = useApi(identifyWithVerificationCode);
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
useGeneralVerificationCodeErrorHandler(); useGeneralVerificationCodeErrorHandler();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const preRegisterErrorHandler = usePreSignInErrorHandler({
replace: true,
interactionEvent: InteractionEvent.Register,
});
const preSignInErrorHandler = usePreSignInErrorHandler({
replace: true,
});
const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showIdentifierErrorAlert = useIdentifierErrorAlert();
const identifierExistErrorHandler = useCallback(async () => { const identifierExistErrorHandler = useCallback(async () => {
const { type, value } = identifier;
// Should not redirect user to sign-in if is register-only mode // Should not redirect user to sign-in if is register-only mode
if (signInMode === SignInMode.Register) { if (signInMode === SignInMode.Register) {
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target); void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
return; return;
} }
@ -53,14 +65,12 @@ const useRegisterFlowCodeVerification = (
show({ show({
confirmText: 'action.sign_in', confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', { 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: value:
method === SignInIdentifier.Phone type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}), }),
onConfirm: async () => { onConfirm: async () => {
const [error, result] = await signInWithIdentifierAsync(); const [error, result] = await signInWithIdentifierAsync(verificationId);
if (error) { if (error) {
await handleError(error, preSignInErrorHandler); await handleError(error, preSignInErrorHandler);
@ -78,16 +88,16 @@ const useRegisterFlowCodeVerification = (
}); });
}, [ }, [
handleError, handleError,
method, identifier,
navigate, navigate,
preSignInErrorHandler,
redirectTo, redirectTo,
show, show,
showIdentifierErrorAlert, showIdentifierErrorAlert,
preSignInErrorHandler,
signInMode, signInMode,
signInWithIdentifierAsync, signInWithIdentifierAsync,
t, t,
target, verificationId,
]); ]);
const errorHandlers = useMemo<ErrorHandlers>( const errorHandlers = useMemo<ErrorHandlers>(
@ -95,20 +105,24 @@ const useRegisterFlowCodeVerification = (
'user.email_already_in_use': identifierExistErrorHandler, 'user.email_already_in_use': identifierExistErrorHandler,
'user.phone_already_in_use': identifierExistErrorHandler, 'user.phone_already_in_use': identifierExistErrorHandler,
...generalVerificationCodeErrorHandlers, ...generalVerificationCodeErrorHandlers,
...preSignInErrorHandler, ...preRegisterErrorHandler,
callback: errorCallback, callback: errorCallback,
}), }),
[ [
identifierExistErrorHandler, identifierExistErrorHandler,
generalVerificationCodeErrorHandlers, generalVerificationCodeErrorHandlers,
preSignInErrorHandler, preRegisterErrorHandler,
errorCallback, errorCallback,
] ]
); );
const onSubmit = useCallback( const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { async (code: string) => {
const [error, result] = await verifyVerificationCode(payload); const [error, result] = await verifyVerificationCode({
verificationId,
identifier,
code,
});
if (error) { if (error) {
await handleError(error, errorHandlers); await handleError(error, errorHandlers);
@ -121,7 +135,15 @@ const useRegisterFlowCodeVerification = (
await redirectTo(result.redirectTo); await redirectTo(result.redirectTo);
} }
}, },
[errorCallback, errorHandlers, handleError, redirectTo, verifyVerificationCode] [
errorCallback,
errorHandlers,
handleError,
identifier,
redirectTo,
verificationId,
verifyVerificationCode,
]
); );
return { return {

View file

@ -1,13 +1,15 @@
import { SignInIdentifier } from '@logto/schemas'; import { type VerificationCodeIdentifier } from '@logto/schemas';
import { t } from 'i18next'; import { t } from 'i18next';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import { useTimer } from 'react-timer-hook'; 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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useToast from '@/hooks/use-toast'; import useToast from '@/hooks/use-toast';
import type { UserFlow } from '@/types'; import type { UserFlow } from '@/types';
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
export const timeRange = 59; export const timeRange = 59;
@ -18,11 +20,7 @@ const getTimeout = () => {
return now; return now;
}; };
const useResendVerificationCode = ( const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeIdentifier) => {
type: UserFlow,
method: SignInIdentifier.Email | SignInIdentifier.Phone,
target: string
) => {
const { setToast } = useToast(); const { setToast } = useToast();
const { seconds, isRunning, restart } = useTimer({ const { seconds, isRunning, restart } = useTimer({
@ -31,11 +29,11 @@ const useResendVerificationCode = (
}); });
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const sendVerificationCode = useApi(sendVerificationCodeApi); const sendVerificationCode = useApi(resendVerificationCodeApi);
const { setVerificationId } = useContext(UserInteractionContext);
const onResendVerificationCode = useCallback(async () => { const onResendVerificationCode = useCallback(async () => {
const payload = method === SignInIdentifier.Email ? { email: target } : { phone: target }; const [error, result] = await sendVerificationCode(flow, identifier);
const [error, result] = await sendVerificationCode(type, payload);
if (error) { if (error) {
await handleError(error); await handleError(error);
@ -44,10 +42,12 @@ const useResendVerificationCode = (
} }
if (result) { if (result) {
// Renew the verification ID in the context
setVerificationId(codeVerificationTypeMap[identifier.type], result.verificationId);
setToast(t('description.passcode_sent')); setToast(t('description.passcode_sent'));
restart(getTimeout(), true); restart(getTimeout(), true);
} }
}, [handleError, method, restart, sendVerificationCode, setToast, target, type]); }, [flow, handleError, identifier, restart, sendVerificationCode, setToast, setVerificationId]);
return { return {
seconds, seconds,

View file

@ -1,13 +1,14 @@
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; import {
import { SignInIdentifier, SignInMode } from '@logto/schemas'; InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { identifyWithVerificationCode, registerWithVerifiedIdentifier } from '@/apis/experience';
registerWithVerifiedIdentifier,
signInWithVerificationCodeIdentifier,
} from '@/apis/interaction';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; 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 useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import type { VerificationCodeIdentifier } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
const useSignInFlowCodeVerification = ( const useSignInFlowCodeVerification = (
method: VerificationCodeIdentifier, identifier: VerificationCodeIdentifier,
target: string, verificationId: string,
errorCallback?: () => void errorCallback?: () => void
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { show } = useConfirmModal(); const { show } = useConfirmModal();
const navigate = useNavigate(); const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const { signInMode } = useSieMethods(); const { signInMode, signUpMethods } = useSieMethods();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const registerWithIdentifierAsync = useApi(registerWithVerifiedIdentifier); const registerWithIdentifierAsync = useApi(registerWithVerifiedIdentifier);
const asyncSignInWithVerificationCodeIdentifier = useApi(signInWithVerificationCodeIdentifier); const asyncSignInWithVerificationCodeIdentifier = useApi(identifyWithVerificationCode);
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
useGeneralVerificationCodeErrorHandler(); useGeneralVerificationCodeErrorHandler();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const preRegisterErrorHandler = usePreSignInErrorHandler({
interactionEvent: InteractionEvent.Register,
});
const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showIdentifierErrorAlert = useIdentifierErrorAlert();
const identifierNotExistErrorHandler = useCallback(async () => { const identifierNotExistErrorHandler = useCallback(async () => {
const { type, value } = identifier;
// Should not redirect user to register if is sign-in only mode or bind social flow // Should not redirect user to register if is sign-in only mode or bind social flow
if (signInMode === SignInMode.SignIn) { if (signInMode === SignInMode.SignIn || !signUpMethods.includes(type)) {
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, method, target); void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, type, value);
return; return;
} }
@ -54,19 +59,15 @@ const useSignInFlowCodeVerification = (
show({ show({
confirmText: 'action.create', confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exist', { 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: value:
method === SignInIdentifier.Phone type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}), }),
onConfirm: async () => { onConfirm: async () => {
const [error, result] = await registerWithIdentifierAsync( const [error, result] = await registerWithIdentifierAsync(verificationId);
method === SignInIdentifier.Email ? { email: target } : { phone: target }
);
if (error) { if (error) {
await handleError(error, preSignInErrorHandler); await handleError(error, preRegisterErrorHandler);
return; return;
} }
@ -80,17 +81,18 @@ const useSignInFlowCodeVerification = (
}, },
}); });
}, [ }, [
identifier,
signInMode, signInMode,
signUpMethods,
show, show,
t, t,
method,
target,
registerWithIdentifierAsync,
showIdentifierErrorAlert, showIdentifierErrorAlert,
navigate, registerWithIdentifierAsync,
verificationId,
handleError, handleError,
preSignInErrorHandler, preRegisterErrorHandler,
redirectTo, redirectTo,
navigate,
]); ]);
const errorHandlers = useMemo<ErrorHandlers>( const errorHandlers = useMemo<ErrorHandlers>(
@ -109,12 +111,15 @@ const useSignInFlowCodeVerification = (
); );
const onSubmit = useCallback( const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { async (code: string) => {
const [error, result] = await asyncSignInWithVerificationCodeIdentifier(payload); const [error, result] = await asyncSignInWithVerificationCodeIdentifier({
verificationId,
identifier,
code,
});
if (error) { if (error) {
await handleError(error, errorHandlers); await handleError(error, errorHandlers);
return; return;
} }
@ -122,7 +127,14 @@ const useSignInFlowCodeVerification = (
await redirectTo(result.redirectTo); await redirectTo(result.redirectTo);
} }
}, },
[asyncSignInWithVerificationCodeIdentifier, errorHandlers, handleError, redirectTo] [
asyncSignInWithVerificationCodeIdentifier,
errorHandlers,
handleError,
identifier,
redirectTo,
verificationId,
]
); );
return { return {

View file

@ -1,10 +1,10 @@
import { experience, type SsoConnectorMetadata } from '@logto/schemas'; 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 { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; 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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
@ -13,7 +13,7 @@ import useSingleSignOn from './use-single-sign-on';
const useCheckSingleSignOn = () => { const useCheckSingleSignOn = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const request = useApi(getSingleSignOnConnectors); const request = useApi(getSsoConnectors);
const [errorMessage, setErrorMessage] = useState<string | undefined>(); const [errorMessage, setErrorMessage] = useState<string | undefined>();
const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } = const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } =
useContext(UserInteractionContext); useContext(UserInteractionContext);
@ -56,8 +56,8 @@ const useCheckSingleSignOn = () => {
return; return;
} }
const connectors = result const connectors = result?.connectorIds
?.map((connectorId) => availableSsoConnectorsMap.get(connectorId)) .map((connectorId) => availableSsoConnectorsMap.get(connectorId))
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); .filter((connector): connector is SsoConnectorMetadata => Boolean(connector));

View file

@ -5,15 +5,11 @@ import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import { import { type MfaFlowState, mfaErrorDataGuard } from '@/types/guard';
type MfaFlowState,
mfaErrorDataGuard,
backupCodeErrorDataGuard,
type BackupCodeBindingState,
} from '@/types/guard';
import { isNativeWebview } from '@/utils/native-sdk'; import { isNativeWebview } from '@/utils/native-sdk';
import type { ErrorHandlers } from './use-error-handler'; import type { ErrorHandlers } from './use-error-handler';
import useBackupCodeBinding from './use-start-backup-code-binding';
import useStartTotpBinding from './use-start-totp-binding'; import useStartTotpBinding from './use-start-totp-binding';
import useStartWebAuthnProcessing from './use-start-webauthn-processing'; import useStartWebAuthnProcessing from './use-start-webauthn-processing';
import useToast from './use-toast'; import useToast from './use-toast';
@ -28,6 +24,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
const { setToast } = useToast(); const { setToast } = useToast();
const startTotpBinding = useStartTotpBinding({ replace }); const startTotpBinding = useStartTotpBinding({ replace });
const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace }); const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace });
const startBackupCodeBinding = useBackupCodeBinding({ replace });
/** /**
* Redirect the user to the corresponding MFA page. * Redirect the user to the corresponding MFA page.
@ -118,30 +115,13 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
[handleMfaRedirect, setToast] [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>( const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
() => ({ () => ({
'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding), 'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding),
'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification), '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; return mfaVerificationErrorHandler;

View file

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

View file

@ -10,8 +10,8 @@ import useRequiredProfileErrorHandler, {
type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions; type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions;
const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => { const usePreSignInErrorHandler = ({ replace, ...rest }: Options = {}): ErrorHandlers => {
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial }); const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, ...rest });
const mfaErrorHandler = useMfaErrorHandler({ replace }); const mfaErrorHandler = useMfaErrorHandler({ replace });
return useMemo( return useMemo(

View file

@ -1,9 +1,9 @@
import { MissingProfile } from '@logto/schemas'; import { InteractionEvent, MissingProfile } from '@logto/schemas';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import { UserFlow, SearchParameters } from '@/types'; import { UserFlow, SearchParameters, type ContinueFlowInteractionEvent } from '@/types';
import { missingProfileErrorDataGuard } from '@/types/guard'; import { missingProfileErrorDataGuard } from '@/types/guard';
import { queryStringify } from '@/utils'; import { queryStringify } from '@/utils';
@ -13,9 +13,19 @@ import useToast from './use-toast';
export type Options = { export type Options = {
replace?: boolean; replace?: boolean;
linkSocial?: string; 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 navigate = useNavigate();
const { setToast } = useToast(); const { setToast } = useToast();
@ -27,9 +37,6 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
// Required as a sign up method but missing in the user profile // Required as a sign up method but missing in the user profile
const missingProfile = data?.missingProfile[0]; 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 const linkSocialQueryString = linkSocial
? `?${queryStringify({ [SearchParameters.LinkSocial]: linkSocial })}` ? `?${queryStringify({ [SearchParameters.LinkSocial]: linkSocial })}`
: undefined; : undefined;
@ -41,7 +48,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
{ {
pathname: `/${UserFlow.Continue}/${missingProfile}`, pathname: `/${UserFlow.Continue}/${missingProfile}`,
}, },
{ replace } { replace, state: { interactionEvent } }
); );
break; break;
} }
@ -53,7 +60,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
pathname: `/${UserFlow.Continue}/${missingProfile}`, pathname: `/${UserFlow.Continue}/${missingProfile}`,
search: linkSocialQueryString, search: linkSocialQueryString,
}, },
{ replace, state: { registeredSocialIdentity } } { replace, state: { interactionEvent } }
); );
break; break;
} }
@ -65,7 +72,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
} }
}, },
}), }),
[linkSocial, navigate, replace, setToast] [interactionEvent, linkSocial, navigate, replace, setToast]
); );
return requiredProfileErrorHandler; return requiredProfileErrorHandler;

View file

@ -1,7 +1,7 @@
import { type BindMfaPayload, type VerifyMfaPayload } from '@logto/schemas'; import { type BindMfaPayload, type VerifyMfaPayload } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { bindMfa, verifyMfa } from '@/apis/interaction'; import { bindMfa, verifyMfa } from '@/apis/experience';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import useApi from './use-api'; import useApi from './use-api';
@ -13,17 +13,19 @@ export type SendMfaPayloadApiOptions =
| { | {
flow: UserMfaFlow.MfaBinding; flow: UserMfaFlow.MfaBinding;
payload: BindMfaPayload; payload: BindMfaPayload;
verificationId: string;
} }
| { | {
flow: UserMfaFlow.MfaVerification; flow: UserMfaFlow.MfaVerification;
payload: VerifyMfaPayload; payload: VerifyMfaPayload;
verificationId?: string;
}; };
const sendMfaPayloadApi = async ({ flow, payload }: SendMfaPayloadApiOptions) => { const sendMfaPayloadApi = async ({ flow, payload, verificationId }: SendMfaPayloadApiOptions) => {
if (flow === UserMfaFlow.MfaBinding) { if (flow === UserMfaFlow.MfaBinding) {
return bindMfa(payload); return bindMfa(payload, verificationId);
} }
return verifyMfa(payload); return verifyMfa(payload, verificationId);
}; };
const useSendMfaPayload = () => { const useSendMfaPayload = () => {

View file

@ -1,13 +1,20 @@
/* Replace legacy useSendVerificationCode hook with this one after the refactor */ /* Replace legacy useSendVerificationCode hook with this one after the refactor */
import { SignInIdentifier } from '@logto/schemas'; 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 { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { sendVerificationCodeApi } from '@/apis/utils'; import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; 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 useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
@ -15,6 +22,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const asyncSendVerificationCode = useApi(sendVerificationCodeApi); const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
const { setVerificationId } = useContext(UserInteractionContext);
const clearErrorMessage = useCallback(() => { const clearErrorMessage = useCallback(() => {
setErrorMessage(''); setErrorMessage('');
@ -26,10 +34,15 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
}; };
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ identifier, value }: Payload) => { async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => {
const [error, result] = await asyncSendVerificationCode(flow, { const [error, result] = await asyncSendVerificationCode(
[identifier]: value, flow,
}); {
type: identifier,
value,
},
interactionEvent
);
if (error) { if (error) {
await handleError(error, { await handleError(error, {
@ -44,6 +57,9 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
} }
if (result) { 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( navigate(
{ {
pathname: `/${flow}/verification-code`, pathname: `/${flow}/verification-code`,
@ -51,11 +67,17 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
}, },
{ {
replace: replaceCurrentPage, 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 { return {

View file

@ -4,7 +4,11 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as s from 'superstruct'; import * as s from 'superstruct';
import { identifierInputValueGuard, ssoConnectorMetadataGuard } from '@/types/guard'; import {
identifierInputValueGuard,
ssoConnectorMetadataGuard,
verificationIdsMapGuard,
} from '@/types/guard';
const logtoStorageKeyPrefix = `logto:${window.location.origin}`; const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
@ -13,6 +17,7 @@ export enum StorageKeys {
SsoConnectors = 'sso-connectors', SsoConnectors = 'sso-connectors',
IdentifierInputValue = 'identifier-input-value', IdentifierInputValue = 'identifier-input-value',
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value', ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
verificationIds = 'verification-ids',
} }
const valueGuard = Object.freeze({ const valueGuard = Object.freeze({
@ -20,6 +25,7 @@ const valueGuard = Object.freeze({
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
[StorageKeys.IdentifierInputValue]: identifierInputValueGuard, [StorageKeys.IdentifierInputValue]: identifierInputValueGuard,
[StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard, [StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard,
[StorageKeys.verificationIds]: verificationIdsMapGuard,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
} satisfies { [key in StorageKeys]: s.Struct<any> }); } satisfies { [key in StorageKeys]: s.Struct<any> });

View file

@ -4,12 +4,12 @@ import {
experience, experience,
type SsoConnectorMetadata, type SsoConnectorMetadata,
} from '@logto/schemas'; } from '@logto/schemas';
import { useEffect, useCallback, useContext } from 'react'; import { useCallback, useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; 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 type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useSingleSignOn from '@/hooks/use-single-sign-on'; import useSingleSignOn from '@/hooks/use-single-sign-on';
@ -28,7 +28,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
const request = useApi(getSingleSignOnConnectors, { silent: true }); const request = useApi(getSsoConnectors, { silent: true });
const singleSignOn = useSingleSignOn(); const singleSignOn = useSingleSignOn();
@ -43,7 +43,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
return false; return false;
} }
const connectors = result const connectors = result.connectorIds
.map((connectorId) => availableSsoConnectorsMap.get(connectorId)) .map((connectorId) => availableSsoConnectorsMap.get(connectorId))
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); .filter((connector): connector is SsoConnectorMetadata => Boolean(connector));

View file

@ -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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk';
@ -10,11 +12,12 @@ import useGlobalRedirectTo from './use-global-redirect-to';
const useSingleSignOn = () => { const useSingleSignOn = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl); const asyncInvokeSingleSignOn = useApi(getSsoAuthorizationUrl);
const redirectTo = useGlobalRedirectTo({ const redirectTo = useGlobalRedirectTo({
shouldClearInteractionContextSession: false, shouldClearInteractionContextSession: false,
isReplace: false, isReplace: false,
}); });
const { setVerificationId } = useContext(UserInteractionContext);
/** /**
* Native IdP Sign In Flow * Native IdP Sign In Flow
@ -45,11 +48,10 @@ const useSingleSignOn = () => {
const state = generateState(); const state = generateState();
storeState(state, connectorId); storeState(state, connectorId);
const [error, redirectUrl] = await asyncInvokeSingleSignOn( const [error, result] = await asyncInvokeSingleSignOn(connectorId, {
connectorId,
state, state,
`${window.location.origin}/callback/${connectorId}` redirectUri: `${window.location.origin}/callback/${connectorId}`,
); });
if (error) { if (error) {
await handleError(error); await handleError(error);
@ -57,19 +59,23 @@ const useSingleSignOn = () => {
return; return;
} }
if (!redirectUrl) { if (!result) {
return; return;
} }
const { authorizationUri, verificationId } = result;
setVerificationId(VerificationType.EnterpriseSso, verificationId);
// Invoke Native Sign In flow // Invoke Native Sign In flow
if (isNativeWebview()) { if (isNativeWebview()) {
nativeSignInHandler(redirectUrl, connectorId); nativeSignInHandler(authorizationUri, connectorId);
} }
// Invoke Web Sign In flow // Invoke Web Sign In flow
await redirectTo(redirectUrl); await redirectTo(authorizationUri);
}, },
[asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo] [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo, setVerificationId]
); );
}; };

View file

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { skipMfa } from '@/apis/interaction'; import { skipMfa } from '@/apis/experience';
import useApi from './use-api'; import useApi from './use-api';
import useErrorHandler from './use-error-handler'; import useErrorHandler from './use-error-handler';

View file

@ -1,22 +1,27 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { linkWithSocial } from '@/apis/interaction'; import { signInAndLinkWithSocial } from '@/apis/experience';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useErrorHandler from './use-error-handler'; import useErrorHandler from './use-error-handler';
import useGlobalRedirectTo from './use-global-redirect-to'; import useGlobalRedirectTo from './use-global-redirect-to';
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
const useLinkSocial = () => { const useLinkSocial = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const asyncLinkWithSocial = useApi(linkWithSocial); const asyncLinkWithSocial = useApi(signInAndLinkWithSocial);
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
return useCallback( return useCallback(
async (connectorId: string) => { async (identifierVerificationId: string, socialVerificationId: string) => {
const [error, result] = await asyncLinkWithSocial(connectorId); const [error, result] = await asyncLinkWithSocial(
identifierVerificationId,
socialVerificationId
);
if (error) { if (error) {
await handleError(error); await handleError(error, preSignInErrorHandler);
return; return;
} }
@ -25,7 +30,7 @@ const useLinkSocial = () => {
await redirectTo(result.redirectTo); await redirectTo(result.redirectTo);
} }
}, },
[asyncLinkWithSocial, handleError, redirectTo] [asyncLinkWithSocial, handleError, preSignInErrorHandler, redirectTo]
); );
}; };

View file

@ -1,25 +1,30 @@
import { InteractionEvent } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { registerWithVerifiedSocial } from '@/apis/interaction'; import { registerWithVerifiedIdentifier } from '@/apis/experience';
import useApi from './use-api'; import useApi from './use-api';
import useErrorHandler from './use-error-handler'; import useErrorHandler from './use-error-handler';
import useGlobalRedirectTo from './use-global-redirect-to'; import useGlobalRedirectTo from './use-global-redirect-to';
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; 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 handleError = useErrorHandler();
const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial); const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier);
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace }); const preRegisterErrorHandler = usePreSignInErrorHandler({
linkSocial: connectorId,
replace,
interactionEvent: InteractionEvent.Register,
});
return useCallback( return useCallback(
async (connectorId: string) => { async (verificationId: string) => {
const [error, result] = await asyncRegisterWithSocial(connectorId); const [error, result] = await asyncRegisterWithSocial(verificationId);
if (error) { if (error) {
await handleError(error, preSignInErrorHandler); await handleError(error, preRegisterErrorHandler);
return; return;
} }
@ -28,7 +33,7 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => {
await redirectTo(result.redirectTo); await redirectTo(result.redirectTo);
} }
}, },
[asyncRegisterWithSocial, handleError, preSignInErrorHandler, redirectTo] [asyncRegisterWithSocial, handleError, preRegisterErrorHandler, redirectTo]
); );
}; };

View file

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

View file

@ -1,8 +1,9 @@
import { MfaFactor } from '@logto/schemas'; import { MfaFactor, VerificationType } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; 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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
@ -15,6 +16,7 @@ type Options = {
const useStartTotpBinding = ({ replace }: Options = {}) => { const useStartTotpBinding = ({ replace }: Options = {}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const asyncCreateTotpSecret = useApi(createTotpSecret); const asyncCreateTotpSecret = useApi(createTotpSecret);
const { setVerificationId } = useContext(UserInteractionContext);
const handleError = useErrorHandler(); const handleError = useErrorHandler();
@ -27,18 +29,20 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
return; return;
} }
const { secret, secretQrCode } = result ?? {}; if (result) {
const { secret, secretQrCode, verificationId } = result;
if (secret && secretQrCode) {
const state: TotpBindingState = { const state: TotpBindingState = {
secret, secret,
secretQrCode, secretQrCode,
...flowState, ...flowState,
}; };
setVerificationId(VerificationType.TOTP, verificationId);
navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state });
} }
}, },
[asyncCreateTotpSecret, handleError, navigate, replace] [asyncCreateTotpSecret, handleError, navigate, replace, setVerificationId]
); );
}; };

View file

@ -1,11 +1,9 @@
import { MfaFactor } from '@logto/schemas'; import { MfaFactor, VerificationType } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
createWebAuthnRegistrationOptions, import { createWebAuthnRegistration, createWebAuthnAuthentication } from '@/apis/experience';
generateWebAuthnAuthnOptions,
} from '@/apis/interaction';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import { type WebAuthnState, type MfaFlowState } from '@/types/guard'; import { type WebAuthnState, type MfaFlowState } from '@/types/guard';
@ -18,13 +16,14 @@ type Options = {
const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions); const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistration);
const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions); const asyncGenerateAuthnOptions = useApi(createWebAuthnAuthentication);
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const { setVerificationId } = useContext(UserInteractionContext);
return useCallback( return useCallback(
async (flow: UserMfaFlow, flowState: MfaFlowState) => { async (flow: UserMfaFlow, flowState: MfaFlowState) => {
const [error, options] = const [error, result] =
flow === UserMfaFlow.MfaBinding flow === UserMfaFlow.MfaBinding
? await asyncCreateRegistrationOptions() ? await asyncCreateRegistrationOptions()
: await asyncGenerateAuthnOptions(); : await asyncGenerateAuthnOptions();
@ -34,7 +33,10 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
return; return;
} }
if (options) { if (result) {
const { verificationId, options } = result;
setVerificationId(VerificationType.WebAuthn, verificationId);
const state: WebAuthnState = { const state: WebAuthnState = {
options, options,
...flowState, ...flowState,
@ -43,7 +45,14 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => {
navigate({ pathname: `/${flow}/${MfaFactor.WebAuthn}` }, { replace, state }); navigate({ pathname: `/${flow}/${MfaFactor.WebAuthn}` }, { replace, state });
} }
}, },
[asyncCreateRegistrationOptions, asyncGenerateAuthnOptions, handleError, navigate, replace] [
asyncCreateRegistrationOptions,
asyncGenerateAuthnOptions,
handleError,
navigate,
replace,
setVerificationId,
]
); );
}; };

View file

@ -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. * 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. * 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()) { if (!browserSupportsWebAuthn()) {
setToast(t('mfa.webauthn_not_supported')); setToast(t('mfa.webauthn_not_supported'));
return; return;
@ -63,19 +63,26 @@ const useWebAuthnOperation = () => {
} }
); );
if (response) { if (!response) {
/** return;
* 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 } }
);
} }
/**
* 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] [sendMfaPayload, setToast, t]
); );

View file

@ -1,4 +1,4 @@
import { MissingProfile, SignInIdentifier } from '@logto/schemas'; import { InteractionEvent, MissingProfile, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
@ -37,10 +37,17 @@ jest.mock('@/apis/utils', () => ({
})); }));
describe('continue with email or phone', () => { describe('continue with email or phone', () => {
afterEach(() => {
jest.clearAllMocks();
});
const renderPage = (missingProfile: VerificationCodeProfileType) => const renderPage = (missingProfile: VerificationCodeProfileType) =>
renderWithPageContext( renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SetEmailOrPhone missingProfile={missingProfile} /> <SetEmailOrPhone
missingProfile={missingProfile}
interactionEvent={InteractionEvent.Register}
/>
</SettingsProvider> </SettingsProvider>
); );
@ -75,7 +82,7 @@ describe('continue with email or phone', () => {
] satisfies Array<[VerificationCodeProfileType, VerificationCodeIdentifier, string]>)( ] satisfies Array<[VerificationCodeProfileType, VerificationCodeIdentifier, string]>)(
'should send verification code properly', 'should send verification code properly',
async (type, identifier, input) => { async (type, identifier, input) => {
const { getByLabelText, getByText, container } = renderPage(type); const { getByText, container } = renderPage(type);
const inputField = container.querySelector('input[name="identifier"]'); const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.continue'); const submitButton = getByText('action.continue');
@ -92,9 +99,14 @@ describe('continue with email or phone', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Continue, { expect(sendVerificationCodeApi).toBeCalledWith(
[identifier]: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input, UserFlow.Continue,
}); {
type: identifier,
value: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
},
InteractionEvent.Register
);
}); });
} }
); );

View file

@ -6,7 +6,7 @@ import { useContext } from 'react';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import useSendVerificationCode from '@/hooks/use-send-verification-code'; import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { VerificationCodeIdentifier } from '@/types'; import type { ContinueFlowInteractionEvent, VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import IdentifierProfileForm from '../IdentifierProfileForm'; import IdentifierProfileForm from '../IdentifierProfileForm';
@ -17,7 +17,7 @@ export type VerificationCodeProfileType = Exclude<MissingProfile, 'username' | '
type Props = { type Props = {
readonly missingProfile: VerificationCodeProfileType; readonly missingProfile: VerificationCodeProfileType;
readonly notification?: TFuncKey; readonly interactionEvent: ContinueFlowInteractionEvent;
}; };
export const pageContent: Record< 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 { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue);
const { setIdentifierInputValue } = useContext(UserInteractionContext); const { setIdentifierInputValue } = useContext(UserInteractionContext);
@ -71,11 +71,11 @@ const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
setIdentifierInputValue({ type: identifier, value }); setIdentifierInputValue({ type: identifier, value });
return onSubmit({ identifier, value }); return onSubmit({ identifier, value }, interactionEvent);
}; };
return ( return (
<SecondaryPageLayout {...pageContent[missingProfile]} notification={notification}> <SecondaryPageLayout {...pageContent[missingProfile]}>
<IdentifierProfileForm <IdentifierProfileForm
autoFocus autoFocus
errorMessage={errorMessage} errorMessage={errorMessage}

View file

@ -1,9 +1,10 @@
import { InteractionEvent } from '@logto/schemas';
import { act, waitFor, fireEvent } from '@testing-library/react'; import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto'; import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { addProfile } from '@/apis/interaction'; import { updateProfile } from '@/apis/experience';
import SetPassword from '.'; import SetPassword from '.';
@ -14,15 +15,15 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
addProfile: jest.fn(async () => ({ redirectTo: '/' })), updateProfile: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('SetPassword', () => { describe('SetPassword', () => {
it('render set-password page properly without confirm password field', () => { it('render set-password page properly without confirm password field', () => {
const { queryByText, container } = renderWithPageContext( const { queryByText, container } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SetPassword /> <SetPassword interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull(); expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
@ -41,7 +42,7 @@ describe('SetPassword', () => {
}, },
}} }}
> >
<SetPassword /> <SetPassword interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull(); expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
@ -60,7 +61,7 @@ describe('SetPassword', () => {
}, },
}} }}
> >
<SetPassword /> <SetPassword interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
const submitButton = getByText('action.save_password'); const submitButton = getByText('action.save_password');
@ -95,7 +96,7 @@ describe('SetPassword', () => {
}, },
}} }}
> >
<SetPassword /> <SetPassword interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
const submitButton = getByText('action.save_password'); const submitButton = getByText('action.save_password');
@ -115,7 +116,13 @@ describe('SetPassword', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(addProfile).toBeCalledWith({ password: '1234!@#$' }); expect(updateProfile).toBeCalledWith(
{
type: 'password',
value: '1234!@#$',
},
InteractionEvent.Register
);
}); });
}); });
}); });

View file

@ -2,17 +2,26 @@ import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import { addProfile } from '@/apis/interaction'; import { updateProfile } from '@/apis/experience';
import SetPasswordForm from '@/containers/SetPassword'; import SetPasswordForm from '@/containers/SetPassword';
import useApi from '@/hooks/use-api';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; 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 usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { usePasswordPolicy } from '@/hooks/use-sie'; 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 [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => { const clearErrorMessage = useCallback(() => {
setErrorMessage(undefined); setErrorMessage(undefined);
}, []); }, []);
@ -21,7 +30,12 @@ const SetPassword = () => {
const { show } = usePromiseConfirmModal(); const { show } = usePromiseConfirmModal();
const redirectTo = useGlobalRedirectTo(); 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( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
@ -30,25 +44,36 @@ const SetPassword = () => {
navigate(-1); navigate(-1);
}, },
...preSignInErrorHandler, ...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) { if (result?.redirectTo) {
await redirectTo(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 { const {
policy: { policy: {
length: { min, max }, length: { min, max },
@ -68,7 +93,7 @@ const SetPassword = () => {
errorMessage={errorMessage} errorMessage={errorMessage}
maxLength={max} maxLength={max}
clearErrorMessage={clearErrorMessage} clearErrorMessage={clearErrorMessage}
onSubmit={action} onSubmit={onSubmitHandler}
/> />
</SecondaryPageLayout> </SecondaryPageLayout>
); );

View file

@ -1,8 +1,9 @@
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { act, waitFor, fireEvent } from '@testing-library/react'; import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { addProfile } from '@/apis/interaction'; import { updateProfile } from '@/apis/experience';
import SetUsername from '.'; import SetUsername from '.';
@ -19,15 +20,15 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
addProfile: jest.fn(async () => ({ redirectTo: '/' })), updateProfile: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('SetUsername', () => { describe('SetUsername', () => {
it('render SetUsername page properly', () => { it('render SetUsername page properly', () => {
const { queryByText, container } = renderWithPageContext( const { queryByText, container } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SetUsername /> <SetUsername interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
expect(container.querySelector('input[name="identifier"]')).not.toBeNull(); expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
@ -37,7 +38,7 @@ describe('SetUsername', () => {
it('should submit properly', async () => { it('should submit properly', async () => {
const { getByText, container } = renderWithPageContext( const { getByText, container } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<SetUsername /> <SetUsername interactionEvent={InteractionEvent.Register} />
</SettingsProvider> </SettingsProvider>
); );
const submitButton = getByText('action.continue'); const submitButton = getByText('action.continue');
@ -52,7 +53,10 @@ describe('SetUsername', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(addProfile).toBeCalledWith({ username: 'username' }); expect(updateProfile).toBeCalledWith(
{ type: SignInIdentifier.Username, value: 'username' },
InteractionEvent.Register
);
}); });
}); });
}); });

View file

@ -1,20 +1,20 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import type { TFuncKey } from 'i18next';
import { useContext } from 'react'; import { useContext } from 'react';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { type ContinueFlowInteractionEvent } from '@/types';
import IdentifierProfileForm from '../IdentifierProfileForm'; import IdentifierProfileForm from '../IdentifierProfileForm';
import useSetUsername from './use-set-username'; import useSetUsername from './use-set-username';
type Props = { type Props = {
readonly notification?: TFuncKey; readonly interactionEvent: ContinueFlowInteractionEvent;
}; };
const SetUsername = (props: Props) => { const SetUsername = ({ interactionEvent }: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(); const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(interactionEvent);
const { setIdentifierInputValue } = useContext(UserInteractionContext); const { setIdentifierInputValue } = useContext(UserInteractionContext);
@ -32,7 +32,6 @@ const SetUsername = (props: Props) => {
<SecondaryPageLayout <SecondaryPageLayout
title="description.enter_username" title="description.enter_username"
description="description.enter_username_description" description="description.enter_username_description"
{...props}
> >
<IdentifierProfileForm <IdentifierProfileForm
autoFocus autoFocus

View file

@ -1,24 +1,28 @@
import { SignInIdentifier } from '@logto/schemas';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { addProfile } from '@/apis/interaction'; import { updateProfile } from '@/apis/experience';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; 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 [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => { const clearErrorMessage = useCallback(() => {
setErrorMessage(''); setErrorMessage('');
}, []); }, []);
const asyncAddProfile = useApi(addProfile); const asyncAddProfile = useApi(updateProfile);
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler(); const preSignInErrorHandler = usePreSignInErrorHandler({
interactionEvent,
});
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
@ -32,7 +36,10 @@ const useSetUsername = () => {
const onSubmit = useCallback( const onSubmit = useCallback(
async (username: string) => { async (username: string) => {
const [error, result] = await asyncAddProfile({ username }); const [error, result] = await asyncAddProfile(
{ type: SignInIdentifier.Username, value: username },
interactionEvent
);
if (error) { if (error) {
await handleError(error, errorHandlers); await handleError(error, errorHandlers);
@ -44,7 +51,7 @@ const useSetUsername = () => {
await redirectTo(result.redirectTo); await redirectTo(result.redirectTo);
} }
}, },
[asyncAddProfile, errorHandlers, handleError, redirectTo] [asyncAddProfile, errorHandlers, handleError, interactionEvent, redirectTo]
); );
return { errorMessage, clearErrorMessage, onSubmit }; return { errorMessage, clearErrorMessage, onSubmit };

View file

@ -1,7 +1,9 @@
import { MissingProfile } from '@logto/schemas'; 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 ErrorPage from '@/pages/ErrorPage';
import { continueFlowStateGuard } from '@/types/guard';
import SetEmailOrPhone from './SetEmailOrPhone'; import SetEmailOrPhone from './SetEmailOrPhone';
import SetPassword from './SetPassword'; import SetPassword from './SetPassword';
@ -13,13 +15,22 @@ type Parameters = {
const Continue = () => { const Continue = () => {
const { method = '' } = useParams<Parameters>(); 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) { if (method === MissingProfile.password) {
return <SetPassword />; return <SetPassword interactionEvent={interactionEvent} />;
} }
if (method === MissingProfile.username) { if (method === MissingProfile.username) {
return <SetUsername />; return <SetUsername interactionEvent={interactionEvent} />;
} }
if ( if (
@ -27,7 +38,7 @@ const Continue = () => {
method === MissingProfile.phone || method === MissingProfile.phone ||
method === MissingProfile.emailOrPhone method === MissingProfile.emailOrPhone
) { ) {
return <SetEmailOrPhone missingProfile={method} />; return <SetEmailOrPhone missingProfile={method} interactionEvent={interactionEvent} />;
} }
return <ErrorPage />; return <ErrorPage />;

View file

@ -1,10 +1,10 @@
import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { act, fireEvent, waitFor } from '@testing-library/react'; import { act, fireEvent, waitFor } from '@testing-library/react';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { putInteraction, sendVerificationCode } from '@/apis/interaction'; import { sendVerificationCodeApi } from '@/apis/utils';
import { UserFlow, type VerificationCodeIdentifier } from '@/types'; import { UserFlow, type VerificationCodeIdentifier } from '@/types';
import ForgotPasswordForm from '.'; import ForgotPasswordForm from '.';
@ -21,9 +21,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/utils', () => ({
sendVerificationCode: jest.fn(() => ({ success: true })), sendVerificationCodeApi: jest.fn().mockResolvedValue({ verificationId: '123' }),
putInteraction: jest.fn(() => ({ success: true })),
})); }));
describe('ForgotPasswordForm', () => { describe('ForgotPasswordForm', () => {
@ -48,6 +47,8 @@ describe('ForgotPasswordForm', () => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
value: originalLocation, value: originalLocation,
}); });
jest.clearAllMocks();
}); });
describe.each([ describe.each([
@ -85,8 +86,14 @@ describe('ForgotPasswordForm', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.ForgotPassword); expect(sendVerificationCodeApi).toBeCalledWith(
expect(sendVerificationCode).toBeCalledWith({ email }); UserFlow.ForgotPassword,
{
type: identifier,
value,
},
undefined
);
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith(
{ {
pathname: `/${UserFlow.ForgotPassword}/verification-code`, pathname: `/${UserFlow.ForgotPassword}/verification-code`,

View file

@ -1,10 +1,11 @@
import { MfaFactor } from '@logto/schemas'; import { MfaFactor } from '@logto/schemas';
import { t } from 'i18next'; import { t } from 'i18next';
import { useState } from 'react'; import { useContext, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DynamicT from '@/components/DynamicT'; import DynamicT from '@/components/DynamicT';
import useSendMfaPayload from '@/hooks/use-send-mfa-payload'; import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
@ -20,11 +21,13 @@ const BackupCodeBinding = () => {
const { copyText, downloadText } = useTextHandler(); const { copyText, downloadText } = useTextHandler();
const sendMfaPayload = useSendMfaPayload(); const sendMfaPayload = useSendMfaPayload();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[MfaFactor.BackupCode];
const { state } = useLocation(); const { state } = useLocation();
const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard); const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard);
if (!backupCodeBindingState) { if (!backupCodeBindingState || !verificationId) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
@ -72,6 +75,7 @@ const BackupCodeBinding = () => {
await sendMfaPayload({ await sendMfaPayload({
flow: UserMfaFlow.MfaBinding, flow: UserMfaFlow.MfaBinding,
payload: { type: MfaFactor.BackupCode }, payload: { type: MfaFactor.BackupCode },
verificationId,
}); });
setIsSubmitting(false); setIsSubmitting(false);
}} }}

View file

@ -4,7 +4,11 @@ import SectionLayout from '@/Layout/SectionLayout';
import TotpCodeVerification from '@/containers/TotpCodeVerification'; import TotpCodeVerification from '@/containers/TotpCodeVerification';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
const VerificationSection = () => { type Props = {
readonly verificationId: string;
};
const VerificationSection = ({ verificationId }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -16,7 +20,7 @@ const VerificationSection = () => {
}} }}
description="mfa.enter_one_time_code_link_description" description="mfa.enter_one_time_code_link_description"
> >
<TotpCodeVerification flow={UserMfaFlow.MfaBinding} /> <TotpCodeVerification flow={UserMfaFlow.MfaBinding} verificationId={verificationId} />
</SectionLayout> </SectionLayout>
); );
}; };

View file

@ -1,8 +1,11 @@
import { VerificationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Divider from '@/components/Divider'; import Divider from '@/components/Divider';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useSkipMfa from '@/hooks/use-skip-mfa'; import useSkipMfa from '@/hooks/use-skip-mfa';
@ -17,9 +20,12 @@ import styles from './index.module.scss';
const TotpBinding = () => { const TotpBinding = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, totpBindingState] = validate(state, totpBindingStateGuard); const [, totpBindingState] = validate(state, totpBindingStateGuard);
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.TOTP];
const skipMfa = useSkipMfa(); const skipMfa = useSkipMfa();
if (!totpBindingState) { if (!totpBindingState || !verificationId) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
@ -33,7 +39,7 @@ const TotpBinding = () => {
<div className={styles.container}> <div className={styles.container}>
<SecretSection {...totpBindingState} /> <SecretSection {...totpBindingState} />
<Divider /> <Divider />
<VerificationSection /> <VerificationSection verificationId={verificationId} />
{availableFactors.length > 1 && ( {availableFactors.length > 1 && (
<> <>
<Divider /> <Divider />

View file

@ -1,9 +1,11 @@
import { VerificationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useState } from 'react'; import { useContext, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useSkipMfa from '@/hooks/use-skip-mfa'; import useSkipMfa from '@/hooks/use-skip-mfa';
@ -18,11 +20,14 @@ import styles from './index.module.scss';
const WebAuthnBinding = () => { const WebAuthnBinding = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, webAuthnState] = validate(state, webAuthnStateGuard); const [, webAuthnState] = validate(state, webAuthnStateGuard);
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.WebAuthn];
const handleWebAuthn = useWebAuthnOperation(); const handleWebAuthn = useWebAuthnOperation();
const skipMfa = useSkipMfa(); const skipMfa = useSkipMfa();
const [isCreatingPasskey, setIsCreatingPasskey] = useState(false); const [isCreatingPasskey, setIsCreatingPasskey] = useState(false);
if (!webAuthnState) { if (!webAuthnState || !verificationId) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
@ -43,7 +48,7 @@ const WebAuthnBinding = () => {
isLoading={isCreatingPasskey} isLoading={isCreatingPasskey}
onClick={async () => { onClick={async () => {
setIsCreatingPasskey(true); setIsCreatingPasskey(true);
await handleWebAuthn(options); await handleWebAuthn(options, verificationId);
setIsCreatingPasskey(false); setIsCreatingPasskey(false);
}} }}
/> />

View file

@ -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 { useLocation } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout'; import SectionLayout from '@/Layout/SectionLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation'; import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
@ -17,10 +19,13 @@ import styles from './index.module.scss';
const WebAuthnVerification = () => { const WebAuthnVerification = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, webAuthnState] = validate(state, webAuthnStateGuard); const [, webAuthnState] = validate(state, webAuthnStateGuard);
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.WebAuthn];
const handleWebAuthn = useWebAuthnOperation(); const handleWebAuthn = useWebAuthnOperation();
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
if (!webAuthnState) { if (!webAuthnState || !verificationId) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
@ -42,7 +47,7 @@ const WebAuthnVerification = () => {
isLoading={isVerifying} isLoading={isVerifying}
onClick={async () => { onClick={async () => {
setIsVerifying(true); setIsVerifying(true);
await handleWebAuthn(options); await handleWebAuthn(options, verificationId);
setIsVerifying(false); setIsVerifying(false);
}} }}
/> />

View file

@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto'; import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { setUserPassword } from '@/apis/interaction'; import { continueRegisterWithPassword } from '@/apis/experience';
import RegisterPassword from '.'; import RegisterPassword from '.';
@ -17,8 +17,8 @@ jest.mock('react-router-dom', () => ({
useLocation: jest.fn(() => ({ state: { username: 'username' } })), useLocation: jest.fn(() => ({ state: { username: 'username' } })),
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
setUserPassword: jest.fn(async () => ({ redirectTo: '/' })), continueRegisterWithPassword: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
const useLocationMock = useLocation as jest.Mock; const useLocationMock = useLocation as jest.Mock;
@ -148,7 +148,7 @@ describe('<RegisterPassword />', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(setUserPassword).toBeCalledWith('1234asdf'); expect(continueRegisterWithPassword).toBeCalledWith('1234asdf');
}); });
}); });
}); });

View file

@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { setUserPassword } from '@/apis/interaction'; import { resetPassword } from '@/apis/experience';
import ResetPassword from '.'; import ResetPassword from '.';
@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
setUserPassword: jest.fn(async () => ({ redirectTo: '/' })), resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('ForgotPassword', () => { describe('ForgotPassword', () => {
@ -73,7 +73,7 @@ describe('ForgotPassword', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(setUserPassword).toBeCalledWith('1234!@#$'); expect(resetPassword).toBeCalledWith('1234!@#$');
}); });
}); });
}); });

View file

@ -4,11 +4,13 @@ import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { setUserPassword } from '@/apis/interaction'; import { resetPassword } from '@/apis/experience';
import SetPassword from '@/containers/SetPassword'; import SetPassword from '@/containers/SetPassword';
import useApi from '@/hooks/use-api';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import { type ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
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 { usePasswordPolicy } from '@/hooks/use-sie'; import { usePasswordPolicy } from '@/hooks/use-sie';
import useToast from '@/hooks/use-toast'; import useToast from '@/hooks/use-toast';
@ -22,6 +24,13 @@ const ResetPassword = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { show } = usePromiseConfirmModal(); const { show } = usePromiseConfirmModal();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
const asyncResetPassword = useApi(resetPassword);
const handleError = useErrorHandler();
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'session.verification_session_not_found': async (error) => { 'session.verification_session_not_found': async (error) => {
@ -31,28 +40,42 @@ const ResetPassword = () => {
'user.same_password': (error) => { 'user.same_password': (error) => {
setErrorMessage(error.message); 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')); const onSubmitHandler = useCallback(
navigate('/sign-in', { replace: true }); async (password: string) => {
const success = await checkPassword(password);
if (!success) {
return;
} }
},
[navigate, setForgotPasswordIdentifierInputValue, setToast, t]
);
const [action] = usePasswordAction({ const [error] = await asyncResetPassword(password);
api: setUserPassword,
setErrorMessage, if (error) {
errorHandlers, await handleError(error, errorHandlers);
successHandler, 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 { const {
policy: { policy: {
@ -73,7 +96,7 @@ const ResetPassword = () => {
errorMessage={errorMessage} errorMessage={errorMessage}
maxLength={max} maxLength={max}
clearErrorMessage={clearErrorMessage} clearErrorMessage={clearErrorMessage}
onSubmit={action} onSubmit={onSubmitHandler}
/> />
</SecondaryPageLayout> </SecondaryPageLayout>
); );

View file

@ -4,17 +4,17 @@ import { fireEvent, waitFor, act } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { import {
signInWithPasswordIdentifier, signInWithPasswordIdentifier,
putInteraction, initInteraction,
sendVerificationCode, sendVerificationCode,
} from '@/apis/interaction'; } from '@/apis/experience';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import PasswordForm from '.'; import PasswordForm from '.';
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })), signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })),
sendVerificationCode: jest.fn(() => ({ success: true })), sendVerificationCode: jest.fn(() => ({ success: true })),
putInteraction: jest.fn(() => ({ success: true })), initInteraction: jest.fn(() => ({ success: true })),
})); }));
const mockedNavigate = jest.fn(); const mockedNavigate = jest.fn();
@ -90,8 +90,11 @@ describe('PasswordSignInForm', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); expect(initInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ [identifier]: value }); expect(sendVerificationCode).toBeCalledWith(InteractionEvent.SignIn, {
type: identifier,
value,
});
}); });
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith(

View file

@ -1,6 +1,5 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
@ -13,7 +12,6 @@ import SignInPassword from '.';
describe('SignInPassword', () => { describe('SignInPassword', () => {
const { result } = renderHook(() => useSessionStorage()); const { result } = renderHook(() => useSessionStorage());
const { set, remove } = result.current; const { set, remove } = result.current;
const mockUseLocation = useLocation as jest.Mock;
const email = 'email@logto.io'; const email = 'email@logto.io';
const phone = '18571111111'; const phone = '18571111111';
const username = 'foo'; const username = 'foo';

View file

@ -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 { Route, Routes } from 'react-router-dom';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto'; import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import SocialRegister from '.'; import SocialRegister from '.';
@ -14,13 +17,24 @@ jest.mock('react-router-dom', () => ({
})), })),
})); }));
const verificationIdsMap = { [VerificationType.Social]: 'foo' };
describe('SocialRegister', () => { describe('SocialRegister', () => {
const { result } = renderHook(() => useSessionStorage());
const { set } = result.current;
beforeAll(() => {
set(StorageKeys.verificationIds, verificationIdsMap);
});
it('render', () => { it('render', () => {
const { queryByText } = renderWithPageContext( const { queryByText } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<Routes> <UserInteractionContextProvider>
<Route path="/social/link/:connectorId" element={<SocialRegister />} /> <Routes>
</Routes> <Route path="/social/link/:connectorId" element={<SocialRegister />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: ['/social/link/github'] } { initialEntries: ['/social/link/github'] }
); );
@ -40,9 +54,11 @@ describe('SocialRegister', () => {
}, },
}} }}
> >
<Routes> <UserInteractionContextProvider>
<Route path="/social/link/:connectorId" element={<SocialRegister />} /> <Routes>
</Routes> <Route path="/social/link/:connectorId" element={<SocialRegister />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: ['/social/link/github'] } { initialEntries: ['/social/link/github'] }
); );
@ -62,9 +78,11 @@ describe('SocialRegister', () => {
}, },
}} }}
> >
<Routes> <UserInteractionContextProvider>
<Route path="/social/link/:connectorId" element={<SocialRegister />} /> <Routes>
</Routes> <Route path="/social/link/:connectorId" element={<SocialRegister />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: ['/social/link/github'] } { initialEntries: ['/social/link/github'] }
); );
@ -84,9 +102,11 @@ describe('SocialRegister', () => {
}, },
}} }}
> >
<Routes> <UserInteractionContextProvider>
<Route path="/social/link/:connectorId" element={<SocialRegister />} /> <Routes>
</Routes> <Route path="/social/link/:connectorId" element={<SocialRegister />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: ['/social/link/github'] } { initialEntries: ['/social/link/github'] }
); );

View file

@ -1,9 +1,11 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier, VerificationType } from '@logto/schemas';
import type { TFuncKey } from 'i18next'; 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 { is } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import SocialLinkAccountContainer from '@/containers/SocialLinkAccount'; import SocialLinkAccountContainer from '@/containers/SocialLinkAccount';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
@ -36,6 +38,8 @@ const SocialLinkAccount = () => {
const { connectorId } = useParams<Parameters>(); const { connectorId } = useParams<Parameters>();
const { state } = useLocation(); const { state } = useLocation();
const { signUpMethods } = useSieMethods(); const { signUpMethods } = useSieMethods();
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.Social];
if (!is(state, socialAccountNotExistErrorDataGuard)) { if (!is(state, socialAccountNotExistErrorDataGuard)) {
return <ErrorPage rawMessage="Missing relate account info" />; return <ErrorPage rawMessage="Missing relate account info" />;
@ -45,11 +49,19 @@ const SocialLinkAccount = () => {
return <ErrorPage rawMessage="Connector not found" />; return <ErrorPage rawMessage="Connector not found" />;
} }
if (!verificationId) {
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
}
const { relatedUser } = state; const { relatedUser } = state;
return ( return (
<SecondaryPageLayout title={getPageTitle(signUpMethods)}> <SecondaryPageLayout title={getPageTitle(signUpMethods)}>
<SocialLinkAccountContainer connectorId={connectorId} relatedUser={relatedUser} /> <SocialLinkAccountContainer
connectorId={connectorId}
verificationId={verificationId}
relatedUser={relatedUser}
/>
</SecondaryPageLayout> </SecondaryPageLayout>
); );
}; };

View file

@ -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 { Navigate, Route, Routes, useSearchParams } from 'react-router-dom';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; 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 { socialConnectors } from '@/__mocks__/social-connectors';
import { signInWithSocial } from '@/apis/interaction'; import { verifySocialVerification, signInWithSso } from '@/apis/experience';
import { singleSignOnAuthorization } from '@/apis/single-sign-on'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { type SignInExperienceResponse } from '@/types'; import { type SignInExperienceResponse } from '@/types';
import { generateState, storeState } from '@/utils/social-connectors'; import { generateState, storeState } from '@/utils/social-connectors';
@ -17,12 +19,10 @@ jest.mock('i18next', () => ({
language: 'en', language: 'en',
})); }));
jest.mock('@/apis/interaction', () => ({ jest.mock('@/apis/experience', () => ({
signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), verifySocialVerification: jest.fn().mockResolvedValue({ verificationId: 'foo' }),
})); identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
signInWithSso: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
jest.mock('@/apis/single-sign-on', () => ({
singleSignOnAuthorization: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
})); }));
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
@ -34,7 +34,19 @@ jest.mock('react-router-dom', () => ({
const mockUseSearchParameters = useSearchParams as jest.Mock; const mockUseSearchParameters = useSearchParams as jest.Mock;
const mockNavigate = Navigate as jest.Mock; const mockNavigate = Navigate as jest.Mock;
const verificationIdsMap = {
[VerificationType.Social]: 'foo',
[VerificationType.EnterpriseSso]: 'bar',
};
describe('SocialCallbackPage with code', () => { describe('SocialCallbackPage with code', () => {
const { result } = renderHook(() => useSessionStorage());
const { set } = result.current;
beforeAll(() => {
set(StorageKeys.verificationIds, verificationIdsMap);
});
describe('fallback', () => { describe('fallback', () => {
it('should redirect to /sign-in if connectorId is not found', async () => { it('should redirect to /sign-in if connectorId is not found', async () => {
mockUseSearchParameters.mockReturnValue([new URLSearchParams('code=foo'), jest.fn()]); mockUseSearchParameters.mockReturnValue([new URLSearchParams('code=foo'), jest.fn()]);
@ -49,7 +61,7 @@ describe('SocialCallbackPage with code', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(signInWithSocial).not.toBeCalled(); expect(verifySocialVerification).not.toBeCalled();
expect(mockNavigate.mock.calls[0][0].to).toBe('/sign-in'); expect(mockNavigate.mock.calls[0][0].to).toBe('/sign-in');
}); });
}); });
@ -68,20 +80,22 @@ describe('SocialCallbackPage with code', () => {
renderWithPageContext( renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<Routes> <UserInteractionContextProvider>
<Route path="/callback/social/:connectorId" element={<SocialCallback />} /> <Routes>
</Routes> <Route path="/callback/social/:connectorId" element={<SocialCallback />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: [`/callback/social/${connectorId}`] } { initialEntries: [`/callback/social/${connectorId}`] }
); );
await waitFor(() => { await waitFor(() => {
expect(signInWithSocial).toBeCalled(); expect(verifySocialVerification).toBeCalled();
}); });
}); });
it('callback with invalid state should not call signInWithSocial', async () => { it('callback with invalid state should not call signInWithSocial', async () => {
(signInWithSocial as jest.Mock).mockClear(); (verifySocialVerification as jest.Mock).mockClear();
mockUseSearchParameters.mockReturnValue([ mockUseSearchParameters.mockReturnValue([
new URLSearchParams(`state=bar&code=foo`), new URLSearchParams(`state=bar&code=foo`),
@ -90,15 +104,17 @@ describe('SocialCallbackPage with code', () => {
renderWithPageContext( renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<Routes> <UserInteractionContextProvider>
<Route path="/callback/social/:connectorId" element={<SocialCallback />} /> <Routes>
</Routes> <Route path="/callback/social/:connectorId" element={<SocialCallback />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: [`/callback/social/${connectorId}`] } { initialEntries: [`/callback/social/${connectorId}`] }
); );
await waitFor(() => { await waitFor(() => {
expect(signInWithSocial).not.toBeCalled(); expect(verifySocialVerification).not.toBeCalled();
}); });
}); });
}); });
@ -121,20 +137,22 @@ describe('SocialCallbackPage with code', () => {
renderWithPageContext( renderWithPageContext(
<SettingsProvider settings={sieSettings}> <SettingsProvider settings={sieSettings}>
<Routes> <UserInteractionContextProvider>
<Route path="/callback/social/:connectorId" element={<SocialCallback />} /> <Routes>
</Routes> <Route path="/callback/social/:connectorId" element={<SocialCallback />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: [`/callback/social/${connectorId}`] } { initialEntries: [`/callback/social/${connectorId}`] }
); );
await waitFor(() => { await waitFor(() => {
expect(singleSignOnAuthorization).toBeCalled(); expect(signInWithSso).toBeCalled();
}); });
}); });
it('callback with invalid state should not call singleSignOnAuthorization', async () => { it('callback with invalid state should not call signInWithSso', async () => {
(singleSignOnAuthorization as jest.Mock).mockClear(); (signInWithSso as jest.Mock).mockClear();
mockUseSearchParameters.mockReturnValue([ mockUseSearchParameters.mockReturnValue([
new URLSearchParams(`state=bar&code=foo`), new URLSearchParams(`state=bar&code=foo`),
@ -143,15 +161,17 @@ describe('SocialCallbackPage with code', () => {
renderWithPageContext( renderWithPageContext(
<SettingsProvider settings={sieSettings}> <SettingsProvider settings={sieSettings}>
<Routes> <UserInteractionContextProvider>
<Route path="/callback/social/:connectorId" element={<SocialCallback />} /> <Routes>
</Routes> <Route path="/callback/social/:connectorId" element={<SocialCallback />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: [`/callback/social/${connectorId}`] } { initialEntries: [`/callback/social/${connectorId}`] }
); );
await waitFor(() => { await waitFor(() => {
expect(singleSignOnAuthorization).not.toBeCalled(); expect(signInWithSso).not.toBeCalled();
}); });
}); });
}); });

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas'; import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom'; 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 useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
@ -15,13 +16,13 @@ import { validateState } from '@/utils/social-connectors';
const useSingleSignOnRegister = () => { const useSingleSignOnRegister = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const request = useApi(singleSignOnRegistration); const request = useApi(registerWithVerifiedIdentifier);
const { termsValidation, agreeToTermsPolicy } = useTerms(); const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate(); const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
return useCallback( return useCallback(
async (connectorId: string) => { async (verificationId: string) => {
/** /**
* Agree to terms and conditions first before proceeding * 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. * If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
@ -32,7 +33,7 @@ const useSingleSignOnRegister = () => {
return; return;
} }
const [error, result] = await request(connectorId); const [error, result] = await request(verificationId);
if (error) { if (error) {
await handleError(error); await handleError(error);
@ -66,19 +67,24 @@ const useSingleSignOnListener = (connectorId: string) => {
const { setToast } = useToast(); const { setToast } = useToast();
const redirectTo = useGlobalRedirectTo(); const redirectTo = useGlobalRedirectTo();
const { signInMode } = useSieMethods(); const { signInMode } = useSieMethods();
const { verificationIdsMap } = useContext(UserInteractionContext);
const verificationId = verificationIdsMap[VerificationType.EnterpriseSso];
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const navigate = useNavigate(); const navigate = useNavigate();
const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization); const singleSignOnAuthorizationRequest = useApi(signInWithSso);
const registerSingleSignOnIdentity = useSingleSignOnRegister(); const registerSingleSignOnIdentity = useSingleSignOnRegister();
const singleSignOnHandler = useCallback( 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, { const [error, result] = await singleSignOnAuthorizationRequest(connectorId, {
...data, verificationId,
// For connector validation use connectorData: {
redirectUri: `${window.location.origin}/callback/${connectorId}`, ...data,
// For connector validation use
redirectUri: `${window.location.origin}/callback/${connectorId}`,
},
}); });
if (error) { if (error) {
@ -92,7 +98,7 @@ const useSingleSignOnListener = (connectorId: string) => {
return; return;
} }
await registerSingleSignOnIdentity(connectorId); await registerSingleSignOnIdentity(verificationId);
}, },
// Redirect to sign-in page if error is not handled by the error handlers // Redirect to sign-in page if error is not handled by the error handlers
global: async (error) => { global: async (error) => {
@ -138,7 +144,14 @@ const useSingleSignOnListener = (connectorId: string) => {
return; 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, connectorId,
isConsumed, isConsumed,
@ -148,6 +161,7 @@ const useSingleSignOnListener = (connectorId: string) => {
setToast, setToast,
singleSignOnHandler, singleSignOnHandler,
t, t,
verificationId,
]); ]);
return { loading }; return { loading };

View file

@ -1,12 +1,23 @@
import { GoogleConnector } from '@logto/connector-kit'; import { GoogleConnector } from '@logto/connector-kit';
import type { RequestErrorBody } from '@logto/schemas'; import type { RequestErrorBody } from '@logto/schemas';
import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas'; import {
import { useCallback, useEffect, useMemo, useState } from 'react'; AgreeToTermsPolicy,
InteractionEvent,
SignInMode,
VerificationType,
experience,
} from '@logto/schemas';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { validate } from 'superstruct'; 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 useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
@ -28,26 +39,37 @@ const useSocialSignInListener = (connectorId: string) => {
const { termsValidation, agreeToTermsPolicy } = useTerms(); const { termsValidation, agreeToTermsPolicy } = useTerms();
const [isConsumed, setIsConsumed] = useState(false); const [isConsumed, setIsConsumed] = useState(false);
const [searchParameters, setSearchParameters] = useSearchParams(); 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 navigate = useNavigate();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const bindSocialRelatedUser = useBindSocialRelatedUser(); const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId, true); const registerWithSocial = useSocialRegister(connectorId, true);
const asyncSignInWithSocial = useApi(signInWithSocial); const verifySocial = useApi(verifySocialVerification);
const asyncPutInteraction = useApi(putInteraction); const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction);
const asyncInitInteraction = useApi(initInteraction);
const accountNotExistErrorHandler = useCallback( const accountNotExistErrorHandler = useCallback(
async (error: RequestErrorBody) => { async (error: RequestErrorBody) => {
const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard); const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard);
const { relatedUser } = data ?? {}; 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 (relatedUser) {
if (socialSignInSettings.automaticAccountLinking) { if (socialSignInSettings.automaticAccountLinking) {
const { type, value } = relatedUser; await bindSocialRelatedUser(verificationId);
await bindSocialRelatedUser({
connectorId,
...(type === 'email' ? { email: value } : { phone: value }),
});
} else { } else {
navigate(`/social/link/${connectorId}`, { navigate(`/social/link/${connectorId}`, {
replace: true, replace: true,
@ -59,17 +81,30 @@ const useSocialSignInListener = (connectorId: string) => {
} }
// Register with social // Register with social
await registerWithSocial(connectorId); await registerWithSocial(verificationId);
}, },
[ [
bindSocialRelatedUser, bindSocialRelatedUser,
connectorId, connectorId,
navigate, navigate,
registerWithSocial, registerWithSocial,
setToast,
socialSignInSettings.automaticAccountLinking, 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 preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo( const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
@ -95,14 +130,11 @@ const useSocialSignInListener = (connectorId: string) => {
await accountNotExistErrorHandler(error); await accountNotExistErrorHandler(error);
}, },
...preSignInErrorHandler, ...preSignInErrorHandler,
// Redirect to sign-in page if error is not handled by the error handlers ...globalErrorHandler,
global: async (error) => {
setToast(error.message);
navigate('/' + experience.routes.signIn);
},
}), }),
[ [
preSignInErrorHandler, preSignInErrorHandler,
globalErrorHandler,
signInMode, signInMode,
agreeToTermsPolicy, agreeToTermsPolicy,
termsValidation, termsValidation,
@ -112,15 +144,15 @@ const useSocialSignInListener = (connectorId: string) => {
] ]
); );
const signInWithSocialHandler = useCallback( const verifySocialCallbackData = useCallback(
async (connectorId: string, data: Record<string, unknown>) => { async (connectorId: string, data: Record<string, unknown>) => {
// When the callback is called from Google One Tap, the interaction event was not set yet. // When the callback is called from Google One Tap, the interaction event was not set yet.
if (data[GoogleConnector.oneTapParams.csrfToken]) { if (data[GoogleConnector.oneTapParams.csrfToken]) {
await asyncPutInteraction(InteractionEvent.SignIn); await asyncInitInteraction(InteractionEvent.SignIn);
} }
const [error, result] = await asyncSignInWithSocial({ const [error, result] = await verifySocial(connectorId, {
connectorId, verificationId: verificationIdRef.current,
connectorData: { connectorData: {
// For validation use only // For validation use only
redirectUri: `${window.location.origin}/callback/${connectorId}`, 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) { if (error) {
setLoading(false); setLoading(false);
await handleError(error, signInWithSocialErrorHandlers); await handleError(error, signInWithSocialErrorHandlers);
@ -139,7 +200,7 @@ const useSocialSignInListener = (connectorId: string) => {
window.location.replace(result.redirectTo); window.location.replace(result.redirectTo);
} }
}, },
[asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers] [asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers, verifySocialCallbackData]
); );
// Social Sign-in Callback Handler // Social Sign-in Callback Handler
@ -152,18 +213,25 @@ const useSocialSignInListener = (connectorId: string) => {
const { state, ...rest } = parseQueryParameters(searchParameters); const { state, ...rest } = parseQueryParameters(searchParameters);
const isGoogleOneTap = validateGoogleOneTapCsrfToken(
rest[GoogleConnector.oneTapParams.csrfToken]
);
// Cleanup the search parameters once it's consumed // Cleanup the search parameters once it's consumed
setSearchParameters({}, { replace: true }); setSearchParameters({}, { replace: true });
if ( if (!validateState(state, connectorId) && !isGoogleOneTap) {
!validateState(state, connectorId) &&
!validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken])
) {
setToast(t('error.invalid_connector_auth')); setToast(t('error.invalid_connector_auth'));
navigate('/' + experience.routes.signIn); navigate('/' + experience.routes.signIn);
return; return;
} }
if (!verificationIdRef.current && !isGoogleOneTap) {
setToast(t('error.invalid_session'));
navigate('/' + experience.routes.signIn);
return;
}
void signInWithSocialHandler(connectorId, rest); void signInWithSocialHandler(connectorId, rest);
}, [ }, [
connectorId, connectorId,

View file

@ -1,4 +1,4 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier, VerificationType } from '@logto/schemas';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { remove } from 'tiny-cookie'; import { remove } from 'tiny-cookie';
@ -16,6 +16,7 @@ describe('VerificationCode Page', () => {
beforeEach(() => { beforeEach(() => {
set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Email, value: 'foo@logto.io' }); set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Email, value: 'foo@logto.io' });
set(StorageKeys.verificationIds, { [VerificationType.EmailVerificationCode]: 'foo' });
}); });
afterEach(() => { afterEach(() => {

View file

@ -1,4 +1,4 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier, type VerificationCodeIdentifier } from '@logto/schemas';
import { t } from 'i18next'; import { t } from 'i18next';
import { useContext } from 'react'; import { useContext } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@ -6,22 +6,33 @@ import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import VerificationCodeContainer from '@/containers/VerificationCode'; import VerificationCodeContainer from '@/containers/VerificationCode';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import { userFlowGuard } from '@/types/guard'; import { userFlowGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
type Parameters = { type Parameters = {
flow: string; flow: string;
}; };
const isValidVerificationCodeIdentifier = (
identifierInputValue: IdentifierInputValue | undefined
): identifierInputValue is VerificationCodeIdentifier =>
Boolean(
identifierInputValue?.type &&
identifierInputValue.type !== SignInIdentifier.Username &&
identifierInputValue.value
);
const VerificationCode = () => { const VerificationCode = () => {
const { flow } = useParams<Parameters>(); const { flow } = useParams<Parameters>();
const { signInMethods } = useSieMethods(); const { signInMethods } = useSieMethods();
const { identifierInputValue, forgotPasswordIdentifierInputValue } = const { identifierInputValue, forgotPasswordIdentifierInputValue, verificationIdsMap } =
useContext(UserInteractionContext); useContext(UserInteractionContext);
const [, userFlow] = validate(flow, userFlowGuard); const [, userFlow] = validate(flow, userFlowGuard);
@ -33,19 +44,24 @@ const VerificationCode = () => {
const cachedIdentifierInputValue = const cachedIdentifierInputValue =
flow === UserFlow.ForgotPassword ? forgotPasswordIdentifierInputValue : identifierInputValue; flow === UserFlow.ForgotPassword ? forgotPasswordIdentifierInputValue : identifierInputValue;
const { type, value } = cachedIdentifierInputValue ?? {}; if (!isValidVerificationCodeIdentifier(cachedIdentifierInputValue)) {
if (!type || type === SignInIdentifier.Username || !value) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
const methodSettings = signInMethods.find((method) => method.identifier === type); const { type, value } = cachedIdentifierInputValue;
// SignIn Method not enabled // SignIn Method not enabled
const methodSettings = signInMethods.find((method) => method.identifier === type);
if (!methodSettings && flow !== UserFlow.ForgotPassword) { if (!methodSettings && flow !== UserFlow.ForgotPassword) {
return <ErrorPage />; return <ErrorPage />;
} }
// VerificationId not found
const verificationId = verificationIdsMap[codeVerificationTypeMap[type]];
if (!verificationId) {
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
}
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
title={`description.verify_${type}`} title={`description.verify_${type}`}
@ -58,8 +74,8 @@ const VerificationCode = () => {
> >
<VerificationCodeContainer <VerificationCodeContainer
flow={userFlow} flow={userFlow}
identifier={type} identifier={cachedIdentifierInputValue}
target={value} verificationId={verificationId}
hasPasswordButton={userFlow === UserFlow.SignIn && methodSettings?.password} hasPasswordButton={userFlow === UserFlow.SignIn && methodSettings?.password}
/> />
</SecondaryPageLayout> </SecondaryPageLayout>

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

View file

@ -1,8 +1,10 @@
import { import {
SignInIdentifier, InteractionEvent,
MissingProfile,
MfaFactor, MfaFactor,
MissingProfile,
SignInIdentifier,
type SsoConnectorMetadata, type SsoConnectorMetadata,
VerificationType,
} from '@logto/schemas'; } from '@logto/schemas';
import * as s from 'superstruct'; import * as s from 'superstruct';
@ -81,12 +83,10 @@ export const totpBindingStateGuard = s.assign(
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>; export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
export const backupCodeErrorDataGuard = s.object({ export const backupCodeBindingStateGuard = s.object({
codes: s.array(s.string()), codes: s.array(s.string()),
}); });
export const backupCodeBindingStateGuard = backupCodeErrorDataGuard;
export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>; export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>;
export const webAuthnStateGuard = s.assign( 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. * Type guard for the `identifier` search param config on the identifier sign-in/register page.
*/ */
export const identifierSearchParamGuard = s.array(identifierEnumGuard); 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>;

View file

@ -4,6 +4,7 @@ import type {
WebAuthnRegistrationOptions, WebAuthnRegistrationOptions,
WebAuthnAuthenticationOptions, WebAuthnAuthenticationOptions,
FullSignInExperience, FullSignInExperience,
InteractionEvent,
} from '@logto/schemas'; } from '@logto/schemas';
export enum UserFlow { export enum UserFlow {
@ -45,3 +46,5 @@ export type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType exten
: never; : never;
export type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions; export type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions;
export type ContinueFlowInteractionEvent = InteractionEvent.Register | InteractionEvent.SignIn;

View file

@ -3,7 +3,7 @@
* Remove this once we have a better way to get the sign in experience through SSR * 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 { isObject } from '@silverhand/essentials';
import i18next from 'i18next'; import i18next from 'i18next';
@ -68,3 +68,8 @@ export const parseHtmlTitle = (path: string) => {
return 'Logto'; return 'Logto';
}; };
export const codeVerificationTypeMap = Object.freeze({
[SignInIdentifier.Email]: VerificationType.EmailVerificationCode,
[SignInIdentifier.Phone]: VerificationType.PhoneVerificationCode,
});