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

View file

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

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 { putInteraction, sendVerificationCode } from './interaction';
import { initInteraction, sendVerificationCode } from './experience';
/** Move to API */
export const sendVerificationCodeApi = async (
type: UserFlow,
payload: SendVerificationCodePayload
identifier: VerificationCodeIdentifier,
interactionEvent?: ContinueFlowInteractionEvent
) => {
if (type === UserFlow.ForgotPassword) {
await putInteraction(InteractionEvent.ForgotPassword);
switch (type) {
case UserFlow.SignIn: {
await initInteraction(InteractionEvent.SignIn);
return sendVerificationCode(InteractionEvent.SignIn, identifier);
}
case UserFlow.Register: {
await initInteraction(InteractionEvent.Register);
return sendVerificationCode(InteractionEvent.Register, identifier);
}
case UserFlow.ForgotPassword: {
await initInteraction(InteractionEvent.ForgotPassword);
return sendVerificationCode(InteractionEvent.ForgotPassword, identifier);
}
case UserFlow.Continue: {
return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier);
}
}
};
export const resendVerificationCodeApi = async (
type: UserFlow,
identifier: VerificationCodeIdentifier
) => {
switch (type) {
case UserFlow.SignIn: {
return sendVerificationCode(InteractionEvent.SignIn, identifier);
}
case UserFlow.Register: {
return sendVerificationCode(InteractionEvent.Register, identifier);
}
case UserFlow.ForgotPassword: {
return sendVerificationCode(InteractionEvent.ForgotPassword, identifier);
}
case UserFlow.Continue: {
// Continue flow does not have its own email template, always use sign-in template for now
return sendVerificationCode(InteractionEvent.SignIn, identifier);
}
}
if (type === UserFlow.SignIn) {
await putInteraction(InteractionEvent.SignIn);
}
if (type === UserFlow.Register) {
await putInteraction(InteractionEvent.Register);
}
return sendVerificationCode(payload);
};

View file

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

View file

@ -36,8 +36,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/single-sign-on', () => ({
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
jest.mock('@/apis/experience', () => ({
getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
}));
const username = 'foo';
@ -151,12 +151,17 @@ describe('IdentifierSignInForm', () => {
if (verificationCode) {
await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, {
[identifier]:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
});
expect(sendVerificationCodeApi).toBeCalledWith(
UserFlow.SignIn,
{
type: identifier,
value:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
undefined
);
expect(mockedNavigate).not.toBeCalled();
});
}
@ -221,7 +226,7 @@ describe('IdentifierSignInForm', () => {
});
it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]);
getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] });
const { getByText, container, queryByText } = renderForm(
mockSignInMethodSettingsTestCases[0]!,
@ -255,7 +260,9 @@ describe('IdentifierSignInForm', () => {
});
it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
getSingleSignOnConnectorsMock.mockResolvedValueOnce({
connectorIds: mockSsoConnectors.map(({ id }) => id),
});
const { getByText, container, queryByText } = renderForm(
mockSignInMethodSettingsTestCases[0]!,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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;
const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => {
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial });
const usePreSignInErrorHandler = ({ replace, ...rest }: Options = {}): ErrorHandlers => {
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, ...rest });
const mfaErrorHandler = useMfaErrorHandler({ replace });
return useMemo(

View file

@ -1,9 +1,9 @@
import { MissingProfile } from '@logto/schemas';
import { InteractionEvent, MissingProfile } from '@logto/schemas';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct';
import { UserFlow, SearchParameters } from '@/types';
import { UserFlow, SearchParameters, type ContinueFlowInteractionEvent } from '@/types';
import { missingProfileErrorDataGuard } from '@/types/guard';
import { queryStringify } from '@/utils';
@ -13,9 +13,19 @@ import useToast from './use-toast';
export type Options = {
replace?: boolean;
linkSocial?: string;
/**
* We use this param to track the current profile fulfillment flow.
* If is UserFlow.Register, we need to call the identify endpoint after the user completes the profile.
* If is UserFlow.SignIn, directly call the submitInteraction endpoint.
**/
interactionEvent?: ContinueFlowInteractionEvent;
};
const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) => {
const useRequiredProfileErrorHandler = ({
replace,
linkSocial,
interactionEvent = InteractionEvent.SignIn,
}: Options = {}) => {
const navigate = useNavigate();
const { setToast } = useToast();
@ -27,9 +37,6 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
// Required as a sign up method but missing in the user profile
const missingProfile = data?.missingProfile[0];
// Required as a sign up method, verified email or phone can be found in Social Identity, but registered with a different account
const registeredSocialIdentity = data?.registeredSocialIdentity;
const linkSocialQueryString = linkSocial
? `?${queryStringify({ [SearchParameters.LinkSocial]: linkSocial })}`
: undefined;
@ -41,7 +48,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
{
pathname: `/${UserFlow.Continue}/${missingProfile}`,
},
{ replace }
{ replace, state: { interactionEvent } }
);
break;
}
@ -53,7 +60,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
pathname: `/${UserFlow.Continue}/${missingProfile}`,
search: linkSocialQueryString,
},
{ replace, state: { registeredSocialIdentity } }
{ replace, state: { interactionEvent } }
);
break;
}
@ -65,7 +72,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) =
}
},
}),
[linkSocial, navigate, replace, setToast]
[interactionEvent, linkSocial, navigate, replace, setToast]
);
return requiredProfileErrorHandler;

View file

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

View file

@ -1,13 +1,20 @@
/* Replace legacy useSendVerificationCode hook with this one after the refactor */
import { SignInIdentifier } from '@logto/schemas';
import { useState, useCallback } from 'react';
import { conditional } from '@silverhand/essentials';
import { useCallback, useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import { type VerificationCodeIdentifier, type UserFlow } from '@/types';
import {
UserFlow,
type ContinueFlowInteractionEvent,
type VerificationCodeIdentifier,
} from '@/types';
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
const [errorMessage, setErrorMessage] = useState<string>();
@ -15,6 +22,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
const handleError = useErrorHandler();
const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
const { setVerificationId } = useContext(UserInteractionContext);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
@ -26,10 +34,15 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
};
const onSubmit = useCallback(
async ({ identifier, value }: Payload) => {
const [error, result] = await asyncSendVerificationCode(flow, {
[identifier]: value,
});
async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => {
const [error, result] = await asyncSendVerificationCode(
flow,
{
type: identifier,
value,
},
interactionEvent
);
if (error) {
await handleError(error, {
@ -44,6 +57,9 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
}
if (result) {
// Store the verification ID in the context so that we can use it in the next step
setVerificationId(codeVerificationTypeMap[identifier], result.verificationId);
navigate(
{
pathname: `/${flow}/verification-code`,
@ -51,11 +67,17 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
},
{
replace: replaceCurrentPage,
// Append the interaction event to the state so that we can use it in the next step
...conditional(
flow === UserFlow.Continue && {
state: { interactionEvent },
}
),
}
);
}
},
[asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage]
[asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage, setVerificationId]
);
return {

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

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.
* So, we need to prepare the necessary WebAuthn options before calling the WebAuthn API, this is why we don't generate the options in this function.
*/
async (options: WebAuthnOptions) => {
async (options: WebAuthnOptions, verificationId: string) => {
if (!browserSupportsWebAuthn()) {
setToast(t('mfa.webauthn_not_supported'));
return;
@ -63,19 +63,26 @@ const useWebAuthnOperation = () => {
}
);
if (response) {
/**
* Assert type manually to get the correct type
*/
void sendMfaPayload(
isAuthenticationResponseJSON(response)
? {
flow: UserMfaFlow.MfaVerification,
payload: { ...response, type: MfaFactor.WebAuthn },
}
: { flow: UserMfaFlow.MfaBinding, payload: { ...response, type: MfaFactor.WebAuthn } }
);
if (!response) {
return;
}
/**
* Assert type manually to get the correct type
*/
void sendMfaPayload(
isAuthenticationResponseJSON(response)
? {
flow: UserMfaFlow.MfaVerification,
payload: { ...response, type: MfaFactor.WebAuthn },
verificationId,
}
: {
flow: UserMfaFlow.MfaBinding,
payload: { ...response, type: MfaFactor.WebAuthn },
verificationId,
}
);
},
[sendMfaPayload, setToast, t]
);

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

View file

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

View file

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

View file

@ -2,17 +2,26 @@ import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import { addProfile } from '@/apis/interaction';
import { updateProfile } from '@/apis/experience';
import SetPasswordForm from '@/containers/SetPassword';
import useApi from '@/hooks/use-api';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker';
import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { usePasswordPolicy } from '@/hooks/use-sie';
import { type ContinueFlowInteractionEvent } from '@/types';
const SetPassword = () => {
type Props = {
readonly interactionEvent: ContinueFlowInteractionEvent;
};
const SetPassword = ({ interactionEvent }: Props) => {
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
setErrorMessage(undefined);
}, []);
@ -21,7 +30,12 @@ const SetPassword = () => {
const { show } = usePromiseConfirmModal();
const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler();
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
const addPassword = useApi(updateProfile);
const handleError = useErrorHandler();
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
const preSignInErrorHandler = usePreSignInErrorHandler({ interactionEvent, replace: true });
const errorHandlers: ErrorHandlers = useMemo(
() => ({
@ -30,25 +44,36 @@ const SetPassword = () => {
navigate(-1);
},
...preSignInErrorHandler,
...passwordRejectionErrorHandler,
}),
[navigate, preSignInErrorHandler, show]
[navigate, passwordRejectionErrorHandler, preSignInErrorHandler, show]
);
const successHandler: SuccessHandler<typeof addProfile> = useCallback(
async (result) => {
const onSubmitHandler = useCallback(
async (password: string) => {
const success = await checkPassword(password);
if (!success) {
return;
}
const [error, result] = await addPassword(
{ type: 'password', value: password },
interactionEvent
);
if (error) {
await handleError(error, errorHandlers);
return;
}
if (result?.redirectTo) {
await redirectTo(result.redirectTo);
}
},
[redirectTo]
[addPassword, checkPassword, errorHandlers, interactionEvent, handleError, redirectTo]
);
const [action] = usePasswordAction({
api: async (password) => addProfile({ password }),
setErrorMessage,
errorHandlers,
successHandler,
});
const {
policy: {
length: { min, max },
@ -68,7 +93,7 @@ const SetPassword = () => {
errorMessage={errorMessage}
maxLength={max}
clearErrorMessage={clearErrorMessage}
onSubmit={action}
onSubmit={onSubmitHandler}
/>
</SecondaryPageLayout>
);

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
import { MissingProfile } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import { useLocation, useParams } from 'react-router-dom';
import { validate } from 'superstruct';
import ErrorPage from '@/pages/ErrorPage';
import { continueFlowStateGuard } from '@/types/guard';
import SetEmailOrPhone from './SetEmailOrPhone';
import SetPassword from './SetPassword';
@ -13,13 +15,22 @@ type Parameters = {
const Continue = () => {
const { method = '' } = useParams<Parameters>();
const { state } = useLocation();
const [, continueFlowState] = validate(state, continueFlowStateGuard);
if (!continueFlowState) {
return <ErrorPage title="error.invalid_session" rawMessage="flow state not found" />;
}
const { interactionEvent } = continueFlowState;
if (method === MissingProfile.password) {
return <SetPassword />;
return <SetPassword interactionEvent={interactionEvent} />;
}
if (method === MissingProfile.username) {
return <SetUsername />;
return <SetUsername interactionEvent={interactionEvent} />;
}
if (
@ -27,7 +38,7 @@ const Continue = () => {
method === MissingProfile.phone ||
method === MissingProfile.emailOrPhone
) {
return <SetEmailOrPhone missingProfile={method} />;
return <SetEmailOrPhone missingProfile={method} interactionEvent={interactionEvent} />;
}
return <ErrorPage />;

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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 {
SignInIdentifier,
MissingProfile,
InteractionEvent,
MfaFactor,
MissingProfile,
SignInIdentifier,
type SsoConnectorMetadata,
VerificationType,
} from '@logto/schemas';
import * as s from 'superstruct';
@ -81,12 +83,10 @@ export const totpBindingStateGuard = s.assign(
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
export const backupCodeErrorDataGuard = s.object({
export const backupCodeBindingStateGuard = s.object({
codes: s.array(s.string()),
});
export const backupCodeBindingStateGuard = backupCodeErrorDataGuard;
export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>;
export const webAuthnStateGuard = s.assign(
@ -130,3 +130,29 @@ export const identifierInputValueGuard: s.Describe<IdentifierInputValue> = s.obj
* Type guard for the `identifier` search param config on the identifier sign-in/register page.
*/
export const identifierSearchParamGuard = s.array(identifierEnumGuard);
type StringGuard = ReturnType<typeof s.string>;
// eslint-disable-next-line no-restricted-syntax -- Object.fromEntries can not infer the key type
const mapGuard = Object.fromEntries(
Object.values(VerificationType).map((type) => [type, s.string()])
) as { [key in VerificationType]: StringGuard };
/**
* Defines the type guard for the verification ids map.
*/
export const verificationIdsMapGuard = s.partial(mapGuard);
export type VerificationIdsMap = s.Infer<typeof verificationIdsMapGuard>;
/**
* Define the interaction event state guard.
*
* This is used to pass the current interaction event state to the continue flow page.
*
* - If is in the sign in flow, directly call the submitInteraction endpoint after the user completes the profile.
* - If is in the register flow, we need to call the identify endpoint first after the user completes the profile.
*/
export const continueFlowStateGuard = s.object({
interactionEvent: s.enums([InteractionEvent.SignIn, InteractionEvent.Register]),
});
export type InteractionFlowState = s.Infer<typeof continueFlowStateGuard>;

View file

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

View file

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