0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(ui): implement continue profile fulfilling flow

implement continue profile fulfilling flow
This commit is contained in:
simeng-li 2022-11-10 19:01:58 +08:00
parent b466d10de0
commit 2acc6da741
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
56 changed files with 1504 additions and 211 deletions

View file

@ -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',

View file

@ -63,11 +63,22 @@ const translation = {
reset_password_description_sms:
'Enter the phone number associated with your account, and well 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',

View file

@ -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",

View file

@ -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: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

@ -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',

View file

@ -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.',

View file

@ -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: '用户名和密码不匹配',

View file

@ -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 = () => {
/>
<Route element={<LoadingLayerProvider />}>
{/* sign-in */}
{/* Sign-in */}
<Route
path="/sign-in"
element={isRegisterOnly ? <Navigate replace to="/register" /> : <SignIn />}
@ -76,7 +77,7 @@ const App = () => {
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/sign-in/:method/password" element={<SignInPassword />} />
{/* register */}
{/* Register */}
<Route
path="/register"
element={isSignInOnly ? <Navigate replace to="/sign-in" /> : <Register />}
@ -87,17 +88,19 @@ const App = () => {
/>
<Route path="/register/:method" element={<SecondaryRegister />} />
{/* forgot password */}
{/* Forgot password */}
<Route path="/forgot-password/reset" element={<ResetPassword />} />
<Route path="/forgot-password/:method" element={<ForgotPassword />} />
{/* social sign-in pages */}
{/* Continue set up missing profile */}
<Route path="/continue/:method" element={<Continue />} />
{/* Social sign-in pages */}
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social/register/:connector" element={<SocialRegister />} />
<Route path="/social/landing/:connector" element={<SocialLanding />} />
{/* always keep route path with param as the last one */}
{/* Always keep route path with param as the last one */}
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
</Route>

View file

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

View file

@ -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,
},
});
});
});

View file

@ -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<Response>();
@ -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<Response>();
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<Response>();
return { success: true };
};
export const continueWithPhone = async (phone: string) =>
api.post(`${continueApiPrefix}/sms`, { json: { phone } }).json<Response>();
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 };
};

View file

@ -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;
};

View file

@ -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(
<MemoryRouter>
<EmailContinue />
</MemoryRouter>
);
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 } }
);
});
});
});

View file

@ -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 (
<EmailForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
);
};
export default EmailContinue;

View file

@ -83,6 +83,7 @@ const EmailForm = ({
{...rest}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
clearErrorMessage?.();
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}

View file

@ -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';

View file

@ -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('<EmailPassword>', () => {
afterEach(() => {

View file

@ -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('<PasscodeValidation />', () => {
const email = 'foo@logto.io';
const phone = '18573333333';
@ -234,36 +245,98 @@ describe('<PasscodeValidation />', () => {
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(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Sms}
target={phone}
/>
);
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(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Sms}
target={phone}
/>
);
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.continue}
method={SignInIdentifier.Email}
target={email}
/>
);
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(
<PasscodeValidation type={UserFlow.continue} method={SignInIdentifier.Sms} target={phone} />
);
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');
});
});
});
});

View file

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

View file

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

View file

@ -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,
]

View file

@ -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,
]
);

View file

@ -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,

View file

@ -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,
]
);

View file

@ -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;
}
};

View file

@ -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(
<MemoryRouter>
<SmsContinue />
</MemoryRouter>
);
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 } }
);
});
});
});

View file

@ -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 (
<SmsForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
);
};
export default SmsContinue;

View file

@ -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';

View file

@ -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('<PhonePassword>', () => {
afterEach(() => {

View file

@ -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('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<SetUsername />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(<SetUsername />);
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);
});
});
});

View file

@ -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 (
<UsernameForm
className={className}
hasTerms={false}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
submitText="action.continue"
onSubmit={onSubmit}
/>
);
};
export default SetUsername;

View file

@ -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<string>();
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;

View file

@ -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('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameRegister />);
test('default render without terms', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
);
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(
<SettingsProvider>
<UsernameRegister />
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with error message', () => {
const { queryByText, getByText } = renderWithPageContext(
<SettingsProvider>
<UsernameForm
errorMessage="error_message"
clearErrorMessage={onClearErrorMessage}
onSubmit={onSubmit}
/>
</SettingsProvider>
);
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(<UsernameRegister />);
const { queryByText, getByText } = renderWithPageContext(<UsernameForm onSubmit={onSubmit} />);
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(<UsernameRegister />);
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
@ -57,7 +74,7 @@ describe('<UsernameRegister />', () => {
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('<UsernameRegister />', () => {
});
test('username with special character should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<UsernameRegister />);
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
@ -80,7 +99,7 @@ describe('<UsernameRegister />', () => {
expect(queryByText('username_valid_charset')).not.toBeNull();
expect(checkUsername).not.toBeCalled();
expect(onSubmit).not.toBeCalled();
// Clear error
if (usernameInput) {
@ -93,7 +112,7 @@ describe('<UsernameRegister />', () => {
test('submit form properly with terms settings enabled', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
@ -111,7 +130,7 @@ describe('<UsernameRegister />', () => {
});
await waitFor(() => {
expect(checkUsername).toBeCalledWith('username');
expect(onSubmit).toBeCalledWith('username');
});
});
});

View file

@ -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<void>;
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<HTMLFormElement>) => {
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 className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.create_account" onClick={async () => onSubmitHandler()} />
<Button title={submitText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default UsernameRegister;
export default UsernameForm;

View file

@ -0,0 +1,51 @@
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 '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/register', () => ({
checkUsername: jest.fn(async () => ({})),
}));
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameRegister />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(checkUsername).toBeCalledWith('username');
});
});
});

View file

@ -0,0 +1,21 @@
import UsernameForm from '../UsernameForm';
import useUsernameRegister from './use-username-register';
type Props = {
className?: string;
};
const UsernameRegister = ({ className }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useUsernameRegister();
return (
<UsernameForm
className={className}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
);
};
export default UsernameRegister;

View file

@ -0,0 +1,45 @@
import { SignInIdentifier } from '@logto/schemas';
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { checkUsername } from '@/apis/register';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-api';
import { UserFlow } from '@/types';
const useUsernameRegister = () => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': (error) => {
setErrorMessage(error.message);
},
}),
[]
);
const { run: asyncCheckUsername } = useApi(checkUsername, errorHandlers);
const onSubmit = useCallback(
async (username: string) => {
const result = await asyncCheckUsername(username);
if (result) {
navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`, {
state: { username },
});
}
},
[asyncCheckUsername, navigate]
);
return { errorMessage, clearErrorMessage, onSubmit };
};
export default useUsernameRegister;

View file

@ -11,4 +11,9 @@
.terms {
margin-bottom: _.unit(4);
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,2 @@
export { default as UsernameRegister } from './UsernameRegister';
export { default as SetUsername } from './SetUsername';

View file

@ -14,6 +14,10 @@ jest.mock('@/apis/sign-in', () => ({ signInWithUsername: jest.fn(async () => 0)
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('<UsernameSignIn>', () => {
afterEach(() => {

View file

@ -7,10 +7,20 @@ import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import useApi from '@/hooks/use-api';
import { bindSocialStateGuard } from '@/types/guard';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const useBindSocial = () => {
const { state } = useLocation();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
registerWithSocial,
requiredProfileErrorHandlers
);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(
bindSocialRelatedUser,
requiredProfileErrorHandlers
);
const createAccountHandler = useCallback(
(connectorId: string) => {

View file

@ -11,6 +11,8 @@ import useApi from '@/hooks/use-api';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const apiMap = {
[SignInIdentifier.Username]: signInWithUsername,
[SignInIdentifier.Email]: signInWithEmailPassword,
@ -24,13 +26,16 @@ const usePasswordSignIn = (method: SignInIdentifier) => {
setErrorMessage('');
}, []);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.invalid_credentials': (error) => {
setErrorMessage(error.message);
},
...requiredProfileErrorHandler,
}),
[setErrorMessage]
[requiredProfileErrorHandler]
);
const { result, run: asyncSignIn } = useApi(apiMap[method], errorHandlers);

View file

@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserFlow } from '@/types';
const useRequiredProfileErrorHandler = (replace?: boolean) => {
const navigate = useNavigate();
const requiredProfileErrorHandler = useMemo(
() => ({
'user.require_password': () => {
navigate(
{
pathname: `/${UserFlow.continue}/password`,
search: location.search,
},
{ replace }
);
},
'user.require_username': () => {
navigate(
{
pathname: `/${UserFlow.continue}/username`,
search: location.search,
},
{ replace }
);
},
'user.require_email': () => {
navigate(
{
pathname: `/${UserFlow.continue}/email`,
search: location.search,
},
{ replace }
);
},
'user.require_sms': () => {
navigate(
{
pathname: `/${UserFlow.continue}/sms`,
search: location.search,
},
{ replace }
);
},
'user.require_email_or_sms': () => {
navigate(
{
pathname: `/${UserFlow.continue}/email`,
search: location.search,
},
{ replace }
);
},
}),
[navigate, replace]
);
return requiredProfileErrorHandler;
};
export default useRequiredProfileErrorHandler;

View file

@ -10,9 +10,11 @@ import { stateValidation } from '@/utils/social-connectors';
import type { ErrorHandlers } from './use-api';
import useApi from './use-api';
import { PageContext } from './use-page-context';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const useSocialSignInListener = () => {
const { setToast, experienceSettings } = useContext(PageContext);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { t } = useTranslation();
const parameters = useParams();
@ -35,8 +37,15 @@ const useSocialSignInListener = () => {
});
}
},
...requiredProfileErrorHandlers,
}),
[experienceSettings?.signInMode, navigate, parameters.connector, setToast]
[
experienceSettings?.signInMode,
navigate,
parameters.connector,
requiredProfileErrorHandlers,
setToast,
]
);
const { result, run: asyncSignInWithSocial } = useApi(

View file

@ -0,0 +1,57 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetEmail from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetEmail', () => {
it('render set email', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Email] },
}}
>
<SetEmail />
</SettingsProvider>
);
expect(queryByText('description.link_email')).not.toBeNull();
expect(queryByText('description.link_email_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('render set email with phone alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Email, SignInIdentifier.Sms],
},
}}
>
<SetEmail />
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
});

View file

@ -0,0 +1,31 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailContinue } from '@/containers/EmailForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetEmail = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Email)) {
return <ErrorPage />;
}
const phoneSignUpAlteration = signUpMethods.includes(SignInIdentifier.Sms);
return (
<SecondaryPageWrapper
title={phoneSignUpAlteration ? 'description.link_email_or_phone' : 'description.link_email'}
description={
phoneSignUpAlteration
? 'description.link_email_or_phone_description'
: 'description.link_email_description'
}
>
<EmailContinue autoFocus hasSwitch={phoneSignUpAlteration} />
</SecondaryPageWrapper>
);
};
export default SetEmail;

View file

@ -0,0 +1,58 @@
import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { continueApi } from '@/apis/continue';
import SetPassword 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 () => ({ redirectTo: '/' })),
}));
describe('SetPassword', () => {
it('render set-password page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<SetPassword />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
it('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<SetPassword />
</SettingsProvider>
);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('password', '123456', undefined);
});
});
});

View file

@ -0,0 +1,25 @@
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import SetPasswordForm from '@/containers/SetPassword';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import useSetPassword from './use-set-password';
const SetPassword = () => {
const { setPassword } = useSetPassword();
const { signUpSettings } = useSieMethods();
// Password not enabled for sign-up identifiers
if (!signUpSettings.password) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper title="description.set_password">
<SetPasswordForm autoFocus onSubmit={setPassword} />
</SecondaryPageWrapper>
);
};
export default SetPassword;

View file

@ -0,0 +1,50 @@
import { useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { continueApi } from '@/apis/continue';
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 { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSetPassword = () => {
const navigate = useNavigate();
const { show } = useConfirmModal();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.password_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
...requiredProfileErrorHandler,
}),
[navigate, requiredProfileErrorHandler, show]
);
const { result, run: asyncSetPassword } = useApi(continueApi, errorHandlers);
const setPassword = useCallback(
async (password: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncSetPassword('password', password, socialToBind);
},
[asyncSetPassword]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, result]);
return {
setPassword,
};
};
export default useSetPassword;

View file

@ -0,0 +1,61 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetPhone from '.';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetPhone', () => {
it('render set phone', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Sms] },
}}
>
<SetPhone />
</SettingsProvider>
);
expect(queryByText('description.link_phone')).not.toBeNull();
expect(queryByText('description.link_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('render set phone with email alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Email, SignInIdentifier.Sms],
},
}}
>
<SetPhone />
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
});

View file

@ -0,0 +1,31 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { SmsContinue } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetPhone = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Sms)) {
return <ErrorPage />;
}
const emailSignUpAlteration = signUpMethods.includes(SignInIdentifier.Email);
return (
<SecondaryPageWrapper
title={emailSignUpAlteration ? 'description.link_email_or_phone' : 'description.link_phone'}
description={
emailSignUpAlteration
? 'description.link_email_or_phone_description'
: 'description.link_phone_description'
}
>
<SmsContinue autoFocus hasSwitch={emailSignUpAlteration} />
</SecondaryPageWrapper>
);
};
export default SetPhone;

View file

@ -0,0 +1,52 @@
import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
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 () => ({ redirectTo: '/' })),
}));
describe('SetPassword', () => {
it('render set-password page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<SetUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<SetUsername />
</SettingsProvider>
);
const submitButton = getByText('action.continue');
const usernameInput = container.querySelector('input[name="new-username"]');
act(() => {
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('username', 'username', undefined);
});
});
});

View file

@ -0,0 +1,25 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { SetUsername as SetUsernameForm } from '@/containers/UsernameForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetUsername = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Username)) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper
title="description.enter_username"
description="description.enter_username_description"
>
<SetUsernameForm />
</SecondaryPageWrapper>
);
};
export default SetUsername;

View file

@ -0,0 +1,37 @@
import { SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import ErrorPage from '@/pages/ErrorPage';
import SetEmail from './SetEmail';
import SetPassword from './SetPassword';
import SetPhone from './SetPhone';
import SetUsername from './SetUsername';
type Parameters = {
method?: string;
};
const Continue = () => {
const { method = '' } = useParams<Parameters>();
if (method === 'password') {
return <SetPassword />;
}
if (method === SignInIdentifier.Username) {
return <SetUsername />;
}
if (method === SignInIdentifier.Email) {
return <SetEmail />;
}
if (method === SignInIdentifier.Sms) {
return <SetPhone />;
}
return <ErrorPage />;
};
export default Continue;

View file

@ -3,7 +3,7 @@ import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailRegister } from '@/containers/EmailForm';
import { SmsRegister } from '@/containers/PhoneForm';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import { UsernameRegister } from '@/containers/UsernameForm';
import * as styles from './index.module.scss';

View file

@ -25,6 +25,14 @@ export const userFlowGuard = s.union([
s.literal('sign-in'),
s.literal('register'),
s.literal('forgot-password'),
s.literal('continue'),
]);
export const continueMethodGuard = s.union([
s.literal('password'),
s.literal('username'),
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Sms),
]);
export const usernameGuard = s.object({