diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index 8e02cfc5b..e9b632a5a 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -67,11 +67,22 @@ const translation = { reset_password_description_sms: 'Gib die Telefonnummer deines Kontos ein und wir senden dir einen Bestätigungscode um dein Passwort zurückzusetzen.', new_password: 'Neues Passwort', + set_password: 'Set password', // UNTRANSLATED password_changed: 'Passwort geändert', no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED }, error: { username_password_mismatch: 'Benutzername oder Passwort ist falsch', diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index a683a43d3..4652c2824 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -63,11 +63,22 @@ const translation = { reset_password_description_sms: 'Enter the phone number associated with your account, and we’ll message you the verification code to reset your password.', new_password: 'New password', + set_password: 'Set password', password_changed: 'Password Changed', no_account: "Don't have an account?", have_account: 'Already have an account?', enter_password: 'Enter Password', enter_password_for: 'Enter the password of {{method}} {{value}}', + enter_username: 'Enter username', + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', + link_email: 'Link email', + link_phone: 'Link phone', + link_email_or_phone: 'Link email or phone', + link_email_description: 'Link your email to sign in or help with account recovery.', + link_phone_description: 'Link your phone number to sign in or help with account recovery.', + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', }, 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 4e99304cb..4d6ceac22 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -67,11 +67,22 @@ 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', + set_password: 'Set password', // UNTRANSLATED password_changed: 'Password Changed', // UNTRANSLATED no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // 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.ts b/packages/phrases-ui/src/locales/ko.ts index 5f0019c93..1b6564c18 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -63,11 +63,22 @@ const translation = { reset_password_description_sms: '계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.', new_password: '새 비밀번호', + set_password: 'Set password', // UNTRANSLATED password_changed: 'Password Changed', // UNTRANSLATED no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // 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 87cab0aa1..bedec68ea 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -63,11 +63,22 @@ 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', + set_password: 'Set password', // UNTRANSLATED password_changed: 'Password Changed', // UNTRANSLATED no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // 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 69b3bef36..5d06b12b4 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -64,11 +64,22 @@ 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', + set_password: 'Set password', // UNTRANSLATED password_changed: 'Password Changed', // UNTRANSLATED no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // 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 3b002b6e8..82e29803b 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -61,11 +61,22 @@ const translation = { reset_password_description_email: '输入邮件地址,领取验证码以重设密码。', reset_password_description_sms: '输入手机号,领取验证码以重设密码。', new_password: '新密码', + set_password: 'Set password', // UNTRANSLATED password_changed: '已重置密码!', no_account: "Don't have an account?", // UNTRANSLATED have_account: 'Already have an account?', // UNTRANSLATED enter_password: 'Enter Password', // UNTRANSLATED enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED + enter_username: 'Enter username', // UNTRANSLATED + enter_username_description: + 'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED + link_email: 'Link email', // UNTRANSLATED + link_phone: 'Link phone', // UNTRANSLATED + link_email_or_phone: 'Link email or phone', // UNTRANSLATED + link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED + link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED + link_email_or_phone_description: + 'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED }, error: { username_password_mismatch: '用户名和密码不匹配', diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 51d8e2d53..a757fbf55 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -9,6 +9,7 @@ import usePreview from './hooks/use-preview'; import initI18n from './i18n/init'; import Callback from './pages/Callback'; import Consent from './pages/Consent'; +import Continue from './pages/Continue'; import ErrorPage from './pages/ErrorPage'; import ForgotPassword from './pages/ForgotPassword'; import Passcode from './pages/Passcode'; @@ -67,7 +68,7 @@ const App = () => { /> }> - {/* sign-in */} + {/* Sign-in */} : } @@ -76,7 +77,7 @@ const App = () => { } /> } /> - {/* register */} + {/* Register */} : } @@ -87,17 +88,19 @@ const App = () => { /> } /> - {/* forgot password */} + {/* Forgot password */} } /> } /> - {/* social sign-in pages */} + {/* Continue set up missing profile */} + } /> + {/* Social sign-in pages */} } /> } /> } /> - {/* always keep route path with param as the last one */} + {/* Always keep route path with param as the last one */} } /> diff --git a/packages/ui/src/apis/api.test.ts b/packages/ui/src/apis/api.test.ts deleted file mode 100644 index 9a9430553..000000000 --- a/packages/ui/src/apis/api.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { SignInIdentifier } from '@logto/schemas'; - -import { UserFlow } from '@/types'; - -import { - verifyForgotPasswordEmailPasscode, - verifyForgotPasswordSmsPasscode, -} from './forgot-password'; -import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register'; -import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in'; -import { getVerifyPasscodeApi } from './utils'; - -describe('api', () => { - it('getVerifyPasscodeApi', () => { - expect(getVerifyPasscodeApi(UserFlow.register, SignInIdentifier.Sms)).toBe( - verifyRegisterSmsPasscode - ); - expect(getVerifyPasscodeApi(UserFlow.register, SignInIdentifier.Email)).toBe( - verifyRegisterEmailPasscode - ); - expect(getVerifyPasscodeApi(UserFlow.signIn, SignInIdentifier.Sms)).toBe( - verifySignInSmsPasscode - ); - expect(getVerifyPasscodeApi(UserFlow.signIn, SignInIdentifier.Email)).toBe( - verifySignInEmailPasscode - ); - expect(getVerifyPasscodeApi(UserFlow.forgotPassword, SignInIdentifier.Email)).toBe( - verifyForgotPasswordEmailPasscode - ); - expect(getVerifyPasscodeApi(UserFlow.forgotPassword, SignInIdentifier.Sms)).toBe( - verifyForgotPasswordSmsPasscode - ); - }); -}); diff --git a/packages/ui/src/apis/continue.test.ts b/packages/ui/src/apis/continue.test.ts index 02653081a..54b9c8539 100644 --- a/packages/ui/src/apis/continue.test.ts +++ b/packages/ui/src/apis/continue.test.ts @@ -1,10 +1,12 @@ +import { PasscodeType } from '@logto/schemas'; import ky from 'ky'; import { - continueWithPassword, - continueWithUsername, - continueWithEmail, - continueWithPhone, + continueApi, + sendContinueSetEmailPasscode, + sendContinueSetPhonePasscode, + verifyContinueSetEmailPasscode, + verifyContinueSetSmsPasscode, } from './continue'; jest.mock('ky', () => ({ @@ -30,8 +32,8 @@ describe('continue API', () => { }); it('continue with password', async () => { - await continueWithPassword('password'); - expect(ky.post).toBeCalledWith('/api/session/continue/password', { + await continueApi('password', 'password'); + expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/password', { json: { password: 'password', }, @@ -39,8 +41,8 @@ describe('continue API', () => { }); it('continue with username', async () => { - await continueWithUsername('username'); - expect(ky.post).toBeCalledWith('/api/session/continue/username', { + await continueApi('username', 'username'); + expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/username', { json: { username: 'username', }, @@ -48,9 +50,9 @@ describe('continue API', () => { }); it('continue with email', async () => { - await continueWithEmail('email'); + await continueApi('email', 'email'); - expect(ky.post).toBeCalledWith('/api/session/continue/email', { + expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/email', { json: { email: 'email', }, @@ -58,12 +60,58 @@ describe('continue API', () => { }); it('continue with phone', async () => { - await continueWithPhone('phone'); + await continueApi('phone', 'phone'); - expect(ky.post).toBeCalledWith('/api/session/continue/sms', { + expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/sms', { json: { phone: 'phone', }, }); }); + + it('sendContinueSetEmailPasscode', async () => { + await sendContinueSetEmailPasscode('email'); + + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', { + json: { + email: 'email', + flow: PasscodeType.Continue, + }, + }); + }); + + it('sendContinueSetSmsPasscode', async () => { + await sendContinueSetPhonePasscode('111111'); + + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', { + json: { + phone: '111111', + flow: PasscodeType.Continue, + }, + }); + }); + + it('verifyContinueSetEmailPasscode', async () => { + await verifyContinueSetEmailPasscode('email', 'passcode'); + + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', { + json: { + email: 'email', + code: 'passcode', + flow: PasscodeType.Continue, + }, + }); + }); + + it('verifyContinueSetSmsPasscode', async () => { + await verifyContinueSetSmsPasscode('phone', 'passcode'); + + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', { + json: { + phone: 'phone', + code: 'passcode', + flow: PasscodeType.Continue, + }, + }); + }); }); diff --git a/packages/ui/src/apis/continue.ts b/packages/ui/src/apis/continue.ts index 8a4c385fc..4300c518e 100644 --- a/packages/ui/src/apis/continue.ts +++ b/packages/ui/src/apis/continue.ts @@ -1,3 +1,5 @@ +import { PasscodeType } from '@logto/schemas'; + import api from './api'; import { bindSocialAccount } from './social'; @@ -5,13 +7,15 @@ type Response = { redirectTo: string; }; -const continueApiPrefix = '/api/session/continue'; +const passwordlessApiPrefix = '/api/session/passwordless'; +const continueApiPrefix = '/api/session/sign-in/continue'; -// Only bind with social after the sign-in bind password flow -export const continueWithPassword = async (password: string, socialToBind?: string) => { +type ContinueKey = 'password' | 'username' | 'email' | 'phone'; + +export const continueApi = async (key: ContinueKey, value: string, socialToBind?: string) => { const result = await api - .post(`${continueApiPrefix}/password`, { - json: { password }, + .post(`${continueApiPrefix}/${key === 'phone' ? 'sms' : key}`, { + json: { [key]: value }, }) .json(); @@ -22,11 +26,48 @@ export const continueWithPassword = async (password: string, socialToBind?: stri return result; }; -export const continueWithUsername = async (username: string) => - api.post(`${continueApiPrefix}/username`, { json: { username } }).json(); +export const sendContinueSetEmailPasscode = async (email: string) => { + await api + .post(`${passwordlessApiPrefix}/email/send`, { + json: { + email, + flow: PasscodeType.Continue, + }, + }) + .json(); -export const continueWithEmail = async (email: string) => - api.post(`${continueApiPrefix}/email`, { json: { email } }).json(); + return { success: true }; +}; -export const continueWithPhone = async (phone: string) => - api.post(`${continueApiPrefix}/sms`, { json: { phone } }).json(); +export const sendContinueSetPhonePasscode = async (phone: string) => { + await api + .post(`${passwordlessApiPrefix}/sms/send`, { + json: { + phone, + flow: PasscodeType.Continue, + }, + }) + .json(); + + return { success: true }; +}; + +export const verifyContinueSetEmailPasscode = async (email: string, code: string) => { + await api + .post(`${passwordlessApiPrefix}/email/verify`, { + json: { email, code, flow: PasscodeType.Continue }, + }) + .json(); + + return { success: true }; +}; + +export const verifyContinueSetSmsPasscode = async (phone: string, code: string) => { + await api + .post(`${passwordlessApiPrefix}/sms/verify`, { + json: { phone, code, flow: PasscodeType.Continue }, + }) + .json(); + + return { success: true }; +}; diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts index a2bbd0c5d..ca091245a 100644 --- a/packages/ui/src/apis/utils.ts +++ b/packages/ui/src/apis/utils.ts @@ -2,27 +2,15 @@ import { SignInIdentifier } from '@logto/schemas'; import { UserFlow } from '@/types'; -import { - sendForgotPasswordEmailPasscode, - sendForgotPasswordSmsPasscode, - verifyForgotPasswordEmailPasscode, - verifyForgotPasswordSmsPasscode, -} from './forgot-password'; -import { - verifyRegisterEmailPasscode, - verifyRegisterSmsPasscode, - sendRegisterEmailPasscode, - sendRegisterSmsPasscode, -} from './register'; -import { - verifySignInEmailPasscode, - verifySignInSmsPasscode, - sendSignInEmailPasscode, - sendSignInSmsPasscode, -} from './sign-in'; +import { sendContinueSetEmailPasscode, sendContinueSetPhonePasscode } from './continue'; +import { sendForgotPasswordEmailPasscode, sendForgotPasswordSmsPasscode } from './forgot-password'; +import { sendRegisterEmailPasscode, sendRegisterSmsPasscode } from './register'; +import { sendSignInEmailPasscode, sendSignInSmsPasscode } from './sign-in'; export type PasscodeChannel = SignInIdentifier.Email | SignInIdentifier.Sms; +// TODO: @simeng-li merge in to one single api + export const getSendPasscodeApi = ( type: UserFlow, method: PasscodeChannel @@ -47,36 +35,13 @@ export const getSendPasscodeApi = ( return sendRegisterEmailPasscode; } - return sendRegisterSmsPasscode; -}; - -export const getVerifyPasscodeApi = ( - type: UserFlow, - method: PasscodeChannel -): (( - _address: string, - code: string, - socialToBind?: string -) => Promise<{ redirectTo?: string; success?: boolean }>) => { - if (type === UserFlow.forgotPassword && method === SignInIdentifier.Email) { - return verifyForgotPasswordEmailPasscode; - } - - if (type === UserFlow.forgotPassword && method === SignInIdentifier.Sms) { - return verifyForgotPasswordSmsPasscode; - } - - if (type === UserFlow.signIn && method === SignInIdentifier.Email) { - return verifySignInEmailPasscode; - } - - if (type === UserFlow.signIn && method === SignInIdentifier.Sms) { - return verifySignInSmsPasscode; - } - - if (type === UserFlow.register && method === SignInIdentifier.Email) { - return verifyRegisterEmailPasscode; - } - - return verifyRegisterSmsPasscode; + if (type === UserFlow.register && method === SignInIdentifier.Sms) { + return sendRegisterSmsPasscode; + } + + if (type === UserFlow.continue && method === SignInIdentifier.Email) { + return sendContinueSetEmailPasscode; + } + + return sendContinueSetPhonePasscode; }; diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx new file mode 100644 index 000000000..b803f28e5 --- /dev/null +++ b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, waitFor, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { sendContinueSetEmailPasscode } from '@/apis/continue'; + +import EmailContinue from './EmailContinue'; + +const mockedNavigate = jest.fn(); + +jest.mock('@/apis/continue', () => ({ + sendContinueSetEmailPasscode: jest.fn(() => ({ success: true })), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + +describe('EmailContinue', () => { + const email = 'foo@logto.io'; + + test('register form submit', async () => { + const { container, getByText } = renderWithPageContext( + + + + ); + const emailInput = container.querySelector('input[name="email"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: email } }); + } + + const submitButton = getByText('action.continue'); + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(sendContinueSetEmailPasscode).toBeCalledWith(email); + expect(mockedNavigate).toBeCalledWith( + { pathname: '/continue/email/passcode-validation', search: '' }, + { state: { email } } + ); + }); + }); +}); diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.tsx new file mode 100644 index 000000000..41b1fc29d --- /dev/null +++ b/packages/ui/src/containers/EmailForm/EmailContinue.tsx @@ -0,0 +1,32 @@ +import { SignInIdentifier } from '@logto/schemas'; + +import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code'; +import { UserFlow } from '@/types'; + +import EmailForm from './EmailForm'; + +type Props = { + className?: string; + // eslint-disable-next-line react/boolean-prop-naming + autoFocus?: boolean; + hasSwitch?: boolean; +}; + +const EmailContinue = (props: Props) => { + const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode( + UserFlow.continue, + SignInIdentifier.Email + ); + + return ( + + ); +}; + +export default EmailContinue; diff --git a/packages/ui/src/containers/EmailForm/EmailForm.tsx b/packages/ui/src/containers/EmailForm/EmailForm.tsx index 9c943045a..d8d7a13ae 100644 --- a/packages/ui/src/containers/EmailForm/EmailForm.tsx +++ b/packages/ui/src/containers/EmailForm/EmailForm.tsx @@ -83,6 +83,7 @@ const EmailForm = ({ {...rest} onClear={() => { setFieldValue((state) => ({ ...state, email: '' })); + clearErrorMessage?.(); }} /> {errorMessage && {errorMessage}} diff --git a/packages/ui/src/containers/EmailForm/index.tsx b/packages/ui/src/containers/EmailForm/index.tsx index ca22f8aae..5e7fc372b 100644 --- a/packages/ui/src/containers/EmailForm/index.tsx +++ b/packages/ui/src/containers/EmailForm/index.tsx @@ -1,3 +1,4 @@ export { default as EmailRegister } from './EmailRegister'; export { default as EmailSignIn } from './EmailSignIn'; export { default as EmailResetPassword } from './EmailResetPassword'; +export { default as EmailContinue } from './EmailContinue'; diff --git a/packages/ui/src/containers/EmailPassword/index.test.tsx b/packages/ui/src/containers/EmailPassword/index.test.tsx index 2eb143706..88aece908 100644 --- a/packages/ui/src/containers/EmailPassword/index.test.tsx +++ b/packages/ui/src/containers/EmailPassword/index.test.tsx @@ -13,6 +13,10 @@ jest.mock('@/apis/sign-in', () => ({ signInWithEmailPassword: jest.fn(async () = jest.mock('react-device-detect', () => ({ isMobile: true, })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); describe('', () => { afterEach(() => { diff --git a/packages/ui/src/containers/PasscodeValidation/index.test.tsx b/packages/ui/src/containers/PasscodeValidation/index.test.tsx index 35978c4ac..7903ecf5e 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.test.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.test.tsx @@ -2,6 +2,11 @@ import { SignInIdentifier } from '@logto/schemas'; import { act, fireEvent, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { + verifyContinueSetEmailPasscode, + continueApi, + verifyContinueSetSmsPasscode, +} from '@/apis/continue'; import { verifyForgotPasswordEmailPasscode, verifyForgotPasswordSmsPasscode, @@ -42,6 +47,12 @@ jest.mock('@/apis/forgot-password', () => ({ verifyForgotPasswordSmsPasscode: jest.fn(), })); +jest.mock('@/apis/continue', () => ({ + verifyContinueSetEmailPasscode: jest.fn(), + verifyContinueSetSmsPasscode: jest.fn(), + continueApi: jest.fn(), +})); + describe('', () => { const email = 'foo@logto.io'; const phone = '18573333333'; @@ -234,36 +245,98 @@ describe('', () => { expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true }); }); }); + + it('fire sms forgot-password validate passcode event', async () => { + (verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({ + success: true, + })); + + const { container } = renderWithPageContext( + + ); + + const inputs = container.querySelectorAll('input'); + + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + await waitFor(() => { + expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111'); + }); + + await waitFor(() => { + expect(window.location.replace).not.toBeCalled(); + expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true }); + }); + }); }); - it('fire Sms forgot-password validate passcode event', async () => { - (verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({ - success: true, - })); + describe('continue flow', () => { + it('set email', async () => { + (verifyContinueSetEmailPasscode as jest.Mock).mockImplementationOnce(() => ({ + success: true, + })); + (continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' })); - const { container } = renderWithPageContext( - - ); + const { container } = renderWithPageContext( + + ); - const inputs = container.querySelectorAll('input'); + const inputs = container.querySelectorAll('input'); - for (const input of inputs) { - act(() => { - fireEvent.input(input, { target: { value: '1' } }); + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + await waitFor(() => { + expect(verifyContinueSetEmailPasscode).toBeCalledWith(email, '111111'); }); - } - await waitFor(() => { - expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111'); + await waitFor(() => { + expect(continueApi).toBeCalledWith('email', email, undefined); + expect(window.location.replace).toBeCalledWith('/redirect'); + }); }); - await waitFor(() => { - expect(window.location.replace).not.toBeCalled(); - expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true }); + it('set Phone', async () => { + (verifyContinueSetSmsPasscode as jest.Mock).mockImplementationOnce(() => ({ + success: true, + })); + (continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' })); + + const { container } = renderWithPageContext( + + ); + + const inputs = container.querySelectorAll('input'); + + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + await waitFor(() => { + expect(verifyContinueSetSmsPasscode).toBeCalledWith(phone, '111111'); + }); + + await waitFor(() => { + expect(continueApi).toBeCalledWith('phone', phone, undefined); + expect(window.location.replace).toBeCalledWith('/redirect'); + }); }); }); }); diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts new file mode 100644 index 000000000..9bdab246d --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts @@ -0,0 +1,75 @@ +import { SignInIdentifier } from '@logto/schemas'; +import { useMemo, useCallback } from 'react'; + +import { verifyContinueSetEmailPasscode, continueApi } from '@/apis/continue'; +import type { ErrorHandlers } from '@/hooks/use-api'; +import useApi from '@/hooks/use-api'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import { UserFlow, SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; + +import useIdentifierErrorAlert from './use-identifier-error-alert'; +import useSharedErrorHandler from './use-shared-error-handler'; + +const useContinueSetEmailPasscodeValidation = (email: string, errorCallback?: () => void) => { + const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler(); + + const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true); + + const identifierNotExistErrorHandler = useIdentifierErrorAlert( + UserFlow.continue, + SignInIdentifier.Email, + email + ); + + const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo( + () => ({ + ...sharedErrorHandlers, + callback: errorCallback, + }), + [errorCallback, sharedErrorHandlers] + ); + + const { run: verifyPasscode } = useApi( + verifyContinueSetEmailPasscode, + verifyPasscodeErrorHandlers + ); + + const setEmailErrorHandlers: ErrorHandlers = useMemo( + () => ({ + 'user.email_not_exists': identifierNotExistErrorHandler, + ...requiredProfileErrorHandler, + callback: errorCallback, + }), + [errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler] + ); + + const { run: setEmail } = useApi(continueApi, setEmailErrorHandlers); + + const onSubmit = useCallback( + async (code: string) => { + const verified = await verifyPasscode(email, code); + + if (!verified) { + return; + } + + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + + const result = await setEmail('email', email, socialToBind); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, + [email, setEmail, verifyPasscode] + ); + + return { + errorMessage, + clearErrorMessage, + onSubmit, + }; +}; + +export default useContinueSetEmailPasscodeValidation; diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts new file mode 100644 index 000000000..4156bf4f6 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts @@ -0,0 +1,72 @@ +import { SignInIdentifier } from '@logto/schemas'; +import { useMemo, useCallback } from 'react'; + +import { verifyContinueSetSmsPasscode, continueApi } from '@/apis/continue'; +import type { ErrorHandlers } from '@/hooks/use-api'; +import useApi from '@/hooks/use-api'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import { UserFlow, SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; + +import useIdentifierErrorAlert from './use-identifier-error-alert'; +import useSharedErrorHandler from './use-shared-error-handler'; + +const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => { + const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler(); + + const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true); + + const identifierNotExistErrorHandler = useIdentifierErrorAlert( + UserFlow.continue, + SignInIdentifier.Sms, + phone + ); + + const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo( + () => ({ + ...sharedErrorHandlers, + callback: errorCallback, + }), + [errorCallback, sharedErrorHandlers] + ); + + const { run: verifyPasscode } = useApi(verifyContinueSetSmsPasscode, verifyPasscodeErrorHandlers); + + const setPhoneErrorHandlers: ErrorHandlers = useMemo( + () => ({ + 'user.phone_not_exists': identifierNotExistErrorHandler, + ...requiredProfileErrorHandler, + callback: errorCallback, + }), + [errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler] + ); + + const { run: setPhone } = useApi(continueApi, setPhoneErrorHandlers); + + const onSubmit = useCallback( + async (code: string) => { + const verified = await verifyPasscode(phone, code); + + if (!verified) { + return; + } + + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + + const result = await setPhone('phone', phone, socialToBind); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, + [phone, setPhone, verifyPasscode] + ); + + return { + errorMessage, + clearErrorMessage, + onSubmit, + }; +}; + +export default useContinueSetSmsPasscodeValidation; diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts index 17e35ced0..9cbd7f0b6 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts @@ -8,6 +8,7 @@ import { signInWithEmail } from '@/apis/sign-in'; import type { ErrorHandlers } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow } from '@/types'; @@ -30,6 +31,8 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: ( email ); + const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true); + const emailExistSignInErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.sign_in', @@ -59,12 +62,14 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: ( ? identifierExistErrorHandler : emailExistSignInErrorHandler, ...sharedErrorHandlers, + ...requiredProfileErrorHandlers, callback: errorCallback, }), [ emailExistSignInErrorHandler, errorCallback, identifierExistErrorHandler, + requiredProfileErrorHandlers, sharedErrorHandlers, signInMode, ] diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts index 293793635..2f273213f 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts @@ -8,6 +8,7 @@ import { signInWithSms } from '@/apis/sign-in'; import type { ErrorHandlers } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; @@ -30,6 +31,8 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: () formatPhoneNumberWithCountryCallingCode(phone) ); + const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true); + const phoneExistSignInErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.sign_in', @@ -59,14 +62,16 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: () ? identifierExistErrorHandler : phoneExistSignInErrorHandler, ...sharedErrorHandlers, + ...requiredProfileErrorHandlers, callback: errorCallback, }), [ - phoneExistSignInErrorHandler, - errorCallback, - identifierExistErrorHandler, - sharedErrorHandlers, signInMode, + identifierExistErrorHandler, + phoneExistSignInErrorHandler, + sharedErrorHandlers, + requiredProfileErrorHandlers, + errorCallback, ] ); diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts index 94de5d7c4..a93ed0d8e 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts @@ -8,6 +8,7 @@ import { verifySignInEmailPasscode } from '@/apis/sign-in'; import type { ErrorHandlers } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow, SearchParameters } from '@/types'; import { getSearchParameters } from '@/utils'; @@ -33,6 +34,8 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: () email ); + const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true); + const emailNotExistRegisterErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.create', @@ -63,12 +66,14 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: () ? identifierNotExistErrorHandler : emailNotExistRegisterErrorHandler, ...sharedErrorHandlers, + ...requiredProfileErrorHandlers, callback: errorCallback, }), [ emailNotExistRegisterErrorHandler, errorCallback, identifierNotExistErrorHandler, + requiredProfileErrorHandlers, sharedErrorHandlers, signInMode, socialToBind, diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts index 15d5f9af7..1d5cddac3 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts @@ -8,6 +8,7 @@ import { verifySignInSmsPasscode } from '@/apis/sign-in'; import type { ErrorHandlers } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import { UserFlow, SearchParameters } from '@/types'; import { getSearchParameters } from '@/utils'; @@ -33,6 +34,8 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () => phone ); + const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true); + const phoneNotExistRegisterErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.create', @@ -63,15 +66,17 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () => ? identifierNotExistErrorHandler : phoneNotExistRegisterErrorHandler, ...sharedErrorHandlers, + ...requiredProfileErrorHandlers, callback: errorCallback, }), [ - phoneNotExistRegisterErrorHandler, - errorCallback, - identifierNotExistErrorHandler, - sharedErrorHandlers, signInMode, socialToBind, + identifierNotExistErrorHandler, + phoneNotExistRegisterErrorHandler, + sharedErrorHandlers, + requiredProfileErrorHandlers, + errorCallback, ] ); diff --git a/packages/ui/src/containers/PasscodeValidation/utils.ts b/packages/ui/src/containers/PasscodeValidation/utils.ts index 8d243bd8b..5260eb2ab 100644 --- a/packages/ui/src/containers/PasscodeValidation/utils.ts +++ b/packages/ui/src/containers/PasscodeValidation/utils.ts @@ -2,6 +2,8 @@ import { SignInIdentifier } from '@logto/schemas'; import { UserFlow } from '@/types'; +import useContinueSetEmailPasscodeValidation from './use-continue-set-email-passcode-validation'; +import useContinueSetSmsPasscodeValidation from './use-continue-set-sms-passcode-validation'; import useForgotPasswordEmailPasscodeValidation from './use-forgot-password-email-passcode-validation'; import useForgotPasswordSmsPasscodeValidation from './use-forgot-password-sms-passcode-validation'; import useRegisterWithEmailPasscodeValidation from './use-register-with-email-passcode-validation'; @@ -27,9 +29,8 @@ export const getPasscodeValidationHook = ( ? useForgotPasswordEmailPasscodeValidation : useForgotPasswordSmsPasscodeValidation; default: - // TODO: continue flow hook return method === SignInIdentifier.Email - ? useRegisterWithEmailPasscodeValidation - : useRegisterWithSmsPasscodeValidation; + ? useContinueSetEmailPasscodeValidation + : useContinueSetSmsPasscodeValidation; } }; diff --git a/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx b/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx new file mode 100644 index 000000000..01dafa97e --- /dev/null +++ b/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, waitFor, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { sendContinueSetPhonePasscode } from '@/apis/continue'; +import { getDefaultCountryCallingCode } from '@/utils/country-code'; + +import SmsContinue from './SmsContinue'; + +const mockedNavigate = jest.fn(); + +// PhoneNum CountryCode detection +jest.mock('i18next', () => ({ + language: 'en', +})); + +jest.mock('@/apis/continue', () => ({ + sendContinueSetPhonePasscode: jest.fn(() => ({ success: true })), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + +describe('SmsContinue', () => { + const phone = '8573333333'; + const defaultCountryCallingCode = getDefaultCountryCallingCode(); + const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`; + + test('register form submit', async () => { + const { container, getByText } = renderWithPageContext( + + + + ); + const phoneInput = container.querySelector('input[name="phone"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phone } }); + } + + const submitButton = getByText('action.continue'); + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(sendContinueSetPhonePasscode).toBeCalledWith(fullPhoneNumber); + expect(mockedNavigate).toBeCalledWith( + { pathname: '/continue/sms/passcode-validation', search: '' }, + { state: { phone: fullPhoneNumber } } + ); + }); + }); +}); diff --git a/packages/ui/src/containers/PhoneForm/SmsContinue.tsx b/packages/ui/src/containers/PhoneForm/SmsContinue.tsx new file mode 100644 index 000000000..79e236057 --- /dev/null +++ b/packages/ui/src/containers/PhoneForm/SmsContinue.tsx @@ -0,0 +1,32 @@ +import { SignInIdentifier } from '@logto/schemas'; + +import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code'; +import { UserFlow } from '@/types'; + +import SmsForm from './PhoneForm'; + +type Props = { + className?: string; + // eslint-disable-next-line react/boolean-prop-naming + autoFocus?: boolean; + hasSwitch?: boolean; +}; + +const SmsContinue = (props: Props) => { + const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode( + UserFlow.continue, + SignInIdentifier.Sms + ); + + return ( + + ); +}; + +export default SmsContinue; diff --git a/packages/ui/src/containers/PhoneForm/index.tsx b/packages/ui/src/containers/PhoneForm/index.tsx index 0db986e9f..de6d4bb60 100644 --- a/packages/ui/src/containers/PhoneForm/index.tsx +++ b/packages/ui/src/containers/PhoneForm/index.tsx @@ -1,3 +1,4 @@ export { default as SmsRegister } from './SmsRegister'; export { default as SmsSignIn } from './SmsSignIn'; export { default as SmsResetPassword } from './SmsResetPassword'; +export { default as SmsContinue } from './SmsContinue'; diff --git a/packages/ui/src/containers/PhonePassword/index.test.tsx b/packages/ui/src/containers/PhonePassword/index.test.tsx index 4dc7ea7e3..fe7ba72ac 100644 --- a/packages/ui/src/containers/PhonePassword/index.test.tsx +++ b/packages/ui/src/containers/PhonePassword/index.test.tsx @@ -18,6 +18,10 @@ jest.mock('react-device-detect', () => ({ jest.mock('i18next', () => ({ language: 'en', })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); describe('', () => { afterEach(() => { diff --git a/packages/ui/src/containers/UsernameForm/SetUsername/index.test.tsx b/packages/ui/src/containers/UsernameForm/SetUsername/index.test.tsx new file mode 100644 index 000000000..b1bf35959 --- /dev/null +++ b/packages/ui/src/containers/UsernameForm/SetUsername/index.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, act, waitFor } from '@testing-library/react'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { continueApi } from '@/apis/continue'; + +import SetUsername from '.'; + +const mockedNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + +jest.mock('@/apis/continue', () => ({ + continueApi: jest.fn(async () => ({})), +})); + +describe('', () => { + test('default render', () => { + const { queryByText, container } = renderWithPageContext(); + expect(container.querySelector('input[name="new-username"]')).not.toBeNull(); + expect(queryByText('action.continue')).not.toBeNull(); + }); + + test('submit form properly', async () => { + const { getByText, container } = renderWithPageContext(); + const submitButton = getByText('action.continue'); + const usernameInput = container.querySelector('input[name="new-username"]'); + + if (usernameInput) { + fireEvent.change(usernameInput, { target: { value: 'username' } }); + } + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(continueApi).toBeCalledWith('username', 'username', undefined); + }); + }); +}); diff --git a/packages/ui/src/containers/UsernameForm/SetUsername/index.tsx b/packages/ui/src/containers/UsernameForm/SetUsername/index.tsx new file mode 100644 index 000000000..e353bbf62 --- /dev/null +++ b/packages/ui/src/containers/UsernameForm/SetUsername/index.tsx @@ -0,0 +1,23 @@ +import UsernameForm from '../UsernameForm'; +import useSetUsername from './use-set-username'; + +type Props = { + className?: string; +}; + +const SetUsername = ({ className }: Props) => { + const { errorMessage, clearErrorMessage, onSubmit } = useSetUsername(); + + return ( + + ); +}; + +export default SetUsername; diff --git a/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts b/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts new file mode 100644 index 000000000..7438e5c17 --- /dev/null +++ b/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts @@ -0,0 +1,50 @@ +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { continueApi } from '@/apis/continue'; +import useApi from '@/hooks/use-api'; +import type { ErrorHandlers } from '@/hooks/use-api'; +import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import { SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; + +const useSetUsername = () => { + const navigate = useNavigate(); + const [errorMessage, setErrorMessage] = useState(); + + const clearErrorMessage = useCallback(() => { + setErrorMessage(''); + }, []); + + const requiredProfileErrorHandler = useRequiredProfileErrorHandler(); + + const errorHandlers: ErrorHandlers = useMemo( + () => ({ + 'user.username_exists_register': (error) => { + setErrorMessage(error.message); + }, + ...requiredProfileErrorHandler, + }), + [requiredProfileErrorHandler] + ); + + const { result, run: setUsername } = useApi(continueApi, errorHandlers); + + const onSubmit = useCallback( + async (username: string) => { + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + await setUsername('username', username, socialToBind); + }, + [setUsername] + ); + + useEffect(() => { + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [navigate, result]); + + return { errorMessage, clearErrorMessage, onSubmit }; +}; + +export default useSetUsername; diff --git a/packages/ui/src/containers/UsernameRegister/index.test.tsx b/packages/ui/src/containers/UsernameForm/UsernameForm.test.tsx similarity index 68% rename from packages/ui/src/containers/UsernameRegister/index.test.tsx rename to packages/ui/src/containers/UsernameForm/UsernameForm.test.tsx index 1f5559d97..a2b729e35 100644 --- a/packages/ui/src/containers/UsernameRegister/index.test.tsx +++ b/packages/ui/src/containers/UsernameForm/UsernameForm.test.tsx @@ -2,49 +2,66 @@ import { fireEvent, act, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { checkUsername } from '@/apis/register'; -import UsernameRegister from '.'; +import UsernameForm from './UsernameForm'; -const mockedNavigate = jest.fn(); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockedNavigate, -})); - -jest.mock('@/apis/register', () => ({ - checkUsername: jest.fn(async () => ({})), -})); +const onSubmit = jest.fn(); +const onClearErrorMessage = jest.fn(); describe('', () => { - test('default render', () => { - const { queryByText, container } = renderWithPageContext(); + test('default render without terms', () => { + const { queryByText, container } = renderWithPageContext( + + + + ); + expect(container.querySelector('input[name="new-username"]')).not.toBeNull(); + expect(queryByText('description.terms_of_use')).toBeNull(); expect(queryByText('action.create_account')).not.toBeNull(); }); test('render with terms settings enabled', () => { const { queryByText } = renderWithPageContext( - + ); expect(queryByText('description.terms_of_use')).not.toBeNull(); }); + test('render with error message', () => { + const { queryByText, getByText } = renderWithPageContext( + + + + ); + expect(queryByText('error_message')).not.toBeNull(); + + const submitButton = getByText('action.create_account'); + fireEvent.click(submitButton); + + expect(onClearErrorMessage).toBeCalled(); + }); + test('username are required', () => { - const { queryByText, getByText } = renderWithPageContext(); + const { queryByText, getByText } = renderWithPageContext(); const submitButton = getByText('action.create_account'); fireEvent.click(submitButton); expect(queryByText('username_required')).not.toBeNull(); - expect(checkUsername).not.toBeCalled(); + expect(onSubmit).not.toBeCalled(); }); test('username with initial numeric char should throw', () => { - const { queryByText, getByText, container } = renderWithPageContext(); + const { queryByText, getByText, container } = renderWithPageContext( + + ); const submitButton = getByText('action.create_account'); const usernameInput = container.querySelector('input[name="new-username"]'); @@ -57,7 +74,7 @@ describe('', () => { expect(queryByText('username_should_not_start_with_number')).not.toBeNull(); - expect(checkUsername).not.toBeCalled(); + expect(onSubmit).not.toBeCalled(); // Clear error if (usernameInput) { @@ -68,7 +85,9 @@ describe('', () => { }); test('username with special character should throw', () => { - const { queryByText, getByText, container } = renderWithPageContext(); + const { queryByText, getByText, container } = renderWithPageContext( + + ); const submitButton = getByText('action.create_account'); const usernameInput = container.querySelector('input[name="new-username"]'); @@ -80,7 +99,7 @@ describe('', () => { expect(queryByText('username_valid_charset')).not.toBeNull(); - expect(checkUsername).not.toBeCalled(); + expect(onSubmit).not.toBeCalled(); // Clear error if (usernameInput) { @@ -93,7 +112,7 @@ describe('', () => { test('submit form properly with terms settings enabled', async () => { const { getByText, container } = renderWithPageContext( - + ); const submitButton = getByText('action.create_account'); @@ -111,7 +130,7 @@ describe('', () => { }); await waitFor(() => { - expect(checkUsername).toBeCalledWith('username'); + expect(onSubmit).toBeCalledWith('username'); }); }); }); diff --git a/packages/ui/src/containers/UsernameRegister/index.tsx b/packages/ui/src/containers/UsernameForm/UsernameForm.tsx similarity index 50% rename from packages/ui/src/containers/UsernameRegister/index.tsx rename to packages/ui/src/containers/UsernameForm/UsernameForm.tsx index 605e22256..8282064a7 100644 --- a/packages/ui/src/containers/UsernameRegister/index.tsx +++ b/packages/ui/src/containers/UsernameForm/UsernameForm.tsx @@ -1,24 +1,25 @@ -import { SignInIdentifier } from '@logto/schemas'; +import type { I18nKey } from '@logto/phrases-ui'; import classNames from 'classnames'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { checkUsername } from '@/apis/register'; import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; import Input from '@/components/Input'; import TermsOfUse from '@/containers/TermsOfUse'; -import type { ErrorHandlers } from '@/hooks/use-api'; -import useApi from '@/hooks/use-api'; import useForm from '@/hooks/use-form'; import useTerms from '@/hooks/use-terms'; -import { UserFlow } from '@/types'; import { usernameValidation } from '@/utils/field-validations'; import * as styles from './index.module.scss'; type Props = { className?: string; + hasTerms?: boolean; + onSubmit: (username: string) => Promise; + errorMessage?: string; + clearErrorMessage?: () => void; + submitText?: I18nKey; }; type FieldState = { @@ -29,57 +30,41 @@ const defaultState: FieldState = { username: '', }; -const UsernameRegister = ({ className }: Props) => { +const UsernameForm = ({ + className, + hasTerms = true, + onSubmit, + errorMessage, + submitText = 'action.create_account', + clearErrorMessage, +}: Props) => { const { t } = useTranslation(); const { termsValidation } = useTerms(); - const navigate = useNavigate(); const { fieldValue, setFieldValue, - setFieldErrors, register: fieldRegister, validateForm, } = useForm(defaultState); - const errorHandlers: ErrorHandlers = useMemo( - () => ({ - 'user.username_exists_register': () => { - setFieldErrors((state) => ({ - ...state, - username: 'username_exists', - })); - }, - }), - [setFieldErrors] - ); - - const { run: asyncCheckUsername } = useApi(checkUsername, errorHandlers); - const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { event?.preventDefault(); + clearErrorMessage?.(); + if (!validateForm()) { return; } - if (!(await termsValidation())) { + if (hasTerms && !(await termsValidation())) { return; } - const { username } = fieldValue; - - // Use sync call for this api to make sure the username value being passed to the password set page stays the same - const result = await asyncCheckUsername(username); - - if (result) { - navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`, { - state: { username }, - }); - } + void onSubmit(fieldValue.username); }, - [validateForm, termsValidation, fieldValue, asyncCheckUsername, navigate] + [clearErrorMessage, validateForm, hasTerms, termsValidation, onSubmit, fieldValue.username] ); return ( @@ -93,14 +78,15 @@ const UsernameRegister = ({ className }: Props) => { setFieldValue((state) => ({ ...state, username: '' })); }} /> + {errorMessage && {errorMessage}} - + {hasTerms && } -