mirror of
https://github.com/logto-io/logto.git
synced 2025-02-03 21:48:55 -05:00
feat(experience): migrate the password sign-in flow
migrate the password sign-in and username-password registration flow
This commit is contained in:
parent
ea41a79a83
commit
4f94b6df0e
12 changed files with 226 additions and 37 deletions
83
packages/experience/src/apis/experience.ts
Normal file
83
packages/experience/src/apis/experience.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
type IdentificationApiPayload,
|
||||
InteractionEvent,
|
||||
type InteractionIdentifier,
|
||||
type PasswordVerificationPayload,
|
||||
SignInIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
const prefix = '/api/experience';
|
||||
|
||||
const experienceRoutes = Object.freeze({
|
||||
prefix,
|
||||
identification: `${prefix}/identification`,
|
||||
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(`${experienceRoutes.prefix}`, {
|
||||
json: {
|
||||
interactionEvent,
|
||||
},
|
||||
});
|
||||
|
||||
const identifyUser = async (payload: IdentificationApiPayload) =>
|
||||
api.post(experienceRoutes.identification, { json: payload });
|
||||
|
||||
const submitInteraction = async () =>
|
||||
api.post(`${experienceRoutes.prefix}/submit`).json<SubmitInteractionResponse>();
|
||||
|
||||
export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => {
|
||||
await initInteraction(InteractionEvent.SignIn);
|
||||
|
||||
const { verificationId } = await api
|
||||
.post(`${experienceRoutes.verification}/password`, {
|
||||
json: payload,
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
await identifyUser({ verificationId });
|
||||
|
||||
return submitInteraction();
|
||||
};
|
||||
|
||||
export const registerWithUsername = async (username: string) => {
|
||||
await initInteraction(InteractionEvent.Register);
|
||||
|
||||
// Expect to throw
|
||||
await api.post(`${experienceRoutes.verification}/new-password-identity`, {
|
||||
json: {
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const registerPassword = async (identifier: InteractionIdentifier, password: string) => {
|
||||
const { verificationId } = await api
|
||||
.post(`${experienceRoutes.verification}/new-password-identity`, {
|
||||
json: {
|
||||
identifier,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.json<VerificationResponse>();
|
||||
|
||||
await identifyUser({ verificationId });
|
||||
|
||||
return submitInteraction();
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import useUpdateEffect from '@/hooks/use-update-effect';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
|
|
@ -34,7 +34,7 @@ const useErrorHandler = () => {
|
|||
}
|
||||
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setToast(t('error.unknown'));
|
||||
console.error(error);
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { type PasswordRejectionCode, type PasswordIssue } from '@logto/core-kit';
|
||||
import { type PasswordIssue, type PasswordRejectionCode } from '@logto/core-kit';
|
||||
import { type RequestErrorBody } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
32
packages/experience/src/hooks/use-password-policy-checker.ts
Normal file
32
packages/experience/src/hooks/use-password-policy-checker.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import usePasswordErrorMessage from './use-password-error-message';
|
||||
import { usePasswordPolicy } from './use-sie';
|
||||
|
||||
type PasswordPolicyCheckProps = {
|
||||
setErrorMessage: (message?: string) => void;
|
||||
};
|
||||
|
||||
const usePasswordPolicyChecker = ({ setErrorMessage }: PasswordPolicyCheckProps) => {
|
||||
const { getErrorMessage } = usePasswordErrorMessage();
|
||||
const { policyChecker } = usePasswordPolicy();
|
||||
|
||||
const checkPassword = useCallback(
|
||||
async (password: string) => {
|
||||
// Perform fast check before sending request
|
||||
const fastCheckErrorMessage = getErrorMessage(policyChecker.fastCheck(password));
|
||||
|
||||
if (fastCheckErrorMessage) {
|
||||
setErrorMessage(fastCheckErrorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[getErrorMessage, policyChecker, setErrorMessage]
|
||||
);
|
||||
|
||||
return checkPassword;
|
||||
};
|
||||
|
||||
export default usePasswordPolicyChecker;
|
|
@ -0,0 +1,31 @@
|
|||
import { type RequestErrorBody } from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import usePasswordErrorMessage from './use-password-error-message';
|
||||
|
||||
type ErrorHandlerProps = {
|
||||
setErrorMessage: (message?: string) => void;
|
||||
};
|
||||
|
||||
const usePasswordRejectionErrorHandler = ({ setErrorMessage }: ErrorHandlerProps) => {
|
||||
const { getErrorMessageFromBody } = usePasswordErrorMessage();
|
||||
|
||||
const passwordRejectionHandler = useCallback(
|
||||
(error: RequestErrorBody) => {
|
||||
setErrorMessage(getErrorMessageFromBody(error));
|
||||
},
|
||||
[getErrorMessageFromBody, setErrorMessage]
|
||||
);
|
||||
|
||||
const passwordRejectionErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'password.rejected': passwordRejectionHandler,
|
||||
}),
|
||||
[passwordRejectionHandler]
|
||||
);
|
||||
|
||||
return passwordRejectionErrorHandler;
|
||||
};
|
||||
|
||||
export default usePasswordRejectionErrorHandler;
|
|
@ -1,7 +1,7 @@
|
|||
import { SignInIdentifier, type PasswordVerificationPayload } from '@logto/schemas';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { PasswordSignInPayload } from '@/apis/interaction';
|
||||
import { signInWithPasswordIdentifier } from '@/apis/interaction';
|
||||
import { signInWithPasswordIdentifier } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
|
@ -34,10 +34,12 @@ const usePasswordSignIn = () => {
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (payload: PasswordSignInPayload) => {
|
||||
async (payload: PasswordVerificationPayload) => {
|
||||
const { identifier } = payload;
|
||||
|
||||
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
|
||||
if (payload.email) {
|
||||
const result = await checkSingleSignOn(payload.email);
|
||||
if (identifier.type === SignInIdentifier.Email) {
|
||||
const result = await checkSingleSignOn(identifier.value);
|
||||
|
||||
if (result) {
|
||||
return;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerWithUsernamePassword } from '@/apis/interaction';
|
||||
import { registerWithUsername } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
|
@ -19,7 +19,7 @@ const useRegisterWithUsername = () => {
|
|||
'user.username_already_in_use': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
'user.missing_profile': () => {
|
||||
'user.password_required_in_profile': () => {
|
||||
navigate('password');
|
||||
},
|
||||
}),
|
||||
|
@ -27,7 +27,7 @@ const useRegisterWithUsername = () => {
|
|||
);
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
const asyncRegister = useApi(registerWithUsernamePassword);
|
||||
const asyncRegister = useApi(registerWithUsername);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (username: string) => {
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import { setUserPassword } from '@/apis/interaction';
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { registerPassword } from '@/apis/experience';
|
||||
import SetPassword from '@/containers/SetPassword';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import useMfaErrorHandler from '@/hooks/use-mfa-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, useSieMethods } from '@/hooks/use-sie';
|
||||
import useToast from '@/hooks/use-toast';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
|
||||
|
@ -24,8 +29,15 @@ const RegisterPassword = () => {
|
|||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage(undefined);
|
||||
}, []);
|
||||
const { setToast } = useToast();
|
||||
|
||||
const { identifierInputValue } = useContext(UserInteractionContext);
|
||||
const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
|
||||
const asyncRegisterPassword = useApi(registerPassword);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const mfaErrorHandler = useMfaErrorHandler({ replace: true });
|
||||
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
@ -35,26 +47,49 @@ const RegisterPassword = () => {
|
|||
navigate(-1);
|
||||
},
|
||||
...mfaErrorHandler,
|
||||
...passwordRejectionErrorHandler,
|
||||
}),
|
||||
[navigate, mfaErrorHandler, show]
|
||||
[mfaErrorHandler, passwordRejectionErrorHandler, show, navigate]
|
||||
);
|
||||
|
||||
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
|
||||
async (result) => {
|
||||
if (result && 'redirectTo' in result) {
|
||||
const onSubmitHandler = useCallback(
|
||||
async (password: string) => {
|
||||
if (!identifierInputValue?.type) {
|
||||
setToast(t('error.invalid_session'));
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await checkPassword(password);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, value } = identifierInputValue;
|
||||
const [error, result] = await asyncRegisterPassword({ type, value }, password);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[redirectTo]
|
||||
[
|
||||
asyncRegisterPassword,
|
||||
checkPassword,
|
||||
errorHandlers,
|
||||
handleError,
|
||||
identifierInputValue,
|
||||
navigate,
|
||||
redirectTo,
|
||||
setToast,
|
||||
]
|
||||
);
|
||||
|
||||
const [action] = usePasswordAction({
|
||||
api: setUserPassword,
|
||||
setErrorMessage,
|
||||
errorHandlers,
|
||||
successHandler,
|
||||
});
|
||||
|
||||
const {
|
||||
policy: {
|
||||
length: { min, max },
|
||||
|
@ -78,7 +113,7 @@ const RegisterPassword = () => {
|
|||
errorMessage={errorMessage}
|
||||
maxLength={max}
|
||||
clearErrorMessage={clearErrorMessage}
|
||||
onSubmit={action}
|
||||
onSubmit={onSubmitHandler}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import LockIcon from '@/assets/icons/lock.svg?react';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import { SmartInputField, PasswordInputField } from '@/components/InputFields';
|
||||
import { PasswordInputField, SmartInputField } from '@/components/InputFields';
|
||||
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
|
||||
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
|
||||
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
|
||||
|
@ -80,7 +80,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
}
|
||||
|
||||
await onSubmit({
|
||||
[type]: value,
|
||||
identifier: { type, value },
|
||||
password,
|
||||
});
|
||||
})(event);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import {
|
||||
signInWithPasswordIdentifier,
|
||||
putInteraction,
|
||||
sendVerificationCode,
|
||||
signInWithPasswordIdentifier,
|
||||
} from '@/apis/interaction';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
|
@ -71,7 +71,13 @@ describe('PasswordSignInForm', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({ [identifier]: value, password });
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({
|
||||
identifier: {
|
||||
type: identifier,
|
||||
value,
|
||||
},
|
||||
password,
|
||||
});
|
||||
});
|
||||
|
||||
if (isVerificationCodeEnabled) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
|
@ -77,7 +77,7 @@ const PasswordForm = ({
|
|||
setIdentifierInputValue({ type, value });
|
||||
|
||||
await onSubmit({
|
||||
[type]: value,
|
||||
identifier: { type, value },
|
||||
password,
|
||||
});
|
||||
})(event);
|
||||
|
|
Loading…
Add table
Reference in a new issue