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:
parent
40a40be17c
commit
9c7bd2d0a8
48 changed files with 1032 additions and 1281 deletions
|
@ -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 />} />
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
|
|
@ -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 } });
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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';
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -20,6 +20,7 @@ const usePasswordSignIn = () => {
|
|||
const asyncSignIn = useApi(signInWithPasswordIdentifier);
|
||||
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ flow: UserFlow.signIn });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
|
|
79
packages/ui/src/hooks/use-send-verification-code-legacy.ts
Normal file
79
packages/ui/src/hooks/use-send-verification-code-legacy.ts
Normal 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;
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
161
packages/ui/src/pages/SignIn/IdentifierSignInForm/index.test.tsx
Normal file
161
packages/ui/src/pages/SignIn/IdentifierSignInForm/index.test.tsx
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
116
packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx
Normal file
116
packages/ui/src/pages/SignIn/IdentifierSignInForm/index.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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: [] } }}>
|
||||
|
|
|
@ -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;
|
103
packages/ui/src/pages/SignInPassword/PasswordForm/index.test.tsx
Normal file
103
packages/ui/src/pages/SignInPassword/PasswordForm/index.test.tsx
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
99
packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx
Normal file
99
packages/ui/src/pages/SignInPassword/PasswordForm/index.tsx
Normal 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;
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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` }),
|
||||
}));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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':
|
||||
|
|
Loading…
Add table
Reference in a new issue