0
Fork 0
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:
simeng-li 2024-08-01 17:36:14 +08:00
parent ea41a79a83
commit 4f94b6df0e
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
12 changed files with 226 additions and 37 deletions

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

View file

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

View file

@ -34,7 +34,7 @@ const useErrorHandler = () => {
}
return;
} catch {
} catch (error) {
setToast(t('error.unknown'));
console.error(error);

View file

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

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

@ -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) {

View file

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