Skip to content

Commit

Permalink
refactor(experience): migrate the password register and sign-in
Browse files Browse the repository at this point in the history
migrate the password register and sign-in flow
  • Loading branch information
simeng-li committed Aug 8, 2024
1 parent ddfa7aa commit 88d7a5f
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 33 deletions.
72 changes: 72 additions & 0 deletions packages/experience/src/apis/experience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
type IdentificationApiPayload,
InteractionEvent,
type PasswordVerificationPayload,
SignInIdentifier,
type UpdateProfileApiPayload,
} 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>();

const updateProfile = async (payload: UpdateProfileApiPayload) => {
await api.post(experienceRoutes.profile, { json: payload });
};

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

return updateProfile({ type: SignInIdentifier.Username, value: username });
};

export const continueRegisterWithPassword = async (password: string) => {
await updateProfile({ type: 'password', value: password });

await identifyUser();

return submitInteraction();
};
2 changes: 1 addition & 1 deletion packages/experience/src/hooks/use-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const useErrorHandler = () => {
}

return;
} catch {
} catch (error) {
setToast(t('error.unknown'));
console.error(error);

Expand Down
32 changes: 32 additions & 0 deletions packages/experience/src/hooks/use-password-policy-checker.ts
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions packages/experience/src/hooks/use-password-rejection-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 7 additions & 5 deletions packages/experience/src/hooks/use-password-sign-in.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo } 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';
Expand All @@ -19,25 +19,25 @@ const useRegisterWithUsername = () => {
'user.username_already_in_use': (error) => {
setErrorMessage(error.message);
},
'user.missing_profile': () => {
navigate('password');
},
}),
[navigate]
[]
);

const handleError = useErrorHandler();
const asyncRegister = useApi(registerWithUsernamePassword);
const asyncRegister = useApi(registerWithUsername);

const onSubmit = useCallback(
async (username: string) => {
const [error] = await asyncRegister(username);

if (error) {
await handleError(error, errorHandlers);
return;
}

navigate('password');
},
[asyncRegister, errorHandlers, handleError]
[asyncRegister, errorHandlers, handleError, navigate]
);

return { errorMessage, clearErrorMessage, onSubmit };
Expand Down
46 changes: 30 additions & 16 deletions packages/experience/src/pages/RegisterPassword/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import { setUserPassword } from '@/apis/interaction';
import { continueRegisterWithPassword } 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 ErrorPage from '../ErrorPage';
Expand All @@ -25,7 +27,12 @@ const RegisterPassword = () => {
setErrorMessage(undefined);
}, []);

const checkPassword = usePasswordPolicyChecker({ setErrorMessage });
const asyncRegisterPassword = useApi(continueRegisterWithPassword);
const handleError = useErrorHandler();

const mfaErrorHandler = useMfaErrorHandler({ replace: true });
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });

const errorHandlers: ErrorHandlers = useMemo(
() => ({
Expand All @@ -35,26 +42,33 @@ 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) => {
const success = await checkPassword(password);

if (!success) {
return;
}

const [error, result] = await asyncRegisterPassword(password);

if (error) {
await handleError(error, errorHandlers);
return;
}

if (result) {
await redirectTo(result.redirectTo);
}
},
[redirectTo]
[asyncRegisterPassword, checkPassword, errorHandlers, handleError, redirectTo]
);

const [action] = usePasswordAction({
api: setUserPassword,
setErrorMessage,
errorHandlers,
successHandler,
});

const {
policy: {
length: { min, max },
Expand All @@ -78,7 +92,7 @@ const RegisterPassword = () => {
errorMessage={errorMessage}
maxLength={max}
clearErrorMessage={clearErrorMessage}
onSubmit={action}
onSubmit={onSubmitHandler}
/>
</SecondaryPageLayout>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
}

await onSubmit({
[type]: value,
identifier: { type, value },
password,
});
})(event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ describe('PasswordSignInForm', () => {
});

await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ [identifier]: value, password });
expect(signInWithPasswordIdentifier).toBeCalledWith({
identifier: {
type: identifier,
value,
},
password,
});
});

if (isVerificationCodeEnabled) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -77,7 +77,7 @@ const PasswordForm = ({
setIdentifierInputValue({ type, value });

await onSubmit({
[type]: value,
identifier: { type, value },
password,
});
})(event);
Expand Down

0 comments on commit 88d7a5f

Please sign in to comment.