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 && }
-