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