0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(ui): add forgot password link

add forgot password link
This commit is contained in:
simeng-li 2022-11-04 17:31:12 +08:00
parent 5b2bbd801b
commit 6f58f30ed0
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
17 changed files with 210 additions and 62 deletions

View file

@ -37,6 +37,15 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
getLogtoConnectors(),
]);
const forgotPassword = {
sms: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Sms && enabled
),
email: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Email && enabled
),
};
// Hard code AdminConsole sign-in methods settings.
if (interaction?.params.client_id === adminConsoleApplicationId) {
ctx.body = {
@ -48,6 +57,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
languageInfo: signInExperience.languageInfo,
signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register,
socialConnectors: [],
forgotPassword,
};
return next();
@ -81,6 +91,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
'demo_app.notification',
autoDetect ? undefined : { lng: fallbackLanguage }
),
forgotPassword,
};
return next();
@ -89,14 +100,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
ctx.body = {
...signInExperience,
socialConnectors,
forgotPassword: {
sms: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Sms && enabled
),
email: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Email && enabled
),
},
forgotPassword,
};
return next();

View file

@ -0,0 +1,19 @@
import type { SignInIdentifier } from '@logto/schemas';
import TextLink from '@/components/TextLink';
import { UserFlow } from '@/types';
type Props = {
method: SignInIdentifier.Email | SignInIdentifier.Sms;
className?: string;
};
const ForgotPasswordLink = ({ method, className }: Props) => (
<TextLink
className={className}
to={`/${UserFlow.forgotPassword}/${method}`}
text="action.forgot_password"
/>
);
export default ForgotPasswordLink;

View file

@ -14,7 +14,8 @@
}
.switch {
display: block;
width: auto;
align-self: start;
}
.formErrors {

View file

@ -8,10 +8,16 @@
}
.inputField,
.link,
.terms {
margin-bottom: _.unit(4);
}
.link {
width: auto;
align-self: start;
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);

View file

@ -1,5 +1,6 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
@ -28,11 +29,14 @@ describe('<EmailPassword>', () => {
test('render with terms settings enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).not.toBeNull();
});
test('required inputs with error message', () => {
@ -64,11 +68,13 @@ describe('<EmailPassword>', () => {
test('should show terms confirm modal', async () => {
const { queryByText, getByText, container } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -94,11 +100,13 @@ describe('<EmailPassword>', () => {
test('should show terms detail modal', async () => {
const { getByText, queryByText, container, queryByRole } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -135,9 +143,11 @@ describe('<EmailPassword>', () => {
test('submit form', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');

View file

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import Input, { PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import { emailValidation, requiredValidation } from '@/utils/field-validations';
@ -34,6 +36,7 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(SignInIdentifier.Email);
const { isForgotPasswordEnabled, email } = useForgotPasswordSettings();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
@ -86,6 +89,13 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
{...register('password', (value) => requiredValidation('password', value))}
/>
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={email ? SignInIdentifier.Email : SignInIdentifier.Sms}
/>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />

View file

@ -8,12 +8,15 @@
}
.inputField,
.link,
.switch {
margin-bottom: _.unit(4);
}
.link,
.switch {
display: block;
align-self: start;
width: auto;
}
.formErrors {

View file

@ -1,13 +1,15 @@
import type { SignInIdentifier } from '@logto/schemas';
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';
@ -42,6 +44,8 @@ const PasswordSignInForm = ({
const { fieldValue, register, validateForm } = useForm(defaultState);
const { isForgotPasswordEnabled, sms, email } = useForgotPasswordSettings();
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
@ -69,6 +73,21 @@ const PasswordSignInForm = ({
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={
method === SignInIdentifier.Email
? email
? SignInIdentifier.Email
: SignInIdentifier.Sms
: sms
? SignInIdentifier.Sms
: SignInIdentifier.Email
}
/>
)}
{hasPasswordlessButton && (
<PasswordlessSignInLink className={styles.switch} method={method} value={value} />
)}

View file

@ -14,7 +14,8 @@
}
.switch {
display: block;
align-self: start;
width: auto;
}
.formErrors {

View file

@ -8,10 +8,16 @@
}
.inputField,
.link,
.terms {
margin-bottom: _.unit(4);
}
.link {
align-self: start;
width: auto;
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);

View file

@ -1,5 +1,6 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
@ -35,11 +36,14 @@ describe('<PhonePassword>', () => {
test('render with terms settings enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).not.toBeNull();
});
test('required inputs with error message', () => {
@ -71,11 +75,13 @@ describe('<PhonePassword>', () => {
test('should show terms confirm modal', async () => {
const { queryByText, getByText, container } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -101,11 +107,13 @@ describe('<PhonePassword>', () => {
test('should show terms detail modal', async () => {
const { getByText, queryByText, container, queryByRole } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -142,9 +150,11 @@ describe('<PhonePassword>', () => {
test('submit form', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');

View file

@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import { PhoneInput, PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import usePhoneNumber from '@/hooks/use-phone-number';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import { requiredValidation } from '@/utils/field-validations';
@ -35,6 +37,7 @@ const PhonePassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(SignInIdentifier.Sms);
const { isForgotPasswordEnabled, sms } = useForgotPasswordSettings();
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
@ -108,6 +111,13 @@ const PhonePassword = ({ className, autoFocus }: Props) => {
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={sms ? SignInIdentifier.Sms : SignInIdentifier.Email}
/>
)}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />

View file

@ -8,10 +8,16 @@
}
.inputField,
.link,
.terms {
margin-bottom: _.unit(4);
}
.link {
align-self: start;
width: auto;
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);

View file

@ -1,8 +1,10 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { signInWithUsername } from '@/apis/sign-in';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
@ -26,13 +28,28 @@ describe('<UsernameSignIn>', () => {
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('render with terms settings enabled', () => {
test('render with terms settings enabled and forgot password enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<UsernameSignIn />
<MemoryRouter>
<UsernameSignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).not.toBeNull();
});
test('render with forgot password disabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, forgotPassword: { sms: false, email: false } }}
>
<UsernameSignIn />
</SettingsProvider>
);
expect(queryByText('action.forgot_password')).toBeNull();
});
test('required inputs with error message', () => {
@ -63,11 +80,13 @@ describe('<UsernameSignIn>', () => {
test('should show terms confirm modal', async () => {
const { queryByText, getByText, container } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -93,11 +112,13 @@ describe('<UsernameSignIn>', () => {
test('should show terms detail modal', async () => {
const { getByText, queryByText, container, queryByRole } = renderWithPageContext(
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
@ -134,9 +155,11 @@ describe('<UsernameSignIn>', () => {
test('submit form', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameSignIn />
</SettingsProvider>
<MemoryRouter>
<SettingsProvider>
<UsernameSignIn />
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');

View file

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import Input, { PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import { requiredValidation } from '@/utils/field-validations';
@ -33,6 +35,7 @@ const defaultState: FieldState = {
const UsernameSignIn = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { isForgotPasswordEnabled, email } = useForgotPasswordSettings();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(
SignInIdentifier.Username
);
@ -87,6 +90,13 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={email ? SignInIdentifier.Email : SignInIdentifier.Sms}
/>
)}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />

View file

@ -15,3 +15,13 @@ export const useSieMethods = () => {
forgotPassword: experienceSettings?.forgotPassword,
};
};
export const useForgotPasswordSettings = () => {
const { experienceSettings } = useContext(PageContext);
const { forgotPassword } = experienceSettings ?? {};
return {
isForgotPasswordEnabled: Boolean(forgotPassword?.email ?? forgotPassword?.sms),
...forgotPassword,
};
};

View file

@ -5,7 +5,7 @@ import { is } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailResetPassword } from '@/containers/EmailForm';
import { SmsResetPassword } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { passcodeMethodGuard } from '@/types/guard';
@ -15,14 +15,14 @@ type Props = {
const ForgotPassword = () => {
const { method = '' } = useParams<Props>();
const { forgotPassword } = useSieMethods();
const forgotPassword = useForgotPasswordSettings();
if (!is(method, passcodeMethodGuard)) {
return <ErrorPage />;
}
// Forgot password with target identifier method is not supported
if (!forgotPassword?.[method]) {
if (!forgotPassword[method]) {
return <ErrorPage />;
}