diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index fc8f2f1e5..124b7c3b5 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -57,6 +57,7 @@ const translation = { reset_password_description_sms: 'Enter the phone number associated with your account, and we’ll text you the verification code to reset your password.', new_password: 'New password', + password_changed: 'Password Changed', }, error: { username_password_mismatch: 'Username and password do not match', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index f0b8432e2..50f55454d 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -61,6 +61,7 @@ const translation = { reset_password_description_sms: 'Entrez le numéro de téléphone associé à votre compte et nous vous enverrons le code de vérification par SMS pour réinitialiser votre mot de passe.', new_password: 'Nouveau mot de passe', + password_changed: 'Password Changed', // UNTRANSLATED }, error: { username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas", diff --git a/packages/phrases-ui/src/locales/ko-kr.ts b/packages/phrases-ui/src/locales/ko-kr.ts index caee9f4ee..17b8e280b 100644 --- a/packages/phrases-ui/src/locales/ko-kr.ts +++ b/packages/phrases-ui/src/locales/ko-kr.ts @@ -57,6 +57,7 @@ const translation = { reset_password_description_sms: '계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.', new_password: '새 비밀번호', + password_changed: 'Password Changed', // UNTRANSLATED }, error: { username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index 7cae28415..04458e132 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -57,6 +57,7 @@ const translation = { reset_password_description_sms: 'Digite o número de telefone associado à sua conta e enviaremos uma mensagem de texto com o código de verificação para redefinir sua senha.', new_password: 'Nova Senha', + password_changed: 'Password Changed', // UNTRANSLATED }, error: { username_password_mismatch: 'O Utilizador e a password não correspondem', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 94c3ecc19..e38861e1f 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -58,6 +58,7 @@ const translation = { reset_password_description_sms: 'Hesabınızla ilişkili telefon numarasını girin, şifrenizi sıfırlamak için size doğrulama kodunu kısa mesajla gönderelim.', new_password: 'Yeni Şifre', + password_changed: 'Password Changed', // UNTRANSLATED }, error: { username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index 3228f0965..a02eb2d9a 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -57,6 +57,7 @@ const translation = { reset_password_description_sms: '输入与你的帐户关联的电话号码,我们将向您发送验证码以重置你的密码。', new_password: '新密码', + password_changed: 'Password Changed', // UNTRANSLATED }, error: { username_password_mismatch: '用户名和密码不匹配', diff --git a/packages/ui/src/apis/forgot-password.ts b/packages/ui/src/apis/forgot-password.ts index b5d1daf3d..d464233e2 100644 --- a/packages/ui/src/apis/forgot-password.ts +++ b/packages/ui/src/apis/forgot-password.ts @@ -18,8 +18,8 @@ export const sendForgotPasswordSmsPasscode = async (phone: string) => { return { success: true }; }; -export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => - api +export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => { + await api .post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, { json: { phone, @@ -28,6 +28,9 @@ export const verifyForgotPasswordSmsPasscode = async (phone: string, code: strin }) .json(); + return { success: true }; +}; + export const sendForgotPasswordEmailPasscode = async (email: string) => { await api .post(`${forgotPasswordApiPrefix}/email/send-passcode`, { @@ -40,8 +43,8 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => { return { success: true }; }; -export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => - api +export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => { + await api .post(`${forgotPasswordApiPrefix}/email/verify-passcode`, { json: { email, @@ -50,9 +53,15 @@ export const verifyForgotPasswordEmailPasscode = async (email: string, code: str }) .json(); -export const resetPassword = async (password: string) => - api + return { success: true }; +}; + +export const resetPassword = async (password: string) => { + await api .post(`${forgotPasswordApiPrefix}/reset`, { json: { password }, }) .json(); + + return { success: true }; +}; diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts index 88247aa22..91ae6f784 100644 --- a/packages/ui/src/apis/utils.ts +++ b/packages/ui/src/apis/utils.ts @@ -51,7 +51,11 @@ export const getSendPasscodeApi = ( export const getVerifyPasscodeApi = ( type: UserFlow, method: PasscodeChannel -): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => { +): (( + _address: string, + code: string, + socialToBind?: string +) => Promise<{ redirectTo?: string; success?: boolean }>) => { if (type === 'forgot-password' && method === 'email') { return verifyForgotPasswordEmailPasscode; } diff --git a/packages/ui/src/containers/PasscodeValidation/index.test.tsx b/packages/ui/src/containers/PasscodeValidation/index.test.tsx index 73a478699..d7113d294 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.test.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.test.tsx @@ -23,13 +23,28 @@ jest.mock('@/apis/utils', () => ({ describe('', () => { const email = 'foo@logto.io'; + const originalLocation = window.location; + + beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + Object.defineProperty(window, 'location', { + configurable: true, + value: { replace: jest.fn() }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); afterAll(() => { jest.clearAllMocks(); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); }); it('render counter', () => { - const { queryByText, debug } = renderWithPageContext( + const { queryByText } = renderWithPageContext( ); @@ -74,4 +89,53 @@ describe('', () => { expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined); }); }); + + it('should redirect with success redirectUri response', async () => { + verifyPasscodeApi.mockImplementationOnce(() => ({ redirectTo: 'foo.com' })); + + const { container } = renderWithPageContext( + + ); + + const inputs = container.querySelectorAll('input'); + + await waitFor(() => { + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined); + }); + + await waitFor(() => { + expect(window.location.replace).toBeCalledWith('foo.com'); + }); + }); + + it('should redirect to reset password page if the flow is forgot-password', async () => { + verifyPasscodeApi.mockImplementationOnce(() => ({ success: true })); + + const { container } = renderWithPageContext( + + ); + + const inputs = container.querySelectorAll('input'); + + await waitFor(() => { + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined); + }); + + await waitFor(() => { + expect(window.location.replace).not.toBeCalled(); + expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true }); + }); + }); }); diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx index bc677cd1b..edcfa4913 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -95,8 +95,14 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { useEffect(() => { if (verifyPasscodeResult?.redirectTo) { window.location.replace(verifyPasscodeResult.redirectTo); + + return; } - }, [verifyPasscodeResult]); + + if (verifyPasscodeResult && type === 'forgot-password') { + navigate('/forgot-password/reset', { replace: true }); + } + }, [navigate, type, verifyPasscodeResult]); return (
diff --git a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx b/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx index 5fd406a28..ef17161ff 100644 --- a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx +++ b/packages/ui/src/containers/Passwordless/PasswordlessSwitch.test.tsx @@ -33,7 +33,10 @@ describe('', () => { const link = getByText('action.switch_to'); fireEvent.click(link); - expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/email' }); + expect(mockedNavigate).toBeCalledWith( + { pathname: '/forgot-password/email' }, + { replace: true } + ); }); test('render email passwordless switch', () => { @@ -50,7 +53,7 @@ describe('', () => { const link = getByText('action.switch_to'); fireEvent.click(link); - expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }); + expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }, { replace: true }); }); test('should not render the switch if SIE setting does not has the supported sign in method', () => { diff --git a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx b/packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx index dbdcb3026..065533473 100644 --- a/packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx +++ b/packages/ui/src/containers/Passwordless/PasswordlessSwitch.tsx @@ -33,9 +33,12 @@ const PasswordlessSwitch = ({ target, className }: Props) => { { - navigate({ - pathname: targetPathname, - }); + navigate( + { + pathname: targetPathname, + }, + { replace: true } + ); }} > {t('action.switch_to', { diff --git a/packages/ui/src/containers/ResetPassword/index.module.scss b/packages/ui/src/containers/ResetPassword/index.module.scss index 2fe7832d3..516780f91 100644 --- a/packages/ui/src/containers/ResetPassword/index.module.scss +++ b/packages/ui/src/containers/ResetPassword/index.module.scss @@ -10,4 +10,9 @@ .inputField { margin-bottom: _.unit(4); } + + .formErrors { + margin-top: _.unit(-2); + margin-bottom: _.unit(4); + } } diff --git a/packages/ui/src/containers/ResetPassword/index.test.tsx b/packages/ui/src/containers/ResetPassword/index.test.tsx index ef2d84f44..2705254c3 100644 --- a/packages/ui/src/containers/ResetPassword/index.test.tsx +++ b/packages/ui/src/containers/ResetPassword/index.test.tsx @@ -5,6 +5,13 @@ import { resetPassword } from '@/apis/forgot-password'; import ResetPassword from '.'; +const mockedNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + jest.mock('@/apis/forgot-password', () => ({ resetPassword: jest.fn(async () => ({ redirectTo: '/' })), })); diff --git a/packages/ui/src/containers/ResetPassword/index.tsx b/packages/ui/src/containers/ResetPassword/index.tsx index 8da55e2cc..4fe2a9e2f 100644 --- a/packages/ui/src/containers/ResetPassword/index.tsx +++ b/packages/ui/src/containers/ResetPassword/index.tsx @@ -1,12 +1,16 @@ import classNames from 'classnames'; -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useMemo, useContext } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { resetPassword } from '@/apis/forgot-password'; import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; import Input from '@/components/Input'; -import useApi from '@/hooks/use-api'; +import useApi, { ErrorHandlers } from '@/hooks/use-api'; +import { useConfirmModal } from '@/hooks/use-confirm-modal'; import useForm from '@/hooks/use-form'; +import { PageContext } from '@/hooks/use-page-context'; import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations'; import * as styles from './index.module.scss'; @@ -29,29 +33,58 @@ const defaultState: FieldState = { const ResetPassword = ({ className, autoFocus }: Props) => { const { t } = useTranslation(); + const { setToast } = useContext(PageContext); + const { + fieldValue, + formErrorMessage, + setFieldValue, + register, + validateForm, + setFormErrorMessage, + } = useForm(defaultState); + const { show } = useConfirmModal(); + const navigate = useNavigate(); - const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); + const resetPasswordErrorHandlers: ErrorHandlers = useMemo( + () => ({ + 'session.forgot_password_session_not_found': async (error) => { + await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); + navigate(-1); + }, + 'session.forgot_password_verification_expired': async (error) => { + await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); + navigate(-1); + }, + 'user.same_password': (error) => { + setFormErrorMessage(error.message); + }, + }), + [navigate, setFormErrorMessage, show] + ); - const { result, run: asyncRegister } = useApi(resetPassword); + const { result, run: asyncRegister } = useApi(resetPassword, resetPasswordErrorHandlers); const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { event?.preventDefault(); + setFormErrorMessage(undefined); + if (!validateForm()) { return; } void asyncRegister(fieldValue.password); }, - [validateForm, asyncRegister, fieldValue] + [setFormErrorMessage, validateForm, asyncRegister, fieldValue.password] ); useEffect(() => { - if (result?.redirectTo) { - window.location.replace(result.redirectTo); + if (result) { + setToast(t('description.password_changed')); + navigate('/sign-in', { replace: true }); } - }, [result]); + }, [navigate, result, setToast, t]); return ( @@ -80,6 +113,9 @@ const ResetPassword = ({ className, autoFocus }: Props) => { setFieldValue((state) => ({ ...state, confirmPassword: '' })); }} /> + {formErrorMessage && ( + {formErrorMessage} + )}