From cdfaf8b1c7fd268f205e4679cfc762d7e3eedfea Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 26 Sep 2022 21:37:35 +0800 Subject: [PATCH 001/171] feat(console): auto detect language setting (#1941) --- .../components/LanguagesForm.tsx | 62 +++++++------------ .../components/index.module.scss | 6 ++ .../src/pages/SignInExperience/types.ts | 13 +--- .../src/pages/SignInExperience/utilities.ts | 23 +------ .../translation/admin-console/sign-in-exp.ts | 16 ++--- .../translation/admin-console/sign-in-exp.ts | 16 ++--- .../translation/admin-console/sign-in-exp.ts | 15 +++-- .../translation/admin-console/sign-in-exp.ts | 15 +++-- .../translation/admin-console/sign-in-exp.ts | 16 ++--- .../translation/admin-console/sign-in-exp.ts | 15 +++-- 10 files changed, 86 insertions(+), 111 deletions(-) diff --git a/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx b/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx index 3f87fc376..988f11af0 100644 --- a/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx +++ b/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx @@ -3,58 +3,40 @@ import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import FormField from '@/components/FormField'; -import RadioGroup, { Radio } from '@/components/RadioGroup'; import Select from '@/components/Select'; +import Switch from '@/components/Switch'; -import { LanguageMode, SignInExperienceForm } from '../types'; +import { SignInExperienceForm } from '../types'; import * as styles from './index.module.scss'; const LanguagesForm = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { watch, control } = useFormContext(); - const mode = watch('languageInfo.mode'); + const { watch, control, register } = useFormContext(); + const isAutoDetect = watch('languageInfo.autoDetect'); return ( <>
{t('sign_in_exp.others.languages.title')}
- - ( - - - - - )} + + - {mode === LanguageMode.Auto && ( - - ( - - )} - /> - - )} + + ( + + + )} diff --git a/packages/demo-app/package.json b/packages/demo-app/package.json index b5d348f0f..bdaa57787 100644 --- a/packages/demo-app/package.json +++ b/packages/demo-app/package.json @@ -17,10 +17,11 @@ "stylelint": "stylelint \"src/**/*.scss\"" }, "devDependencies": { + "@logto/core-kit": "^1.0.0-beta.13", + "@logto/language-kit": "1.0.0-beta.16", "@logto/phrases": "^1.0.0-beta.9", "@logto/react": "1.0.0-beta.8", "@logto/schemas": "^1.0.0-beta.9", - "@logto/core-kit": "^1.0.0-beta.13", "@parcel/core": "2.7.0", "@parcel/transformer-sass": "2.7.0", "@silverhand/eslint-config": "1.0.0", diff --git a/packages/demo-app/src/i18n/init.ts b/packages/demo-app/src/i18n/init.ts index b9fcb9546..df9f8850c 100644 --- a/packages/demo-app/src/i18n/init.ts +++ b/packages/demo-app/src/i18n/init.ts @@ -1,10 +1,10 @@ -import type { LanguageKey } from '@logto/core-kit'; +import type { LanguageTag } from '@logto/language-kit'; import resources from '@logto/phrases'; import i18next from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -const initI18n = async (language?: LanguageKey) => +const initI18n = async (language?: LanguageTag) => i18next .use(initReactI18next) .use(LanguageDetector) diff --git a/packages/demo-app/src/include.d/react-i18next.d.ts b/packages/demo-app/src/include.d/react-i18next.d.ts index 204b95f7f..9f69cc137 100644 --- a/packages/demo-app/src/include.d/react-i18next.d.ts +++ b/packages/demo-app/src/include.d/react-i18next.d.ts @@ -1,15 +1,12 @@ // https://react.i18next.com/latest/typescript#create-a-declaration-file -import { Translation, Errors } from '@logto/phrases'; +import { LocalPhrase } from '@logto/phrases'; // eslint-disable-next-line unused-imports/no-unused-imports import { CustomTypeOptions } from 'react-i18next'; declare module 'react-i18next' { interface CustomTypeOptions { allowObjectInHTMLChildren: true; - resources: { - translation: Translation; - errors: Errors; - }; + resources: LocalPhrase; } } diff --git a/packages/phrases/package.json b/packages/phrases/package.json index 3c6daf68b..6a854308e 100644 --- a/packages/phrases/package.json +++ b/packages/phrases/package.json @@ -29,9 +29,10 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "@logto/core-kit": "^1.0.0-beta.13", + "@logto/core-kit": "1.0.0-beta.16", "@logto/language-kit": "1.0.0-beta.15", - "@silverhand/essentials": "^1.2.1" + "@silverhand/essentials": "^1.2.1", + "zod": "^3.18.0" }, "devDependencies": { "@silverhand/eslint-config": "1.0.0", diff --git a/packages/phrases/src/index.ts b/packages/phrases/src/index.ts index 9d23593d7..d8f3b948e 100644 --- a/packages/phrases/src/index.ts +++ b/packages/phrases/src/index.ts @@ -1,4 +1,7 @@ +import { fallback } from '@logto/core-kit'; +import { languages, LanguageTag } from '@logto/language-kit'; import { NormalizeKeyPaths } from '@silverhand/essentials'; +import { z } from 'zod'; import en from './locales/en'; import fr from './locales/fr'; @@ -6,16 +9,37 @@ import koKR from './locales/ko-kr'; import ptPT from './locales/pt-pt'; import trTR from './locales/tr-tr'; import zhCN from './locales/zh-cn'; -import { Resource } from './types'; +import { LocalPhrase } from './types'; + +export type { LocalPhrase } from './types'; + +export type I18nKey = NormalizeKeyPaths; + +export const builtInLanguages = ['en', 'fr', 'pt-PT', 'zh-CN', 'ko-KR', 'tr-TR'] as const; + +export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({ + value: languageTag, + title: languages[languageTag], +})); + +export const builtInLanguageTagGuard = z.enum(builtInLanguages); + +export type BuiltInLanguageTag = z.infer; -export { languageOptions } from './types'; -export type Translation = typeof en.translation; export type Errors = typeof en.errors; export type LogtoErrorCode = NormalizeKeyPaths; export type LogtoErrorI18nKey = `errors:${LogtoErrorCode}`; -export type I18nKey = NormalizeKeyPaths; + export type AdminConsoleKey = NormalizeKeyPaths; +export const getDefaultLanguageTag = (languages: string): LanguageTag => + builtInLanguageTagGuard.or(fallback('en')).parse(languages); + +export const isBuiltInLanguageTag = (language: string): language is BuiltInLanguageTag => + builtInLanguageTagGuard.safeParse(language).success; + +export type Resource = Record; + const resource: Resource = { en, fr, diff --git a/packages/phrases/src/locales/fr/index.ts b/packages/phrases/src/locales/fr/index.ts index 79da482a7..f2dbf4643 100644 --- a/packages/phrases/src/locales/fr/index.ts +++ b/packages/phrases/src/locales/fr/index.ts @@ -1,8 +1,8 @@ -import en from '../en'; +import { LocalPhrase } from '../../types'; import errors from './errors'; import translation from './translation'; -const fr: typeof en = Object.freeze({ +const fr: LocalPhrase = Object.freeze({ translation, errors, }); diff --git a/packages/phrases/src/locales/ko-kr/index.ts b/packages/phrases/src/locales/ko-kr/index.ts index 144190b6a..0cc717165 100644 --- a/packages/phrases/src/locales/ko-kr/index.ts +++ b/packages/phrases/src/locales/ko-kr/index.ts @@ -1,8 +1,8 @@ -import en from '../en'; +import { LocalPhrase } from '../../types'; import errors from './errors'; import translation from './translation'; -const koKR: typeof en = Object.freeze({ +const koKR: LocalPhrase = Object.freeze({ translation, errors, }); diff --git a/packages/phrases/src/locales/pt-pt/index.ts b/packages/phrases/src/locales/pt-pt/index.ts index be517acf3..d05767c78 100644 --- a/packages/phrases/src/locales/pt-pt/index.ts +++ b/packages/phrases/src/locales/pt-pt/index.ts @@ -1,8 +1,8 @@ -import en from '../en'; +import { LocalPhrase } from '../../types'; import errors from './errors'; import translation from './translation'; -const ptPT: typeof en = Object.freeze({ +const ptPT: LocalPhrase = Object.freeze({ translation, errors, }); diff --git a/packages/phrases/src/locales/tr-tr/index.ts b/packages/phrases/src/locales/tr-tr/index.ts index a515674fa..d03b5324f 100644 --- a/packages/phrases/src/locales/tr-tr/index.ts +++ b/packages/phrases/src/locales/tr-tr/index.ts @@ -1,8 +1,8 @@ -import en from '../en'; +import { LocalPhrase } from '../../types'; import errors from './errors'; import translation from './translation'; -const trTR: typeof en = Object.freeze({ +const trTR: LocalPhrase = Object.freeze({ translation, errors, }); diff --git a/packages/phrases/src/locales/zh-cn/index.ts b/packages/phrases/src/locales/zh-cn/index.ts index a3d61151c..b1bf96f33 100644 --- a/packages/phrases/src/locales/zh-cn/index.ts +++ b/packages/phrases/src/locales/zh-cn/index.ts @@ -1,8 +1,8 @@ -import en from '../en'; +import { LocalPhrase } from '../../types'; import errors from './errors'; import translation from './translation'; -const zhCN: typeof en = Object.freeze({ +const zhCN: LocalPhrase = Object.freeze({ translation, errors, }); diff --git a/packages/phrases/src/types.ts b/packages/phrases/src/types.ts index 12e79dd92..d8cab9855 100644 --- a/packages/phrases/src/types.ts +++ b/packages/phrases/src/types.ts @@ -1,21 +1,3 @@ -import { LanguageKey, languageKeyGuard } from '@logto/core-kit'; +import en from './locales/en'; -/* Copied from i18next/index.d.ts */ -export type Resource = Record; - -export type ResourceLanguage = Record; - -export type ResourceKey = string | Record; - -const languageCodeAndDisplayNameMappings: Record = { - en: 'English', - fr: 'Français', - 'pt-PT': 'Português', - 'zh-CN': '简体中文', - 'tr-TR': 'Türkçe', - 'ko-KR': '한국어', -}; - -export const languageOptions: Array<{ value: LanguageKey; title: string }> = Object.entries( - languageCodeAndDisplayNameMappings -).map(([key, value]) => ({ value: languageKeyGuard.parse(key), title: value })); +export type LocalPhrase = typeof en; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8b97356d..afe8beb4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,7 @@ importers: packages/demo-app: specifiers: '@logto/core-kit': ^1.0.0-beta.13 + '@logto/language-kit': 1.0.0-beta.16 '@logto/phrases': ^1.0.0-beta.9 '@logto/react': 1.0.0-beta.8 '@logto/schemas': ^1.0.0-beta.9 @@ -344,6 +345,7 @@ importers: typescript: ^4.7.4 devDependencies: '@logto/core-kit': 1.0.0-beta.13 + '@logto/language-kit': 1.0.0-beta.16 '@logto/phrases': link:../phrases '@logto/react': 1.0.0-beta.8_react@18.2.0 '@logto/schemas': link:../schemas @@ -423,7 +425,7 @@ importers: packages/phrases: specifiers: - '@logto/core-kit': ^1.0.0-beta.13 + '@logto/core-kit': 1.0.0-beta.16 '@logto/language-kit': 1.0.0-beta.15 '@silverhand/eslint-config': 1.0.0 '@silverhand/essentials': ^1.2.1 @@ -432,10 +434,12 @@ importers: lint-staged: ^13.0.0 prettier: ^2.7.1 typescript: ^4.7.4 + zod: ^3.18.0 dependencies: - '@logto/core-kit': 1.0.0-beta.13 + '@logto/core-kit': 1.0.0-beta.16 '@logto/language-kit': 1.0.0-beta.15 '@silverhand/essentials': 1.2.1 + zod: 3.18.0 devDependencies: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@silverhand/ts-config': 1.0.0_typescript@4.7.4 @@ -2465,7 +2469,7 @@ packages: dependencies: '@logto/language-kit': 1.0.0-beta.16 color: 4.2.3 - nanoid: 3.1.30 + nanoid: 3.3.4 zod: 3.18.0 /@logto/js/1.0.0-beta.8: @@ -11288,6 +11292,7 @@ packages: resolution: {integrity: sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + dev: false /nanoid/3.3.1: resolution: {integrity: sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==} From afa2ac47ee461e3526f61594e456d484fd3166af Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 10 Oct 2022 09:51:18 +0800 Subject: [PATCH 049/171] feat(ui): add reset password error handling flow (#2079) --- packages/phrases-ui/src/locales/en.ts | 1 + packages/phrases-ui/src/locales/fr.ts | 1 + packages/phrases-ui/src/locales/ko-kr.ts | 1 + packages/phrases-ui/src/locales/pt-pt.ts | 1 + packages/phrases-ui/src/locales/tr-tr.ts | 1 + packages/phrases-ui/src/locales/zh-cn.ts | 1 + packages/ui/src/apis/forgot-password.ts | 21 ++++-- packages/ui/src/apis/utils.ts | 6 +- .../PasscodeValidation/index.test.tsx | 66 ++++++++++++++++++- .../containers/PasscodeValidation/index.tsx | 8 ++- .../Passwordless/PasswordlessSwitch.test.tsx | 7 +- .../Passwordless/PasswordlessSwitch.tsx | 9 ++- .../ResetPassword/index.module.scss | 5 ++ .../containers/ResetPassword/index.test.tsx | 7 ++ .../ui/src/containers/ResetPassword/index.tsx | 52 ++++++++++++--- 15 files changed, 165 insertions(+), 22 deletions(-) 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} + )}