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

refactor(ui): refactor password & passwordless sign-in flow (#3076)

This commit is contained in:
simeng-li 2023-02-14 10:29:03 +08:00 committed by GitHub
parent 40a40be17c
commit 9c7bd2d0a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1032 additions and 1281 deletions

View file

@ -18,7 +18,6 @@ import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername';
import Register from './pages/Register';
import ResetPassword from './pages/ResetPassword';
import SecondaryRegister from './pages/SecondaryRegister';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SocialLanding from './pages/SocialLanding';
@ -63,52 +62,57 @@ const App = () => {
<AppBoundary>
<Routes>
<Route element={<AppContent />}>
<Route path="/" element={<Navigate replace to="/sign-in" />} />
<Route path="/sign-in/consent" element={<Consent />} />
<Route index element={<Navigate replace to="/sign-in" />} />
<Route
path="/unknown-session"
path="unknown-session"
element={<ErrorPage message="error.invalid_session" />}
/>
<Route path="sign-in/consent" element={<Consent />} />
<Route element={<LoadingLayerProvider />}>
{/* Sign-in */}
<Route
path="/sign-in"
element={isRegisterOnly ? <Navigate replace to="/register" /> : <SignIn />}
/>
<Route path="/sign-in/social/:connectorId" element={<SocialSignIn />} />
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/sign-in/:method/password" element={<SignInPassword />} />
<Route path="sign-in">
<Route
index
element={isRegisterOnly ? <Navigate replace to="/register" /> : <SignIn />}
/>
<Route path="password" element={<SignInPassword />} />
<Route path="social/:connectorId" element={<SocialSignIn />} />
</Route>
{/* Register */}
<Route
path="/register"
element={isSignInOnly ? <Navigate replace to="/sign-in" /> : <Register />}
/>
<Route
path="/register/username/password"
element={<PasswordRegisterWithUsername />}
/>
<Route path="/register/:method" element={<SecondaryRegister />} />
<Route path="register">
<Route
index
element={isSignInOnly ? <Navigate replace to="/sign-in" /> : <Register />}
/>
<Route path="username/password" element={<PasswordRegisterWithUsername />} />
<Route path=":method" element={<SecondaryRegister />} />
</Route>
{/* Forgot password */}
<Route path="/forgot-password/reset" element={<ResetPassword />} />
<Route path="/forgot-password/:method" element={<ForgotPassword />} />
<Route path="forgot-password">
<Route path="reset" element={<ResetPassword />} />
<Route path=":method" element={<ForgotPassword />} />
</Route>
{/* Continue set up missing profile */}
<Route
path="/continue/email-or-phone/:method"
element={<ContinueWithEmailOrPhone />}
/>
<Route path="/continue/:method" element={<Continue />} />
<Route path="continue">
<Route path="email-or-phone/:method" element={<ContinueWithEmailOrPhone />} />
<Route path=":method" element={<Continue />} />
</Route>
{/* Passwordless verification code */}
<Route path=":flow/verification-code" element={<VerificationCode />} />
{/* Social sign-in pages */}
<Route path="/callback/:connectorId" element={<Callback />} />
<Route path="/social/link/:connectorId" element={<SocialLinkAccount />} />
<Route path="/social/landing/:connectorId" element={<SocialLanding />} />
{/* Always keep route path with param as the last one */}
<Route path="/:type/:method/verification-code" element={<VerificationCode />} />
<Route path="social">
<Route path="link/:connectorId" element={<SocialLinkAccount />} />
<Route path="landing/:connectorId" element={<SocialLanding />} />
</Route>
<Route path="callback/:connectorId" element={<Callback />} />
</Route>
<Route path="*" element={<ErrorPage />} />

View file

@ -1,4 +1,4 @@
import type { SignInExperience } from '@logto/schemas';
import type { SignInExperience, SignIn } from '@logto/schemas';
import {
BrandingStyle,
ConnectorPlatform,
@ -11,6 +11,7 @@ import type { SignInExperienceResponse } from '@/types';
export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4';
export const appHeadline = 'Build user identity in a modern way';
export const socialConnectors = [
{
id: 'BE8QXN0VsrOH7xdWFDJZ9',
@ -230,3 +231,58 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
phone: true,
},
};
const usernameSettings = {
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
};
export const mockSignInMethodSettingsTestCases: Array<SignIn['methods']> = [
[
usernameSettings,
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
},
{
identifier: SignInIdentifier.Phone,
password: true,
verificationCode: true,
isPasswordPrimary: true,
},
],
[
usernameSettings,
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: false,
},
{
identifier: SignInIdentifier.Phone,
password: true,
verificationCode: false,
isPasswordPrimary: false,
},
],
[
usernameSettings,
{
identifier: SignInIdentifier.Email,
password: false,
verificationCode: true,
isPasswordPrimary: false,
},
{
identifier: SignInIdentifier.Phone,
password: false,
verificationCode: true,
isPasswordPrimary: false,
},
],
];

View file

@ -61,7 +61,10 @@ export const setUserPassword = async (password: string) => {
return result || { success: true };
};
export type SendVerificationCodePayload = { email: string } | { phone: string };
export type SendVerificationCodePayload = {
email?: string;
phone?: string;
};
export const putInteraction = async (event: InteractionEvent) =>
api.put(`${interactionPrefix}`, { json: { event } });

View file

@ -5,20 +5,22 @@ import { UserFlow } from '@/types';
import type { SendVerificationCodePayload } from './interaction';
import { putInteraction, sendVerificationCode } from './interaction';
export const getSendVerificationCodeApi =
(type: UserFlow) => async (payload: SendVerificationCodePayload) => {
if (type === UserFlow.forgotPassword) {
await putInteraction(InteractionEvent.ForgotPassword);
}
/** Move to API */
export const sendVerificationCodeApi = async (
type: UserFlow,
payload: SendVerificationCodePayload
) => {
if (type === UserFlow.forgotPassword) {
await putInteraction(InteractionEvent.ForgotPassword);
}
// Init a new interaction only if the user is not binding with a social
if (type === UserFlow.signIn) {
await putInteraction(InteractionEvent.SignIn);
}
if (type === UserFlow.signIn) {
await putInteraction(InteractionEvent.SignIn);
}
if (type === UserFlow.register) {
await putInteraction(InteractionEvent.Register);
}
if (type === UserFlow.register) {
await putInteraction(InteractionEvent.Register);
}
return sendVerificationCode(payload);
};
return sendVerificationCode(payload);
};

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import { maskEmail } from '@/utils/format';

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import EmailForm from './EmailForm';

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import EmailForm from './EmailForm';

View file

@ -1,170 +0,0 @@
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendVerificationCode, putInteraction } from '@/apis/interaction';
import EmailSignIn from './EmailSignIn';
const mockedNavigate = jest.fn();
jest.mock('@/apis/interaction', () => ({
sendVerificationCode: jest.fn(() => ({ success: true })),
putInteraction: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('EmailSignIn', () => {
const email = 'foo@logto.io';
afterEach(() => {
jest.clearAllMocks();
});
test('EmailSignIn form with password as primary method', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password true but verification code false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password true but not primary, verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: false,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code', search: '' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password false but primary verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: false,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code', search: '' },
{ state: { email } }
);
});
});
});

View file

@ -1,60 +0,0 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import useContinueSignInWithPassword from '@/hooks/use-continue-sign-in-with-password';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { ArrayElement } from '@/types';
import { UserFlow } from '@/types';
import EmailForm from './EmailForm';
type FormProps = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
type Props = FormProps & {
signInMethod: ArrayElement<SignIn['methods']>;
};
const EmailSignInWithVerificationCode = (props: FormProps) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.signIn,
SignInIdentifier.Email
);
return (
<EmailForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.sign_in"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
const EmailSignInWithPassword = (props: FormProps) => {
const onSubmit = useContinueSignInWithPassword(SignInIdentifier.Email);
return <EmailForm onSubmit={onSubmit} {...props} submitButtonText="action.sign_in" />;
};
const EmailSignIn = ({ signInMethod, ...props }: Props) => {
const { password, isPasswordPrimary, verificationCode } = signInMethod;
// Continue with password
if (password && (isPasswordPrimary || !verificationCode)) {
return <EmailSignInWithPassword {...props} />;
}
// Send verification code
if (verificationCode) {
return <EmailSignInWithVerificationCode {...props} />;
}
return null;
};
export default EmailSignIn;

View file

@ -1,4 +1,3 @@
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

@ -1,144 +0,0 @@
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { fireEvent, waitFor, act } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import {
signInWithPasswordIdentifier,
putInteraction,
sendVerificationCode,
} from '@/apis/interaction';
import { UserFlow } from '@/types';
import PasswordSignInForm from '.';
jest.mock('@/apis/interaction', () => ({
signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })),
sendVerificationCode: jest.fn(() => ({ success: true })),
putInteraction: jest.fn(() => ({ success: true })),
}));
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('PasswordSignInForm', () => {
const email = 'foo@logto.io';
const phone = '18573333333';
const password = '111222';
afterEach(() => {
jest.clearAllMocks();
});
it('Password is required', async () => {
const { getByText, queryByText } = renderWithPageContext(
<PasswordSignInForm method={SignInIdentifier.Email} value={email} />
);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).not.toBeCalled();
expect(queryByText('password_required')).not.toBeNull();
});
});
it('EmailPasswordSignForm', async () => {
const { getByText, container } = renderWithPageContext(
<PasswordSignInForm hasPasswordlessButton method={SignInIdentifier.Email} value={email} />
);
const passwordInput = container.querySelector('input[name="password"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: password } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ email, password });
});
const sendVerificationCodeLink = getByText('action.sign_in_via_passcode');
expect(sendVerificationCodeLink).not.toBeNull();
act(() => {
fireEvent.click(sendVerificationCodeLink);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
});
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/verification-code`,
search: '',
},
{
state: { email },
replace: true,
}
);
});
it('PhonePasswordSignForm', async () => {
const { getByText, container } = renderWithPageContext(
<PasswordSignInForm hasPasswordlessButton method={SignInIdentifier.Phone} value={phone} />
);
const passwordInput = container.querySelector('input[name="password"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: password } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ phone, password });
});
const sendVerificationCodeLink = getByText('action.sign_in_via_passcode');
expect(sendVerificationCodeLink).not.toBeNull();
act(() => {
fireEvent.click(sendVerificationCodeLink);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone });
});
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Phone}/verification-code`,
search: '',
},
{
state: { phone },
replace: true,
}
);
});
});

View file

@ -1,105 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import { PasswordInput } from '@/components/Input';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import { requiredValidation } from '@/utils/field-validations';
import PasswordlessSignInLink from './PasswordlessSignInLink';
import * as styles from './index.module.scss';
type Props = {
method: SignInIdentifier.Email | SignInIdentifier.Phone;
value: string;
hasPasswordlessButton?: boolean;
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
type FieldState = {
password: string;
};
const defaultState: FieldState = {
password: '',
};
const PasswordSignInForm = ({
className,
autoFocus,
hasPasswordlessButton = false,
method,
value,
}: Props) => {
const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { fieldValue, register, validateForm } = useForm(defaultState);
const { isForgotPasswordEnabled, phone, email } = useForgotPasswordSettings();
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
if (!validateForm()) {
return;
}
const payload =
method === SignInIdentifier.Email
? { email: value, password: fieldValue.password }
: { phone: value, password: fieldValue.password };
void onSubmit(payload);
},
[clearErrorMessage, validateForm, onSubmit, method, value, fieldValue.password]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PasswordInput
autoFocus={autoFocus}
className={styles.inputField}
name="password"
autoComplete="current-password"
placeholder={t('input.password')}
{...register('password', (value) => requiredValidation('password', value))}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={
method === SignInIdentifier.Email
? email
? SignInIdentifier.Email
: SignInIdentifier.Phone
: phone
? SignInIdentifier.Phone
: SignInIdentifier.Email
}
/>
)}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
{hasPasswordlessButton && (
<PasswordlessSignInLink className={styles.switch} method={method} value={value} />
)}
<input hidden type="submit" />
</form>
);
};
export default PasswordSignInForm;

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import PhoneForm from './PhoneForm';

View file

@ -1,6 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import PhoneForm from './PhoneForm';

View file

@ -1,178 +0,0 @@
import { SignInIdentifier, InteractionEvent } from '@logto/schemas';
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendVerificationCode, putInteraction } from '@/apis/interaction';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PhoneSignIn from './PhoneSignIn';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('@/apis/interaction', () => ({
sendVerificationCode: jest.fn(() => ({ success: true })),
putInteraction: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('PhoneSignIn', () => {
const phone = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
afterEach(() => {
jest.clearAllMocks();
});
test('PhoneSignIn form with password as primary method', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneSignIn
signInMethod={{
identifier: SignInIdentifier.Phone,
password: true,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/password' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('PhoneSignIn form with password true, primary true but verification code false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneSignIn
signInMethod={{
identifier: SignInIdentifier.Phone,
password: true,
verificationCode: false,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/password' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('PhoneSignIn form with password true but is primary false and verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneSignIn
signInMethod={{
identifier: SignInIdentifier.Phone,
password: true,
verificationCode: true,
isPasswordPrimary: false,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('PhoneSignIn form with password false but primary verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneSignIn
signInMethod={{
identifier: SignInIdentifier.Phone,
password: false,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
});

View file

@ -1,60 +0,0 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import useContinueSignInWithPassword from '@/hooks/use-continue-sign-in-with-password';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { ArrayElement } from '@/types';
import { UserFlow } from '@/types';
import PhoneForm from './PhoneForm';
type FormProps = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
type Props = FormProps & {
signInMethod: ArrayElement<SignIn['methods']>;
};
const PhoneSignInWithVerificationCode = (props: FormProps) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.signIn,
SignInIdentifier.Phone
);
return (
<PhoneForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.sign_in"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
const PhoneSignInWithPassword = (props: FormProps) => {
const onSubmit = useContinueSignInWithPassword(SignInIdentifier.Phone);
return <PhoneForm onSubmit={onSubmit} {...props} submitButtonText="action.sign_in" />;
};
const PhoneSignIn = ({ signInMethod, ...props }: Props) => {
const { password, isPasswordPrimary, verificationCode } = signInMethod;
// Continue with password
if (password && (isPasswordPrimary || !verificationCode)) {
return <PhoneSignInWithPassword {...props} />;
}
// Send verification code
if (verificationCode) {
return <PhoneSignInWithVerificationCode {...props} />;
}
return null;
};
export default PhoneSignIn;

View file

@ -1,4 +1,3 @@
export { default as PhoneRegister } from './PhoneRegister';
export { default as PhoneSignIn } from './PhoneSignIn';
export { default as PhoneResetPassword } from './PhoneResetPassword';
export { default as PhoneContinue } from './PhoneContinue';

View file

@ -17,7 +17,7 @@ const PasswordSignInLink = ({ className, method, target }: Props) => {
className={className}
icon={<SwitchIcon />}
text="action.sign_in_via_password"
to={`/${UserFlow.signIn}/${method}/password`}
to={`/${UserFlow.signIn}/password`}
state={{ [method]: target }}
/>
);

View file

@ -8,14 +8,13 @@ import {
signInWithVerificationCodeIdentifier,
addProfileWithVerificationCodeIdentifier,
} from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils';
import { UserFlow } from '@/types';
import VerificationCode from '.';
jest.useFakeTimers();
const sendVerificationCodeApi = jest.fn();
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@ -24,7 +23,7 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('@/apis/utils', () => ({
getSendVerificationCodeApi: () => sendVerificationCodeApi,
sendVerificationCodeApi: jest.fn(),
}));
jest.mock('@/apis/interaction', () => ({
@ -58,7 +57,7 @@ describe('<VerificationCode />', () => {
it('render counter', () => {
const { queryByText } = renderWithPageContext(
<VerificationCode type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
<VerificationCode flow={UserFlow.signIn} identifier={SignInIdentifier.Email} target={email} />
);
expect(queryByText('description.resend_after_seconds')).not.toBeNull();
@ -72,7 +71,7 @@ describe('<VerificationCode />', () => {
it('fire resend event', async () => {
const { getByText } = renderWithPageContext(
<VerificationCode type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
<VerificationCode flow={UserFlow.signIn} identifier={SignInIdentifier.Email} target={email} />
);
act(() => {
jest.advanceTimersByTime(1e3 * 60);
@ -83,7 +82,7 @@ describe('<VerificationCode />', () => {
fireEvent.click(resendButton);
});
expect(sendVerificationCodeApi).toBeCalledWith({ email });
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.signIn, { email });
});
describe('sign-in', () => {
@ -93,7 +92,11 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
<VerificationCode
flow={UserFlow.signIn}
identifier={SignInIdentifier.Email}
target={email}
/>
);
const inputs = container.querySelectorAll('input');
@ -121,7 +124,11 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.signIn} method={SignInIdentifier.Phone} target={phone} />
<VerificationCode
flow={UserFlow.signIn}
identifier={SignInIdentifier.Phone}
target={phone}
/>
);
const inputs = container.querySelectorAll('input');
@ -151,7 +158,11 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.register} method={SignInIdentifier.Email} target={email} />
<VerificationCode
flow={UserFlow.register}
identifier={SignInIdentifier.Email}
target={email}
/>
);
const inputs = container.querySelectorAll('input');
@ -179,7 +190,11 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.register} method={SignInIdentifier.Phone} target={phone} />
<VerificationCode
flow={UserFlow.register}
identifier={SignInIdentifier.Phone}
target={phone}
/>
);
const inputs = container.querySelectorAll('input');
@ -210,8 +225,8 @@ describe('<VerificationCode />', () => {
const { container } = renderWithPageContext(
<VerificationCode
type={UserFlow.forgotPassword}
method={SignInIdentifier.Email}
flow={UserFlow.forgotPassword}
identifier={SignInIdentifier.Email}
target={email}
/>
);
@ -241,8 +256,8 @@ describe('<VerificationCode />', () => {
const { container } = renderWithPageContext(
<VerificationCode
type={UserFlow.forgotPassword}
method={SignInIdentifier.Phone}
flow={UserFlow.forgotPassword}
identifier={SignInIdentifier.Phone}
target={phone}
/>
);
@ -275,8 +290,8 @@ describe('<VerificationCode />', () => {
const { container } = renderWithPageContext(
<MemoryRouter>
<VerificationCode
type={UserFlow.continue}
method={SignInIdentifier.Email}
flow={UserFlow.continue}
identifier={SignInIdentifier.Email}
target={email}
/>
</MemoryRouter>
@ -310,8 +325,8 @@ describe('<VerificationCode />', () => {
const { container } = renderWithPageContext(
<MemoryRouter>
<VerificationCode
type={UserFlow.continue}
method={SignInIdentifier.Phone}
flow={UserFlow.continue}
identifier={SignInIdentifier.Phone}
target={phone}
/>
</MemoryRouter>

View file

@ -13,44 +13,44 @@ import useResendVerificationCode from './use-resend-verification-code';
import { getCodeVerificationHookByFlow } from './utils';
type Props = {
type: UserFlow;
method: SignInIdentifier.Email | SignInIdentifier.Phone;
flow: UserFlow;
identifier: SignInIdentifier.Email | SignInIdentifier.Phone;
target: string;
hasPasswordButton?: boolean;
className?: string;
};
const VerificationCode = ({ type, method, className, hasPasswordButton, target }: Props) => {
const VerificationCode = ({ flow, identifier, className, hasPasswordButton, target }: Props) => {
const [code, setCode] = useState<string[]>([]);
const { t } = useTranslation();
const useVerificationCode = getCodeVerificationHookByFlow(type);
const useVerificationCode = getCodeVerificationHookByFlow(flow);
const errorCallback = useCallback(() => {
setCode([]);
}, []);
const { errorMessage, clearErrorMessage, onSubmit } = useVerificationCode(
method,
identifier,
target,
errorCallback
);
const { seconds, isRunning, onResendVerificationCode } = useResendVerificationCode(
type,
method,
flow,
identifier,
target
);
useEffect(() => {
if (code.length === defaultLength && code.every(Boolean)) {
const payload =
method === SignInIdentifier.Email
identifier === SignInIdentifier.Email
? { email: target, verificationCode: code.join('') }
: { phone: target, verificationCode: code.join('') };
void onSubmit(payload);
}
}, [code, method, onSubmit, target]);
}, [code, identifier, onSubmit, target]);
return (
<form className={classNames(styles.form, className)}>
@ -78,8 +78,9 @@ const VerificationCode = ({ type, method, className, hasPasswordButton, target }
}}
/>
)}
{type === UserFlow.signIn && hasPasswordButton && (
<PasswordSignInLink method={method} target={target} className={styles.switch} />
{flow === UserFlow.signIn && hasPasswordButton && (
<PasswordSignInLink method={identifier} target={target} className={styles.switch} />
)}
</form>
);

View file

@ -3,7 +3,7 @@ import { t } from 'i18next';
import { useCallback, useContext } from 'react';
import { useTimer } from 'react-timer-hook';
import { getSendVerificationCodeApi } from '@/apis/utils';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import { PageContext } from '@/hooks/use-page-context';
@ -31,11 +31,11 @@ const useResendVerificationCode = (
});
const handleError = useErrorHandler();
const sendVerificationCode = useApi(getSendVerificationCodeApi(type));
const sendVerificationCode = useApi(sendVerificationCodeApi);
const onResendVerificationCode = useCallback(async () => {
const payload = method === SignInIdentifier.Email ? { email: target } : { phone: target };
const [error, result] = await sendVerificationCode(payload);
const [error, result] = await sendVerificationCode(type, payload);
if (error) {
await handleError(error);
@ -47,7 +47,7 @@ const useResendVerificationCode = (
setToast(t('description.passcode_sent'));
restart(getTimeout(), true);
}
}, [handleError, method, restart, sendVerificationCode, setToast, target]);
}, [handleError, method, restart, sendVerificationCode, setToast, target, type]);
return {
seconds,

View file

@ -1,23 +0,0 @@
import type { SignInIdentifier } from '@logto/schemas';
import { useNavigate } from 'react-router-dom';
import { UserFlow } from '@/types';
const useContinueSignInWithPassword = <T extends SignInIdentifier.Email | SignInIdentifier.Phone>(
method: T
) => {
const navigate = useNavigate();
type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
return (payload: Payload) => {
navigate(
{
pathname: `/${UserFlow.signIn}/${method}/password`,
},
{ state: payload }
);
};
};
export default useContinueSignInWithPassword;

View file

@ -20,6 +20,7 @@ const usePasswordSignIn = () => {
const asyncSignIn = useApi(signInWithPasswordIdentifier);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ flow: UserFlow.signIn });
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.invalid_credentials': (error) => {

View file

@ -0,0 +1,79 @@
/** TODO: to be deprecated */
import { SignInIdentifier } from '@logto/schemas';
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import type { UserFlow } from '@/types';
const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdentifier.Phone>(
flow: UserFlow,
method: T,
replaceCurrentPage?: boolean
) => {
const [errorMessage, setErrorMessage] = useState<string>();
const navigate = useNavigate();
const handleError = useErrorHandler();
const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'guard.invalid_input': () => {
setErrorMessage(method === SignInIdentifier.Email ? 'invalid_email' : 'invalid_phone');
},
}),
[method]
);
type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
const onSubmit = useCallback(
async (payload: Payload) => {
const [error, result] = await asyncSendVerificationCode(flow, payload);
if (error) {
await handleError(error, errorHandlers);
return;
}
if (result) {
navigate(
{
pathname: `/${flow}/${method}/verification-code`,
search: location.search,
},
{
state: payload,
replace: replaceCurrentPage,
}
);
}
},
[
asyncSendVerificationCode,
errorHandlers,
flow,
handleError,
method,
navigate,
replaceCurrentPage,
]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useSendVerificationCode;

View file

@ -1,45 +1,45 @@
/* Replace legacy useSendVerificationCode hook with this one after the refactor */
import { SignInIdentifier } from '@logto/schemas';
import { useState, useMemo, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getSendVerificationCodeApi } from '@/apis/utils';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import type { UserFlow } from '@/types';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types';
const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdentifier.Phone>(
flow: UserFlow,
method: T,
replaceCurrentPage?: boolean
) => {
const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
const [errorMessage, setErrorMessage] = useState<string>();
const navigate = useNavigate();
const handleError = useErrorHandler();
const asyncSendVerificationCode = useApi(getSendVerificationCodeApi(flow));
const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'guard.invalid_input': () => {
setErrorMessage(method === SignInIdentifier.Email ? 'invalid_email' : 'invalid_phone');
},
}),
[method]
);
type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
type Payload = {
identifier: VerificationCodeIdentifier;
value: string;
};
const onSubmit = useCallback(
async (payload: Payload) => {
const [error, result] = await asyncSendVerificationCode(payload);
async ({ identifier, value }: Payload) => {
const [error, result] = await asyncSendVerificationCode(UserFlow.signIn, {
[identifier]: value,
});
if (error) {
await handleError(error, errorHandlers);
await handleError(error, {
'guard.invalid_input': () => {
setErrorMessage(
identifier === SignInIdentifier.Email ? 'invalid_email' : 'invalid_phone'
);
},
});
return;
}
@ -47,25 +47,17 @@ const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdenti
if (result) {
navigate(
{
pathname: `/${flow}/${method}/verification-code`,
pathname: `verification-code`,
search: location.search,
},
{
state: payload,
state: { identifier, value },
replace: replaceCurrentPage,
}
);
}
},
[
asyncSendVerificationCode,
errorHandlers,
flow,
handleError,
method,
navigate,
replaceCurrentPage,
]
[asyncSendVerificationCode, handleError, navigate, replaceCurrentPage]
);
return {

View file

@ -1,4 +1,5 @@
import { useContext } from 'react';
import { SignInIdentifier } from '@logto/schemas';
import { useContext, useCallback } from 'react';
import { PageContext } from './use-page-context';
@ -24,7 +25,27 @@ export const useForgotPasswordSettings = () => {
const { experienceSettings } = useContext(PageContext);
const { forgotPassword } = experienceSettings ?? {};
const getEnabledRetrievePasswordIdentifier = useCallback(
(identifier: SignInIdentifier) => {
if (identifier === SignInIdentifier.Username || identifier === SignInIdentifier.Email) {
return forgotPassword?.email
? SignInIdentifier.Email
: forgotPassword?.phone
? SignInIdentifier.Phone
: undefined;
}
return forgotPassword?.phone
? SignInIdentifier.Phone
: forgotPassword?.email
? SignInIdentifier.Email
: undefined;
},
[forgotPassword]
);
return {
getEnabledRetrievePasswordIdentifier,
isForgotPasswordEnabled: Boolean(
forgotPassword && (forgotPassword.email || forgotPassword.phone)
),

View file

@ -1,122 +0,0 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SecondarySignIn from '@/pages/SecondarySignIn';
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<SecondarySignIn />', () => {
test('renders phone', async () => {
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/phone']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.sign_in')).toHaveLength(2);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});
test('renders email', async () => {
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.sign_in')).toHaveLength(2);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
test('render un-recognized method', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/test']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render un-supported method', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: mockSignInExperienceSettings.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Email
),
},
}}
>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render with register only mode', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signInMode: SignInMode.Register,
}}
>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
});

View file

@ -1,40 +0,0 @@
/** To Be Deprecated */
import { SignInMode, SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailSignIn } from '@/containers/EmailForm';
import { PhoneSignIn } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
type Props = {
method?: string;
};
const SecondarySignIn = () => {
const { method = '' } = useParams<Props>();
const { signInMethods, signInMode } = useSieMethods();
const signInMethod = signInMethods.find(({ identifier }) => identifier === method);
if (!signInMode || signInMode === SignInMode.Register) {
return <ErrorPage />;
}
if (!signInMethod) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper title="action.sign_in">
{signInMethod.identifier === SignInIdentifier.Phone ? (
<PhoneSignIn autoFocus signInMethod={signInMethod} />
) : signInMethod.identifier === SignInIdentifier.Email ? (
<EmailSignIn autoFocus signInMethod={signInMethod} />
) : null}
</SecondaryPageWrapper>
);
};
export default SecondarySignIn;

View file

@ -0,0 +1,19 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.terms,
.formErrors {
margin-bottom: _.unit(4);
}
.formErrors {
margin-top: _.unit(-3);
}
}

View file

@ -0,0 +1,161 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { mockSignInMethodSettingsTestCases } from '@/__mocks__/logto';
import { sendVerificationCodeApi } from '@/apis/utils';
import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import IdentifierSignInForm from './index';
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
}));
const mockedNavigate = jest.fn();
jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
const username = 'foo';
const email = 'foo@email.com';
const phone = '8573333333';
const renderForm = (signInMethods: SignIn['methods']) =>
renderWithPageContext(
<MemoryRouter>
<IdentifierSignInForm signInMethods={signInMethods} />
</MemoryRouter>
);
describe('IdentifierSignInForm', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should show required error message when input is empty', async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!);
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getByText('general_required')).not.toBeNull();
});
});
test.each(['0foo', ' foo@', '85711'])(
`should show error message when with invalid input %p`,
async (input) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value: input } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getByText('general_invalid')).not.toBeNull();
});
}
);
describe.each(mockSignInMethodSettingsTestCases)(
'render IdentifierSignInForm with [%p, %p, %p]',
(...signInMethods) => {
test.each([
[SignInIdentifier.Username, username],
[SignInIdentifier.Email, email],
[SignInIdentifier.Phone, phone],
])('sign in with %p', async (identifier, value) => {
const { getByText, container } = renderForm(signInMethods);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
if (identifier === SignInIdentifier.Username) {
await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: 'password' },
{ state: { identifier: SignInIdentifier.Username, value } }
);
});
return;
}
const signInMethod = signInMethods.find((method) => method.identifier === identifier);
assert(signInMethod, new Error('invalid sign in method'));
const { password, verificationCode, isPasswordPrimary } = signInMethod;
if (password && (isPasswordPrimary || !verificationCode)) {
await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: 'password' },
{
state: {
identifier,
value:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
}
);
});
return;
}
if (verificationCode) {
await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.signIn, {
[identifier]:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
});
expect(mockedNavigate).not.toBeCalled();
});
}
});
}
);
});

View file

@ -0,0 +1,116 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignIn } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import { identifierErrorWatcher, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
import useOnSubmit from './use-on-submit';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
signInMethods: SignIn['methods'];
};
type FormState = {
identifier: string;
};
const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
const enabledSignInMethods = useMemo(
() => signInMethods.map(({ identifier }) => identifier),
[signInMethods]
);
const [inputType, setInputType] = useState<IdentifierInputType>(
enabledSignInMethods[0] ?? SignInIdentifier.Username
);
const {
register,
setValue,
handleSubmit,
formState: { errors, isSubmitted },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
clearErrorMessage();
if (!(await termsValidation())) {
return;
}
await onSubmit(inputType, identifier);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
);
const identifierError = identifierErrorWatcher(enabledSignInMethods, errors.identifier);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
required
autoComplete="identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}
isDanger={!!identifierError || !!errorMessage}
error={identifierError}
enabledTypes={enabledSignInMethods}
onTypeChange={setInputType}
{...register('identifier', {
required: true,
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
if (errorMessage) {
return typeof errorMessage === 'string'
? t(`error.${errorMessage}`)
: t(`error.${errorMessage.code}`, errorMessage.data);
}
return true;
},
})}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" htmlType="submit" />
<input hidden type="submit" />
</form>
);
};
export default IdentifierSignInForm;

View file

@ -0,0 +1,63 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { UserFlow } from '@/types';
const useOnSubmit = (signInMethods: SignIn['methods']) => {
const navigate = useNavigate();
const signInWithPassword = useCallback(
(identifier: SignInIdentifier, value: string) => {
navigate(
{
pathname: 'password',
},
{ state: { identifier, value } }
);
},
[navigate]
);
const {
errorMessage,
clearErrorMessage,
onSubmit: sendVerificationCode,
} = useSendVerificationCode(UserFlow.signIn);
const onSubmit = async (identifier: SignInIdentifier, value: string) => {
const method = signInMethods.find((method) => method.identifier === identifier);
if (!method) {
throw new Error(`Cannot find method with identifier type ${identifier}`);
}
const { password, isPasswordPrimary, verificationCode } = method;
if (identifier === SignInIdentifier.Username) {
signInWithPassword(identifier, value);
return;
}
if (password && (isPasswordPrimary || !verificationCode)) {
signInWithPassword(identifier, value);
return;
}
if (verificationCode) {
await sendVerificationCode({ identifier, value });
}
};
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useOnSubmit;

View file

@ -1,12 +1,13 @@
import type { ConnectorMetadata, SignInExperience } from '@logto/schemas';
import type { SignIn, ConnectorMetadata } from '@logto/schemas';
import SocialSignIn from '@/containers/SocialSignIn';
import IdentifierSignInForm from './IdentifierSignInForm';
import PasswordSignInForm from './PasswordSignInForm';
import * as styles from './index.module.scss';
type Props = {
signInMethods: SignInExperience['signIn']['methods'];
signInMethods: SignIn['methods'];
socialConnectors: ConnectorMetadata[];
};
@ -29,8 +30,7 @@ const Main = ({ signInMethods, socialConnectors }: Props) => {
}
if (signInMethods.length > 0) {
// TODO: password or validation code signIn
return <div>Working In Progress</div>;
return <IdentifierSignInForm className={styles.main} signInMethods={signInMethods} />;
}
return null;

View file

@ -38,12 +38,14 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled, ...passwordlessMethod } = useForgotPasswordSettings();
const { getEnabledRetrievePasswordIdentifier } = useForgotPasswordSettings();
const [inputType, setInputType] = useState<IdentifierInputType>(
signInMethods[0] ?? SignInIdentifier.Username
);
const forgotPasswordIdentifier = getEnabledRetrievePasswordIdentifier(inputType);
const {
register,
setValue,
@ -119,15 +121,12 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
{...register('password', { required: true })}
/>
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={passwordlessMethod.email ? SignInIdentifier.Email : SignInIdentifier.Phone}
/>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{forgotPasswordIdentifier && (
<ForgotPasswordLink className={styles.link} method={forgotPasswordIdentifier} />
)}
<TermsOfUse className={styles.terms} />
<Button name="submit" title="action.sign_in" htmlType="submit" />

View file

@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { mockSignInExperienceSettings, mockSignInMethodSettingsTestCases } from '@/__mocks__/logto';
import SignIn from '@/pages/SignIn';
jest.mock('i18next', () => ({
@ -53,6 +53,22 @@ describe('<SignIn />', () => {
});
});
describe('renders with identifier code only SignIn method settings', () => {
test.each(mockSignInMethodSettingsTestCases)(
'renders with [%p %p %p] SignIn Methods only mode',
async (...methods) => {
const { container } = renderSignIn({
signIn: {
methods,
},
});
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).toBeNull();
}
);
});
test('renders with social as primary', async () => {
const { container } = renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, signIn: { methods: [] } }}>

View file

@ -1,24 +1,23 @@
import { SignInIdentifier } from '@logto/schemas';
import { useContext, useEffect } from 'react';
import SwitchIcon from '@/assets/icons/switch-icon.svg';
import TextLink from '@/components/TextLink';
import { PageContext } from '@/hooks/use-page-context';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types';
type Props = {
className?: string;
method: SignInIdentifier.Email | SignInIdentifier.Phone;
identifier: VerificationCodeIdentifier;
value: string;
};
const PasswordlessSignInLink = ({ className, method, value }: Props) => {
const VerificationCodeLink = ({ className, identifier, value }: Props) => {
const { setToast } = useContext(PageContext);
const { errorMessage, clearErrorMessage, onSubmit } = useSendVerificationCode(
UserFlow.signIn,
method,
true
);
@ -35,10 +34,10 @@ const PasswordlessSignInLink = ({ className, method, value }: Props) => {
icon={<SwitchIcon />}
onClick={() => {
clearErrorMessage();
void onSubmit(method === SignInIdentifier.Email ? { email: value } : { phone: value });
void onSubmit({ identifier, value });
}}
/>
);
};
export default PasswordlessSignInLink;
export default VerificationCodeLink;

View file

@ -0,0 +1,103 @@
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { fireEvent, waitFor, act } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import {
signInWithPasswordIdentifier,
putInteraction,
sendVerificationCode,
} from '@/apis/interaction';
import PasswordForm from '.';
jest.mock('@/apis/interaction', () => ({
signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })),
sendVerificationCode: jest.fn(() => ({ success: true })),
putInteraction: jest.fn(() => ({ success: true })),
}));
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('PasswordSignInForm', () => {
const username = 'foo';
const email = 'foo@logto.io';
const phone = '18573333333';
const password = '111222';
afterEach(() => {
jest.clearAllMocks();
});
test.each([
{ identifier: SignInIdentifier.Username, value: username, isVerificationCodeEnabled: false },
{ identifier: SignInIdentifier.Email, value: email, isVerificationCodeEnabled: true },
{ identifier: SignInIdentifier.Phone, value: phone, isVerificationCodeEnabled: true },
])(
'Password SignInForm for %variable.identifier',
async ({ identifier, value, isVerificationCodeEnabled }) => {
const { getByText, queryByText, container } = renderWithPageContext(
<PasswordForm
identifier={identifier}
value={value}
isVerificationCodeEnabled={isVerificationCodeEnabled}
/>
);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).not.toBeCalled();
expect(queryByText('password_required')).not.toBeNull();
});
const passwordInput = container.querySelector('input[name="password"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: password } });
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ [identifier]: value, password });
});
if (isVerificationCodeEnabled) {
const sendVerificationCodeLink = getByText('action.sign_in_via_passcode');
expect(sendVerificationCodeLink).not.toBeNull();
act(() => {
fireEvent.click(sendVerificationCodeLink);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ [identifier]: value });
});
expect(mockedNavigate).toBeCalledWith(
{
pathname: 'verification-code',
search: '',
},
{
state: { identifier, value },
replace: true,
}
);
}
}
);
});

View file

@ -0,0 +1,99 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import { PasswordInputField } from '@/components/InputFields';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import * as styles from '../index.module.scss';
import VerificationCodeLink from './VerificationCodeLink';
type Props = {
className?: string;
identifier: SignInIdentifier;
value: string;
isVerificationCodeEnabled?: boolean;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
type FormState = {
password: string;
};
const PasswordForm = ({
className,
autoFocus,
isVerificationCodeEnabled = false,
identifier,
value,
}: Props) => {
const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { getEnabledRetrievePasswordIdentifier } = useForgotPasswordSettings();
const forgotPasswordIdentifier = getEnabledRetrievePasswordIdentifier(identifier);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { password: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
void handleSubmit(async ({ password }, event) => {
event?.preventDefault();
await onSubmit({
[identifier]: value,
password,
});
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit, identifier, value]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PasswordInputField
required
autoFocus={autoFocus}
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!errors.password}
error={errors.password && 'password_required'}
{...register('password', { required: true })}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{forgotPasswordIdentifier && (
<ForgotPasswordLink className={styles.link} method={forgotPasswordIdentifier} />
)}
<Button title="action.continue" name="submit" htmlType="submit" />
{identifier !== SignInIdentifier.Username && isVerificationCodeEnabled && (
<VerificationCodeLink className={styles.switch} identifier={identifier} value={value} />
)}
<input hidden type="submit" />
</form>
);
};
export default PasswordForm;

View file

@ -1,5 +1,5 @@
import { SignInIdentifier } from '@logto/schemas';
import { Routes, Route, MemoryRouter, useLocation } from 'react-router-dom';
import { MemoryRouter, useLocation } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
@ -16,156 +16,85 @@ describe('SignInPassword', () => {
const mockUseLocation = useLocation as jest.Mock;
const email = 'email@logto.io';
const phone = '18571111111';
const username = 'foo';
const renderPasswordSignInPage = (settings?: Partial<typeof mockSignInExperienceSettings>) =>
renderWithPageContext(
<MemoryRouter>
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
...settings,
}}
>
<SignInPassword />
</SettingsProvider>
</MemoryRouter>
);
beforeEach(() => {
mockUseLocation.mockImplementation(() => ({ state: { email, phone } }));
jest.clearAllMocks();
});
test('Show error page with unknown method', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/test/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
test('Show invalid session error page with invalid state', () => {
const { queryByText } = renderPasswordSignInPage();
expect(queryByText('description.enter_password')).toBeNull();
expect(queryByText('error.invalid_session')).not.toBeNull();
});
test('Show 404 error page with invalid method', () => {
mockUseLocation.mockImplementation(() => ({
state: { identifier: SignInIdentifier.Username, value: username },
}));
const { queryByText } = renderPasswordSignInPage({
signIn: {
methods: [
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
},
],
},
});
expect(queryByText('description.enter_password')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('Show error page with unsupported method', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: mockSignInExperienceSettings.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Email
),
},
}}
>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
test.each([
{ identifier: SignInIdentifier.Email, value: email, verificationCode: true },
{ identifier: SignInIdentifier.Phone, value: phone, verificationCode: false },
])(
'render password page with %variable.identifier',
({ identifier, value, verificationCode }) => {
mockUseLocation.mockImplementation(() => ({
state: { identifier, value },
}));
expect(queryByText('description.enter_password')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
const { queryByText, container } = renderPasswordSignInPage({
signIn: {
methods: [
{
identifier,
password: true,
verificationCode,
isPasswordPrimary: true,
},
],
},
});
test('Show error page if no address value found', () => {
mockUseLocation.mockClear();
mockUseLocation.mockImplementation(() => ({}));
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('description.enter_password')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('description.enter_password')).toBeNull();
expect(queryByText('error.invalid_email')).not.toBeNull();
});
test('/sign-in/email/password', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('description.enter_password')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in_via_passcode')).not.toBeNull();
});
test('/sign-in/phone/password', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/phone/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('description.enter_password')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in_via_passcode')).not.toBeNull();
});
test('should not render passwordless link if verificationCode is disabled', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email/password']}>
<Routes>
<Route
path="/sign-in/:method/password"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: [
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
}}
>
<SignInPassword />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('description.enter_password')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in_via_passcode')).toBeNull();
});
if (verificationCode) {
expect(queryByText('action.sign_in_via_passcode')).not.toBeNull();
} else {
expect(queryByText('action.sign_in_via_passcode')).toBeNull();
}
}
);
});

View file

@ -1,45 +1,35 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useParams, useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import PasswordSignInForm from '@/containers/PasswordSignInForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { emailOrPhoneStateGuard } from '@/types/guard';
import { passwordIdentifierStateGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import { isEmailOrPhoneMethod } from '@/utils/sign-in-experience';
import { identifierInputDescriptionMap } from '@/utils/form';
type Parameters = {
method?: string;
};
import PasswordForm from './PasswordForm';
const SignInPassword = () => {
const { t } = useTranslation();
const { method } = useParams<Parameters>();
const { state } = useLocation();
const { signInMethods } = useSieMethods();
const methodSetting = signInMethods.find(({ identifier }) => identifier === method);
// Only Email and Phone method should use this page
if (
!methodSetting ||
!isEmailOrPhoneMethod(methodSetting.identifier) ||
!methodSetting.password
) {
return <ErrorPage />;
const [_, identifierState] = validate(state, passwordIdentifierStateGuard);
if (!identifierState) {
return <ErrorPage title="error.invalid_session" />;
}
const invalidState = !is(state, emailOrPhoneStateGuard);
const value = !invalidState && state[methodSetting.identifier];
const { identifier, value } = identifierState;
if (!value) {
return (
<ErrorPage
title={method === SignInIdentifier.Email ? 'error.invalid_email' : 'error.invalid_phone'}
/>
);
const methodSetting = signInMethods.find((method) => method.identifier === identifier);
// Sign-In method not enabled
if (!methodSetting || !methodSetting.password) {
return <ErrorPage />;
}
return (
@ -47,18 +37,18 @@ const SignInPassword = () => {
title="description.enter_password"
description="description.enter_password_for"
descriptionProps={{
method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
method: t(identifierInputDescriptionMap[identifier]),
value:
method === SignInIdentifier.Phone
identifier === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(value)
: value,
}}
>
<PasswordSignInForm
<PasswordForm
autoFocus
method={methodSetting.identifier}
identifier={methodSetting.identifier}
value={value}
hasPasswordlessButton={methodSetting.verificationCode}
isVerificationCodeEnabled={methodSetting.verificationCode}
/>
</SecondaryPageWrapper>
);

View file

@ -10,6 +10,11 @@ import SocialCallback from '.';
const origin = 'http://localhost:3000';
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
}));
jest.mock('@/apis/interaction', () => ({
signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
}));

View file

@ -8,17 +8,17 @@ import VerificationCode from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
state: { email: 'foo@logto.io' },
state: { identifier: 'email', value: 'foo@logto.io' },
}),
}));
describe('VerificationCode Page', () => {
it('render properly', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email/verification-code']}>
<MemoryRouter initialEntries={['/sign-in/verification-code']}>
<SettingsProvider>
<Routes>
<Route path="/:type/:method/verification-code" element={<VerificationCode />} />
<Route path="/:flow/verification-code" element={<VerificationCode />} />
</Routes>
</SettingsProvider>
</MemoryRouter>
@ -28,24 +28,11 @@ describe('VerificationCode Page', () => {
expect(queryByText('description.enter_passcode')).not.toBeNull();
});
it('render with invalid method', () => {
it('render with invalid flow', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/username/verification-code']}>
<MemoryRouter initialEntries={['/social/verification-code']}>
<Routes>
<Route path="/:type/:method/verification-code" element={<VerificationCode />} />
</Routes>
</MemoryRouter>
);
expect(queryByText('action.enter_passcode')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
it('render with invalid type', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/social/email/verification-code']}>
<Routes>
<Route path="/:type/:method/verification-code" element={<VerificationCode />} />
<Route path="/:flow/verification-code" element={<VerificationCode />} />
</Routes>
</MemoryRouter>
);

View file

@ -1,61 +1,58 @@
import { t } from 'i18next';
import { useParams, useLocation } from 'react-router-dom';
import { is, validate } from 'superstruct';
import { validate } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import VerificationCodeContainer from '@/containers/VerificationCode';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
import { emailOrPhoneStateGuard, verificationCodeMethodGuard, userFlowGuard } from '@/types/guard';
import { verificationCodeStateGuard, userFlowGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
type Parameters = {
type: string;
method: string;
flow: string;
};
const VerificationCode = () => {
const { method, type = '' } = useParams<Parameters>();
const { flow } = useParams<Parameters>();
const { signInMethods } = useSieMethods();
const { state } = useLocation();
const invalidMethod = !is(method, verificationCodeMethodGuard);
const invalidState = !is(state, emailOrPhoneStateGuard);
const [, identifierState] = validate(state, verificationCodeStateGuard);
const [, useFlow] = validate(flow, userFlowGuard);
const [, flow] = validate(type, userFlowGuard);
if (!flow || invalidMethod) {
if (!useFlow) {
return <ErrorPage />;
}
if (!identifierState) {
return <ErrorPage title="error.invalid_session" />;
}
const { identifier, value } = identifierState;
const methodSettings = signInMethods.find((method) => method.identifier === identifier);
// SignIn Method not enabled
const methodSettings = signInMethods.find(({ identifier }) => identifier === method);
if (!methodSettings && type !== UserFlow.forgotPassword) {
if (!methodSettings && flow !== UserFlow.forgotPassword) {
return <ErrorPage />;
}
const target = !invalidState && state[method];
if (!target) {
return <ErrorPage title={method === 'email' ? 'error.invalid_email' : 'error.invalid_phone'} />;
}
return (
<SecondaryPageWrapper
title="action.enter_passcode"
description="description.enter_passcode"
descriptionProps={{
address: t(`description.${method === 'email' ? 'email' : 'phone_number'}`),
target: method === 'phone' ? formatPhoneNumberWithCountryCallingCode(target) : target,
address: t(`description.${identifier === 'email' ? 'email' : 'phone_number'}`),
target: identifier === 'phone' ? formatPhoneNumberWithCountryCallingCode(value) : value,
}}
>
<VerificationCodeContainer
type={flow}
method={method}
target={target}
hasPasswordButton={type === UserFlow.signIn && methodSettings?.password}
flow={useFlow}
identifier={identifier}
target={value}
hasPasswordButton={useFlow === UserFlow.signIn && methodSettings?.password}
/>
</SecondaryPageWrapper>
);

View file

@ -3,16 +3,12 @@ import * as s from 'superstruct';
import { UserFlow } from '.';
export const emailOrPhoneStateGuard = s.object({
email: s.optional(s.string()),
phone: s.optional(s.string()),
/* Password SignIn Flow */
export const passwordIdentifierStateGuard = s.object({
identifier: s.enums([SignInIdentifier.Email, SignInIdentifier.Phone, SignInIdentifier.Username]),
value: s.string(),
});
export const verificationCodeMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Phone),
]);
export const SignInMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Phone),
@ -32,17 +28,18 @@ export const continueFlowStateGuard = s.optional(
})
);
export const continueMethodGuard = s.union([
s.literal('password'),
s.literal('username'),
/* Verification Code Flow Guard */
export const verificationCodeMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Phone),
]);
export const usernameGuard = s.object({
username: s.string(),
export const verificationCodeStateGuard = s.object({
identifier: verificationCodeMethodGuard,
value: s.string(),
});
/* Social Flow Guard */
const registeredSocialIdentity = s.optional(
s.object({
email: s.optional(s.string()),

View file

@ -4,9 +4,6 @@ import type {
AppearanceMode,
SignInIdentifier,
} from '@logto/schemas';
import type * as s from 'superstruct';
import type { emailOrPhoneStateGuard } from './guard';
export enum UserFlow {
signIn = 'sign-in',
@ -28,8 +25,6 @@ export type Theme = 'dark' | 'light';
export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone;
export type EmailOrPhoneState = s.Infer<typeof emailOrPhoneStateGuard>;
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors
export type SignInExperienceResponse = Omit<SignInExperience, 'socialSignInConnectorTargets'> & {
socialConnectors: ConnectorMetadata[];

View file

@ -17,6 +17,12 @@ export const identifierInputPlaceholderMap: { [K in IdentifierInputType]: TFuncK
[SignInIdentifier.Username]: 'input.username',
};
export const identifierInputDescriptionMap: { [K in IdentifierInputType]: TFuncKey } = {
[SignInIdentifier.Phone]: 'description.phone_number',
[SignInIdentifier.Email]: 'description.email',
[SignInIdentifier.Username]: 'description.username',
};
export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined => {
switch (error?.type) {
case 'required':
@ -31,7 +37,7 @@ export const identifierErrorWatcher = (
enabledFields: IdentifierInputType[],
error?: FieldError
): ErrorType | undefined => {
const data = { types: enabledFields.map((field) => t(identifierInputPlaceholderMap[field])) };
const data = { types: enabledFields.map((field) => t(identifierInputDescriptionMap[field])) };
switch (error?.type) {
case 'required':