diff --git a/README.md b/README.md index 5932ac5..66e775d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![React 18](https://img.shields.io/badge/react-18-blue) [![codecov](https://codecov.io/gh/dhis2/login-app/graph/badge.svg?token=3RL8FV6K0L)](https://codecov.io/gh/dhis2/login-app) This project was bootstrapped with [DHIS2 Application Platform](https://github.com/dhis2/app-platform). diff --git a/src/app.jsx b/src/app.jsx new file mode 100644 index 0000000..17fd231 --- /dev/null +++ b/src/app.jsx @@ -0,0 +1,125 @@ +import { CssReset, CssVariables } from '@dhis2/ui' +import parse from 'html-react-parser' +import React from 'react' +import { HashRouter, Navigate, Routes, Route } from 'react-router-dom' +import { + ApplicationDescription, + ApplicationLeftFooter, + ApplicationRightFooter, + ApplicationTitle, + Flag, + LanguageSelect, + Logo, + PoweredByDHIS2, +} from './components/customizable-elements.jsx' +import { Popup } from './components/pop-up.jsx' +import { sanitizeMainHTML } from './helpers/handleHTML.js' +import { + LoginPage, + CompleteRegistrationPage, + CreateAccountPage, + PasswordResetRequestPage, + PasswordUpdatePage, + SafeModePage, + DownloadPage, +} from './pages/index.js' +import { LoginConfigProvider, useLoginConfig } from './providers/index.js' +import i18n from './locales/index.js' // eslint-disable-line +import { standard, sidebar } from './templates/index.js' + +const LoginRoutes = () => { + return ( + <> + + + } /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + } /> + + + ) +} + +const options = { + replace: ({ attribs }) => { + if (!attribs) { + return + } + + if (attribs.id === 'login-box') { + return + } + + if (attribs.id === 'application-title') { + return + } + + if (attribs.id === 'application-introduction') { + return + } + + if (attribs.id === 'flag') { + return + } + + if (attribs.id === 'logo') { + return + } + + if (attribs.id === 'powered-by') { + return + } + + if (attribs.id === 'application-left-footer') { + return + } + + if (attribs.id === 'application-right-footer') { + return + } + + if (attribs.id === 'language-select') { + return + } + }, +} + +export const AppContent = () => { + const { loginPageLayout, loginPageTemplate } = useLoginConfig() + let html + if (loginPageLayout === 'SIDEBAR') { + html = sidebar + } else if (loginPageLayout === 'CUSTOM') { + html = loginPageTemplate ?? standard + } else { + html = standard + } + + return <>{parse(sanitizeMainHTML(html), options)} +} + +const App = () => ( + + + + + + + +) + +export default App diff --git a/src/app.test.jsx b/src/app.test.jsx new file mode 100644 index 0000000..3c64a36 --- /dev/null +++ b/src/app.test.jsx @@ -0,0 +1,154 @@ +import { screen } from '@testing-library/react' +import React from 'react' +import { AppContent } from './app.jsx' +import { useLoginConfig } from './providers/use-login-config.js' +import { renderWithRouter } from './test-utils/index.js' + +jest.mock('./components/customizable-elements.js', () => ({ + ...jest.requireActual('./components/customizable-elements.js'), + LanguageSelect: () =>
MOCK_LANGUAGE_SELECT
, + ApplicationTitle: () =>
MOCK_APPLICATION_TITLE
, + ApplicationDescription: () =>
MOCK_APPLICATION_DESCRIPTION
, + Flag: () =>
MOCK_FLAG
, + Logo: () =>
MOCK_LOGO
, + ApplicationLeftFooter: () =>
MOCK_APPLICATION_LEFT_FOOTER
, + ApplicationRightFooter: () =>
MOCK_APPLICATION_RIGHT_FOOTER
, + PoweredByDHIS2: () =>
MOCK_POWERED_BY
, +})) + +jest.mock('./providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(() => ({ + loginPageLayout: 'DEFAULT', + loginPageTemplate: null, + })), +})) + +jest.mock('./templates/index.js', () => ({ + __esModule: true, + standard: '
STANDARD TEMPLATE
', + sidebar: '
SIDEBAR TEMPLATE
', +})) + +describe('AppContent', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('loads standard template if loginPageLayout is DEFAULT', () => { + renderWithRouter() + expect(screen.getByText('STANDARD TEMPLATE')).toBeInTheDocument() + }) + + it('loads sidebar template if loginPageLayout is SIDEBAR', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'SIDEBAR', + }) + renderWithRouter() + expect(screen.getByText('SIDEBAR TEMPLATE')).toBeInTheDocument() + }) + + it('loads standard template if loginPageLayout is CUSTOM and loginPageTemplate is null', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: null, + }) + renderWithRouter() + expect(screen.getByText('STANDARD TEMPLATE')).toBeInTheDocument() + }) + + it('loads custom loginPageTemplate if loginPageLayout is CUSTOM and loginPageTemplate is not null', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
CUSTOM TEMPLATE
', + }) + renderWithRouter() + expect(screen.getByText('CUSTOM TEMPLATE')).toBeInTheDocument() + }) + + it('replaces application-title element with ApplicationTitle component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect(screen.getByText('MOCK_APPLICATION_TITLE')).toBeInTheDocument() + }) + + it('replaces application-introduction element with ApplicationDescription component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect( + screen.getByText('MOCK_APPLICATION_DESCRIPTION') + ).toBeInTheDocument() + }) + + it('replaces application-title element with ApplicationDescription component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect(screen.getByText('MOCK_APPLICATION_TITLE')).toBeInTheDocument() + }) + + it('replaces application-title element with ApplicationDescription component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect(screen.getByText('MOCK_APPLICATION_TITLE')).toBeInTheDocument() + }) + + it('replaces flag element with Flag component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect(screen.getByText('MOCK_FLAG')).toBeInTheDocument() + }) + + it('replaces logo element with Logo component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '', + }) + renderWithRouter() + expect(screen.getByText('MOCK_LOGO')).toBeInTheDocument() + }) + + it('replaces powered-by element with PoweredBy component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '
', + }) + renderWithRouter() + expect(screen.getByText('MOCK_POWERED_BY')).toBeInTheDocument() + }) + + it('replaces application-left-footer element with ApplicationLeftFooter component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '', + }) + renderWithRouter() + expect( + screen.getByText('MOCK_APPLICATION_LEFT_FOOTER') + ).toBeInTheDocument() + }) + + it('replaces application-right-footer element with ApplicationRightFooter component ', () => { + useLoginConfig.mockReturnValue({ + loginPageLayout: 'CUSTOM', + loginPageTemplate: '', + }) + renderWithRouter() + expect( + screen.getByText('MOCK_APPLICATION_RIGHT_FOOTER') + ).toBeInTheDocument() + }) +}) diff --git a/src/components/__tests__/back-to-login-button.test.jsx b/src/components/__tests__/back-to-login-button.test.jsx new file mode 100644 index 0000000..edb3a5e --- /dev/null +++ b/src/components/__tests__/back-to-login-button.test.jsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { BackToLoginButton } from '../back-to-login-button.jsx' + +const MainPage = () =>
MAIN PAGE
+const OtherPage = ({ children }) => <>{children} + +OtherPage.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]), +} + +const Wrapper = ({ children }) => ( + + + } /> + {children}} /> + + +) + +Wrapper.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]), +} + +describe('BackToLoginButton', () => { + it('redirects to main page when clicked', () => { + render( + + + + ) + expect(screen.queryByText('MAIN PAGE')).toBe(null) + fireEvent.click( + screen.getByRole('button', { + name: /back to log in/i, + }) + ) + expect(screen.getByText('MAIN PAGE')).not.toBe(null) + }) +}) diff --git a/src/components/__tests__/customizable-elements.test.jsx b/src/components/__tests__/customizable-elements.test.jsx new file mode 100644 index 0000000..d61bc8d --- /dev/null +++ b/src/components/__tests__/customizable-elements.test.jsx @@ -0,0 +1,119 @@ +import i18n from '@dhis2/d2-i18n' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { useLoginConfig } from '../../providers/use-login-config.js' +import { + ApplicationDescription, + ApplicationLeftFooter, + ApplicationRightFooter, + ApplicationTitle, + LanguageSelect, + PoweredByDHIS2, +} from '../customizable-elements.jsx' + +const mockRefreshOnTranslation = jest.fn() + +const LANGUAGE_SELECT_DEFAULT_VALUES = { + localesUI: [ + { locale: 'en', displayName: 'English', name: 'English' }, + { locale: 'fr', displayName: 'French', name: 'français' }, + { locale: 'cy', displayName: 'Welsh', name: 'Cymraeg' }, + { locale: 'dz', displayName: 'Dzongkha', name: 'རྫོང་ཁ་' }, + { locale: 'nv', displayName: 'Navajo', name: 'Diné bizaad' }, + { locale: 'wo', displayName: 'Wolof', name: 'Wolof làkk' }, + ], + uiLocale: 'cy', + systemLocale: 'en', +} + +jest.mock('../../providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(), +})) + +describe('LanguageSelect', () => { + it('displays uiLocale as selection', () => { + useLoginConfig.mockReturnValue({ + ...LANGUAGE_SELECT_DEFAULT_VALUES, + refreshOnTranslation: mockRefreshOnTranslation, + }) + render() + expect(screen.getByText('Cymraeg — Welsh')).toBeInTheDocument() + }) + + it('calls refreshOnTranslation', async () => { + const user = userEvent.setup() + useLoginConfig.mockReturnValue({ + ...LANGUAGE_SELECT_DEFAULT_VALUES, + refreshOnTranslation: mockRefreshOnTranslation, + }) + render() + + await user.click(screen.getByText('Cymraeg — Welsh')) + await user.click(screen.getByText('Wolof làkk — Wolof')) + expect(mockRefreshOnTranslation).toHaveBeenCalled() + expect(mockRefreshOnTranslation).toHaveBeenCalledWith({ locale: 'wo' }) + }) +}) + +describe('ApplicationTitle', () => { + it('shows value from useLoginConfig', () => { + useLoginConfig.mockReturnValue({ + applicationTitle: 'Little Red Riding Hood', + }) + render() + expect(screen.getByText('Little Red Riding Hood')).toBeInTheDocument() + }) +}) + +describe('ApplicationDescription', () => { + it('shows value from useLoginConfig', () => { + useLoginConfig.mockReturnValue({ + applicationDescription: + 'Wolf eats grandmother; grandaugther gets house', + }) + render() + expect( + screen.getByText('Wolf eats grandmother; grandaugther gets house') + ).toBeInTheDocument() + }) +}) + +describe('ApplicationLeftFooter', () => { + it('shows value from useLoginConfig', () => { + useLoginConfig.mockReturnValue({ + applicationLeftSideFooter: 'This way home', + }) + render() + expect(screen.getByText('This way home')).toBeInTheDocument() + }) +}) + +describe('ApplicationRightFooter', () => { + it('shows value from useLoginConfig', () => { + useLoginConfig.mockReturnValue({ + applicationRightSideFooter: "That way to grandma's", + }) + render() + expect(screen.getByText("That way to grandma's")).toBeInTheDocument() + }) +}) + +describe('PoweredByDHIS2', () => { + it('displays in translation', () => { + useLoginConfig.mockReturnValue({ + lngs: ['id', 'en'], + }) + const i18Spy = jest + .spyOn(i18n, 't') + .mockReturnValue('Dipersembahkan oleh DHIS2') + render() + expect(i18Spy).toHaveBeenCalled() + expect(i18Spy).toHaveBeenCalledWith('Powered by DHIS2', { + lngs: ['id', 'en'], + }) + expect( + screen.getByText('Dipersembahkan oleh DHIS2') + ).toBeInTheDocument() + }) +}) diff --git a/src/components/__tests__/login-links.test.jsx b/src/components/__tests__/login-links.test.jsx new file mode 100644 index 0000000..771d1ff --- /dev/null +++ b/src/components/__tests__/login-links.test.jsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react' +import React from 'react' +import { useLoginConfig } from '../../providers/use-login-config.js' +import { renderWithRouter } from '../../test-utils/render-with-router.jsx' +import { LoginLinks } from '../login-links.jsx' + +jest.mock('../../providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(), +})) + +describe('LoginLinks', () => { + it('shows link to reset password if allowAccountRecovery and emailConfigured', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + selfRegistrationEnabled: true, + }) + renderWithRouter() + expect(screen.getByText(/forgot password/i)).toBeInTheDocument() + expect( + screen.getByText(/forgot password/i).closest('a') + ).toHaveAttribute('href', '/reset-password') + }) + + it('shows link to reset password if allowAccountRecovery and emailConfigured with username if passed', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + selfRegistrationEnabled: true, + }) + renderWithRouter() + expect(screen.getByText(/forgot password/i)).toBeInTheDocument() + expect( + screen.getByText(/forgot password/i).closest('a') + ).toHaveAttribute('href', '/reset-password?username=Askepott') + }) + + it('does not show link to reset password if allowAccountRecovery false', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: false, + emailConfigured: true, + selfRegistrationEnabled: true, + }) + renderWithRouter() + expect(screen.queryByText(/forgot password/i)).toBe(null) + }) + + it('does not show link to reset password if emailConfigured false', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: false, + selfRegistrationEnabled: true, + }) + renderWithRouter() + expect(screen.queryByText(/forgot password/i)).toBe(null) + }) + + it('shows link to create account if selfRegistrationEnabled', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + selfRegistrationEnabled: true, + }) + renderWithRouter() + expect(screen.getByText(/create an account/i)).toBeInTheDocument() + expect( + screen.getByText(/create an account/i).closest('a') + ).toHaveAttribute('href', '/create-account') + }) + + it('does not show link to create account if selfRegistrationEnabled is false', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + selfRegistrationEnabled: false, + }) + renderWithRouter() + expect(screen.queryByText(/create an account/i)).toBe(null) + }) +}) diff --git a/src/components/account-creation-form.jsx b/src/components/account-creation-form.jsx new file mode 100644 index 0000000..0c7e20d --- /dev/null +++ b/src/components/account-creation-form.jsx @@ -0,0 +1,316 @@ +import i18n from '@dhis2/d2-i18n' +import { + ReactFinalForm, + InputFieldFF, + Button, + ButtonStrip, + IconErrorFilled24, + colors, +} from '@dhis2/ui' +import { + createCharacterLengthRange, + dhis2Password, + dhis2Username, + email, + internationalPhoneNumber, +} from '@dhis2/ui-forms' +import PropTypes from 'prop-types' +import React, { useEffect } from 'react' +import ReCAPTCHA from 'react-google-recaptcha' +import { Link } from 'react-router-dom' +import { BackToLoginButton } from '../components/back-to-login-button.jsx' +import { FormNotice } from '../components/form-notice.jsx' +import { FormSubtitle } from '../components/form-subtitle.jsx' +import { + getIsRequired, + removeHTMLTags, + composeAndTranslateValidators, +} from '../helpers/index.js' +import { useLoginConfig } from '../providers/index.js' +import styles from './account-creation-form.module.css' + +export const CREATE_FORM_TYPES = { + create: 'create', + confirm: 'confirm', +} + +const AccountFormSection = ({ children, title }) => ( +
+ {title &&

{title}

} + {children} +
+) + +AccountFormSection.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, + title: PropTypes.string, +} + +const RecaptchaWarning = ({ lngs }) => ( +
+ +
+ {i18n.t( + 'Please confirm that you are not a robot by checking the checkbox.', + { lngs } + )} +
+
+) + +RecaptchaWarning.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), +} + +const InnerCreateAccountForm = ({ + handleSubmit, + loading, + lngs, + prepopulatedFields, + emailConfigured, + recaptchaSite, + recaptchaRef, + recaptchaError, + selfRegistrationNoRecaptcha, +}) => { + const isRequired = getIsRequired(lngs?.[0]) + return ( +
+
+ + + + + + + + + + {!emailConfigured && ( + + )} + + {!selfRegistrationNoRecaptcha && ( + + + {recaptchaError && } + + )} +
+ + + + +
+ ) +} + +InnerCreateAccountForm.propTypes = { + emailConfigured: PropTypes.bool, + handleSubmit: PropTypes.func, + lngs: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + prepopulatedFields: PropTypes.object, + recaptchaError: PropTypes.bool, + recaptchaRef: PropTypes.node, + recaptchaSite: PropTypes.string, + selfRegistrationNoRecaptcha: PropTypes.bool, +} + +export const CreateAccountForm = ({ + createType, + prepopulatedFields, + loading, + error, + data, + handleRegister, + recaptchaRef, + recaptchaError, +}) => { + // depends on https://dhis2.atlassian.net/browse/DHIS2-14615 + const { + applicationTitle, + lngs, + emailConfigured, + recaptchaSite, + selfRegistrationNoRecaptcha, + } = useLoginConfig() + + useEffect(() => { + // we should scroll top top of the page when an error is registered, so user sees this + if (error) { + // this is not working? + window.scroll(0, 0) + } + }, [error]) + + return ( +
+ {!error && ( + +

+ {i18n.t( + 'Enter your details below to create a {{- applicationName}} account.', + { + lngs, + applicationName: + removeHTMLTags(applicationTitle), + } + )} +

+ {createType === CREATE_FORM_TYPES.create && ( +

+ {i18n.t('Already have an account?', { + lngs, + })}{' '} + {i18n.t('Log in.', { lngs })} +

+ )} +
+ )} + +
+ {error && ( + + {error?.message} + + )} + {data && ( + <> + + + {i18n.t( + 'You can use your username and password to log in.', + { lngs } + )} + + + + + )} + {!data && ( + + {({ handleSubmit }) => ( + + )} + + )} +
+
+ ) +} + +CreateAccountForm.propTypes = { + createType: PropTypes.string.isRequired, + handleRegister: PropTypes.func.isRequired, + data: PropTypes.object, + error: PropTypes.object, + loading: PropTypes.bool, + prepopulatedFields: PropTypes.object, + recaptchaError: PropTypes.bool, + recaptchaRef: PropTypes.node, +} diff --git a/src/components/application-notification.jsx b/src/components/application-notification.jsx new file mode 100644 index 0000000..71bdb23 --- /dev/null +++ b/src/components/application-notification.jsx @@ -0,0 +1,20 @@ +import { NoticeBox } from '@dhis2/ui' +import React from 'react' +import { convertHTML } from '../helpers/handleHTML.js' +import { useLoginConfig } from '../providers/index.js' +import styles from './application-notification.module.css' + +export const ApplicationNotification = () => { + const { applicationNotification } = useLoginConfig() + return ( + <> + {applicationNotification && ( +
+ + {convertHTML(applicationNotification)} + +
+ )} + + ) +} diff --git a/src/components/back-to-login-button.jsx b/src/components/back-to-login-button.jsx new file mode 100644 index 0000000..ddc3f8f --- /dev/null +++ b/src/components/back-to-login-button.jsx @@ -0,0 +1,29 @@ +import i18n from '@dhis2/d2-i18n' +import { Button } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { Link } from 'react-router-dom' +import { useLoginConfig } from '../providers/index.js' +import styles from './back-to-login-button.module.css' + +export const BackToLoginButton = ({ fullWidth, buttonText }) => { + const { uiLocale } = useLoginConfig() + return ( + <> + + + + + ) +} + +BackToLoginButton.propTypes = { + buttonText: PropTypes.string, + fullWidth: PropTypes.bool, +} diff --git a/src/components/customizable-elements.jsx b/src/components/customizable-elements.jsx new file mode 100644 index 0000000..caee44d --- /dev/null +++ b/src/components/customizable-elements.jsx @@ -0,0 +1,110 @@ +import { useConfig } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { SingleSelectField, SingleSelectOption } from '@dhis2/ui' +import React from 'react' +import { convertHTML } from '../helpers/index.js' +import { useLoginConfig } from '../providers/index.js' +import { DHIS2Logo } from './dhis2-logo.jsx' + +export const ApplicationTitle = () => { + const { applicationTitle } = useLoginConfig() + return applicationTitle ? ( + {convertHTML(applicationTitle)} + ) : null +} + +export const ApplicationDescription = () => { + const { applicationDescription } = useLoginConfig() + return applicationDescription ? ( + {convertHTML(applicationDescription)} + ) : null +} + +export const Flag = () => { + const { countryFlag } = useLoginConfig() + const { baseUrl } = useConfig() + + return countryFlag ? ( + flag + ) : null +} + +export const Logo = () => { + const { loginPageLayout, loginPageLogo } = useLoginConfig() + const { baseUrl } = useConfig() + + if (!loginPageLogo && loginPageLayout === 'SIDEBAR') { + return + } + + return ( + logo + ) +} + +export const ApplicationLeftFooter = () => { + const { applicationLeftSideFooter } = useLoginConfig() + return applicationLeftSideFooter ? ( + {convertHTML(applicationLeftSideFooter)} + ) : null +} + +export const ApplicationRightFooter = () => { + const { applicationRightSideFooter } = useLoginConfig() + return applicationRightSideFooter ? ( + {convertHTML(applicationRightSideFooter)} + ) : null +} + +export const PoweredByDHIS2 = () => { + const { lngs } = useLoginConfig() + return ( + + + + {i18n.t('Powered by DHIS2', { lngs })} + + + + ) +} + +export const LanguageSelect = () => { + const { refreshOnTranslation, localesUI, uiLocale, systemLocale } = + useLoginConfig() + + return ( + l.locale).includes(uiLocale) + ? uiLocale + : 'en' + } + onChange={({ selected }) => { + refreshOnTranslation({ locale: selected }) + }} + > + {localesUI && + localesUI.map((locale) => ( + + ))} + + ) +} diff --git a/src/components/dhis2-logo.jsx b/src/components/dhis2-logo.jsx new file mode 100644 index 0000000..5ff5c5b --- /dev/null +++ b/src/components/dhis2-logo.jsx @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types' +import React from 'react' + +function LogoSvg({ iconColor, textColor, className, dataTest }) { + return ( + + + + + + + + + + + + ) +} + +LogoSvg.propTypes = { + iconColor: PropTypes.string.isRequired, + textColor: PropTypes.string.isRequired, + className: PropTypes.string, + dataTest: PropTypes.string, +} + +const blue = '#0080d4' +const dark = '#212225' + +export const DHIS2Logo = ({ className, dataTest }) => ( + +) + +DHIS2Logo.defaultProps = { + dataTest: 'dhis2-uicore-logo', +} + +DHIS2Logo.propTypes = { + className: PropTypes.string, + dataTest: PropTypes.string, +} diff --git a/src/components/form-container.jsx b/src/components/form-container.jsx new file mode 100644 index 0000000..3a04959 --- /dev/null +++ b/src/components/form-container.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types' +import React from 'react' +import styles from './form-container.module.css' + +export const FormContainer = ({ children, title, variableWidth }) => ( +
+ {title &&

{title}

} + {children} +
+) + +FormContainer.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, + title: PropTypes.string, + variableWidth: PropTypes.bool, +} diff --git a/src/components/form-notice.jsx b/src/components/form-notice.jsx new file mode 100644 index 0000000..7d97df3 --- /dev/null +++ b/src/components/form-notice.jsx @@ -0,0 +1,27 @@ +import { NoticeBox } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './form-notice.module.css' + +export const FormNotice = ({ title, error, valid, children }) => ( +
+ + {children} + +
+) + +FormNotice.defaultProps = { + error: false, + valid: false, +} + +FormNotice.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]), + error: PropTypes.bool, + title: PropTypes.string, + valid: PropTypes.bool, +} diff --git a/src/components/form-subtitle.jsx b/src/components/form-subtitle.jsx new file mode 100644 index 0000000..fe241fb --- /dev/null +++ b/src/components/form-subtitle.jsx @@ -0,0 +1,14 @@ +import PropTypes from 'prop-types' +import React from 'react' +import styles from './form-subtitle.module.css' + +export const FormSubtitle = ({ children }) => { + return
{children}
+} + +FormSubtitle.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, +} diff --git a/src/components/loader.jsx b/src/components/loader.jsx new file mode 100644 index 0000000..b460392 --- /dev/null +++ b/src/components/loader.jsx @@ -0,0 +1,10 @@ +import { CenteredContent, CircularLoader } from '@dhis2/ui' +import React from 'react' + +const Loader = () => ( + + + +) + +export { Loader } diff --git a/src/components/login-links.jsx b/src/components/login-links.jsx new file mode 100644 index 0000000..592a245 --- /dev/null +++ b/src/components/login-links.jsx @@ -0,0 +1,47 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { Link } from 'react-router-dom' +import { useLoginConfig } from '../providers/index.js' +import styles from './login-links.module.css' + +export const LoginLinks = ({ formUserName }) => { + const { + allowAccountRecovery, + emailConfigured, + selfRegistrationEnabled, + lngs, + } = useLoginConfig() + + return ( + <> +
+ {allowAccountRecovery && emailConfigured && ( + + + {i18n.t('Forgot password?', { lngs })} + + + )} + {selfRegistrationEnabled && ( + + {i18n.t("Don't have an account?", { lngs })}{' '} + + {i18n.t('Create an account', { lngs })} + + + )} +
+ + ) +} + +LoginLinks.propTypes = { + formUserName: PropTypes.string, +} diff --git a/src/components/not-allowed-notice.jsx b/src/components/not-allowed-notice.jsx new file mode 100644 index 0000000..363d160 --- /dev/null +++ b/src/components/not-allowed-notice.jsx @@ -0,0 +1,45 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { BackToLoginButton } from './back-to-login-button.jsx' +import { FormContainer } from './form-container.jsx' +import { FormNotice } from './form-notice.jsx' + +export const NotAllowedNotice = ({ lngs }) => ( + + + + {i18n.t( + 'The requested page is not configured for your system', + { lngs } + )} + + + + +) + +NotAllowedNotice.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), +} + +export const NotAllowedNoticeCreateAccount = ({ lngs }) => ( + + + + {i18n.t( + 'Contact a system administrator to create an account.', + { lngs } + )} + + + + +) + +NotAllowedNoticeCreateAccount.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), +} diff --git a/src/components/oidc-login-options.jsx b/src/components/oidc-login-options.jsx new file mode 100644 index 0000000..1b5ebae --- /dev/null +++ b/src/components/oidc-login-options.jsx @@ -0,0 +1,52 @@ +import i18n from '@dhis2/d2-i18n' +import { Button } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { useLoginConfig } from '../providers/index.js' +import styles from './oidc-login-options.module.css' + +const loginTextStrings = { + login_with_google: i18n.t('Log in with Google'), + login_with_azure: i18n.t('Log in with Microsoft'), +} + +const SVGIcon = ({ baseUrl, endpoint }) => ( +
+ +
+) + +SVGIcon.propTypes = { + baseUrl: PropTypes.string, + endpoint: PropTypes.string, +} + +const redirectToOIDC = ({ baseUrl, endpoint }) => { + window.location.href = `${baseUrl}${endpoint}` +} + +export const OIDCLoginOptions = () => { + const { oidcProviders, baseUrl, lngs } = useLoginConfig() + if (!(oidcProviders?.length > 0)) { + return null + } + return ( +
+ {oidcProviders.map((oidc) => ( + + ))} +
+ ) +} diff --git a/src/components/pop-up.jsx b/src/components/pop-up.jsx new file mode 100644 index 0000000..19c3e73 --- /dev/null +++ b/src/components/pop-up.jsx @@ -0,0 +1,46 @@ +import i18n from '@dhis2/d2-i18n' +import { + Modal, + ModalContent, + ModalActions, + Button, + ButtonStrip, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useCallback, useState } from 'react' +import { convertHTML } from '../helpers/index.js' +import { useLoginConfig } from '../providers/index.js' + +const PopupContents = ({ popupHTML }) => { + const [popupOpen, setPopupOpen] = useState(true) + const closePopup = useCallback(() => { + setPopupOpen(false) + }, [setPopupOpen]) + if (!popupOpen) { + return null + } + return ( + + {convertHTML(popupHTML)} + + + + + + + ) +} + +PopupContents.propTypes = { + popupHTML: PropTypes.string, +} + +export const Popup = () => { + const { loginPopup } = useLoginConfig() + if (!loginPopup?.length) { + return null + } + return +} diff --git a/src/helpers/__tests__/handleHTML.test.jsx b/src/helpers/__tests__/handleHTML.test.jsx new file mode 100644 index 0000000..492e8ce --- /dev/null +++ b/src/helpers/__tests__/handleHTML.test.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import { convertHTML, sanitizeMainHTML, removeHTMLTags } from '../handleHTML.js' + +describe('sanitizeMainHTML', () => { + it('removes ' + const after = '
hello
another
' + expect(sanitizeMainHTML(before)).toBe(after) + }) + it('keeps
hello
another
' + const after = + '
hello
another
' + expect(sanitizeMainHTML(before)).toBe(after) + }) + it('fixes missing closing tags', () => { + const before = '

okay

not okay

' + const after = '

okay

not okay

' + expect(sanitizeMainHTML(before)).toBe(after) + }) +}) + +describe('convertHTML', () => { + it('converts into an array of HTML elements', () => { + const before = '
one

some content

' + const after = [ +
one
, +
+

some content

+
, + ] + expect(convertHTML(before)).toEqual(after) + }) +}) + +describe('removeHTMLTags', () => { + it('removes html tag from text', () => { + const before = + 'My wonderful DHIS2 instance' + const after = 'My wonderful DHIS2 instance' + expect(removeHTMLTags(before)).toBe(after) + }) +}) diff --git a/src/pages/__tests__/login.integration.test.jsx b/src/pages/__tests__/login.integration.test.jsx new file mode 100644 index 0000000..9d42f33 --- /dev/null +++ b/src/pages/__tests__/login.integration.test.jsx @@ -0,0 +1,116 @@ +import { CustomDataProvider } from '@dhis2/app-runtime' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PropTypes from 'prop-types' +import React from 'react' +import { MemoryRouter } from 'react-router-dom' +import LoginPage from '../login.jsx' + +const getCustomData = (statusMessage) => ({ + 'auth/login': { loginStatus: statusMessage }, +}) + +const login = async ({ user }) => { + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'Fl@klypa.no' }, + }) + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'SolanOgLudvig' }, + }) + await user.click(screen.getByRole('button', { name: /log in/i })) +} + +const Wrapper = ({ statusMessage, children }) => ( + + {children} + +) + +Wrapper.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, + statusMessage: PropTypes.string, +} + +describe('LoginForm', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('shows password expired messages if status is PASSWORD_EXPIRED', async () => { + const user = userEvent.setup() + render( + + + + ) + await login({ user }) + + expect(screen.getByText('Password expired')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) + + it('shows account not accessible message if status is ACCOUNT_DISABLED', async () => { + const user = userEvent.setup() + render( + + + + ) + await login({ user }) + + expect(screen.getByText('Account not accessible')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) + + it('shows account not accessible message if status is ACCOUNT_LOCKED', async () => { + const user = userEvent.setup() + render( + + + + ) + await login({ user }) + + expect(screen.getByText('Account not accessible')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) + + it('shows account not accessible message if status is ACCOUNT_EXPIRED', async () => { + const user = userEvent.setup() + render( + + + + ) + await login({ user }) + + expect(screen.getByText('Account not accessible')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) + + it('shows something went wrong message if status is not a known status', async () => { + const user = userEvent.setup() + render( + + + + ) + await login({ user }) + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) +}) diff --git a/src/pages/__tests__/login.test.jsx b/src/pages/__tests__/login.test.jsx new file mode 100644 index 0000000..b769e6d --- /dev/null +++ b/src/pages/__tests__/login.test.jsx @@ -0,0 +1,373 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { checkIsLoginFormValid } from '../../helpers/validators.js' +import { useLogin } from '../../hooks/useLogin.js' +import { useLoginConfig } from '../../providers/use-login-config.js' +import { LoginFormContainer } from '../login.jsx' + +jest.mock('../../helpers/validators.js', () => ({ + getIsRequired: () => () => null, + checkIsLoginFormValid: jest.fn(), +})) + +const mockLogin = jest.fn() + +jest.mock('../../hooks/useLogin.js', () => ({ + useLogin: jest.fn(() => ({ + login: mockLogin, + cancelTwoFA: () => {}, + twoFAVerificationRequired: false, + passwordExpired: false, + accountInaccessible: false, + })), +})) + +jest.mock('../../providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(() => ({ + allowAccountRecovery: false, + emailConfigured: false, + })), +})) + +jest.mock('../../components/index.js', () => ({ + ...jest.requireActual('../../components/index.js'), + LoginLinks: () =>

LOGIN LINKS

, + OIDCLoginOptions: () =>

OIDC LOGIN OPTIONS

, +})) + +describe('LoginForm', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('validates the form upon submission', () => { + render() + + fireEvent.click( + screen.getByRole('button', { + name: /log in/i, + }) + ) + expect(checkIsLoginFormValid).toHaveBeenCalled() + }) + + it('performs login on submission (if form valid) ', () => { + checkIsLoginFormValid.mockImplementation(() => true) + render() + + fireEvent.click(screen.getByRole('button')) + expect(mockLogin).toHaveBeenCalled() + }) + + it('does not perform login on submission (if form is not valid) ', () => { + checkIsLoginFormValid.mockImplementation(() => false) + render() + + fireEvent.click(screen.getByRole('button')) + expect(mockLogin).not.toHaveBeenCalled() + }) + + it('calls login function with username and password inputs provided by user', async () => { + const user = userEvent.setup() + + checkIsLoginFormValid.mockImplementation(() => true) + render() + + await user.type(screen.getByLabelText('Username'), 'KiKi') + await user.type(screen.getByLabelText('Password'), 'DeliveryService') + await user.click(screen.getByRole('button')) + + expect(mockLogin).toHaveBeenCalled() + expect(mockLogin).toHaveBeenCalledWith({ + password: 'DeliveryService', + twoFA: '', + username: 'KiKi', + }) + }) + + it('calls login function with username, password, and twofa inputs provided by user', async () => { + const user = userEvent.setup() + useLogin.mockReturnValue({ + login: mockLogin, + twoFAVerificationRequired: true, + cancelTwoFA: () => {}, + }) + + checkIsLoginFormValid.mockImplementation(() => true) + render() + + // populate form with username + password (this would need to be done ) + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'Tintin' }, + }) + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'Milou' }, + }) + + await user.type(screen.getByLabelText('Authentication code'), '123456') + await user.click( + screen.getByRole('button', { + name: /log in/i, + }) + ) + + expect(mockLogin).toHaveBeenCalled() + expect(mockLogin).toHaveBeenCalledWith({ + password: 'Milou', + twoFA: '123456', + username: 'Tintin', + }) + }) + + it('cancels twofa when cancel is clicked', () => { + const mockCancelTwoFA = jest.fn() + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: true, + cancelTwoFA: mockCancelTwoFA, + }) + render() + + fireEvent.click( + screen.getByRole('button', { + name: /cancel/i, + }) + ) + expect(mockCancelTwoFA).toHaveBeenCalled() + }) + + it('clears twoFA and password fields when twoFA is cancelled ', async () => { + const user = userEvent.setup() + const mockCancelTwoFA = jest.fn() + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: true, + cancelTwoFA: mockCancelTwoFA, + }) + render() + // populate form with username + password (this would need to be done ) + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'Bastian' }, + }) + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'Kardemomme' }, + }) + await user.click(screen.getByRole('button', { name: /log in/i })) + await user.type(screen.getByLabelText('Authentication code'), '123456') + await user.click(screen.getByRole('button', { name: /cancel/i })) + + expect(screen.getByLabelText('Username')).toHaveValue('Bastian') + expect(screen.getByLabelText('Authentication code')).toHaveValue('') + expect(screen.getByLabelText('Password')).toHaveValue('') + }) + + it('log in button is disabled if in loading state', async () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + loading: true, + }) + render() + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByText('Logging in...')).toBeInTheDocument() + }) + + // ideally would check visibility of fields in these states, but not working in tests due to jsdom interpretation of css + it('has header of "log in" if twoFAVerificationRequired is false', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + }) + render() + + expect(screen.getByRole('heading', { name: /log in/i })).not.toBe(null) + }) + + it('has header of "Two-factor authentication" if twoFAVerificationRequired is true', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: true, + cancelTwoFA: () => {}, + }) + render() + + expect( + screen.getByRole('heading', { name: /two-factor authentication/i }) + ).not.toBe(null) + }) + + it('shows incorrect username error on 401 error ', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + error: { details: { httpStatusCode: 401 } }, + }) + render() + + expect(screen.getByText('Incorrect username or password')).toBeVisible() + }) + + it('shows something went wrong on error if 501 error, with message ', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + error: { + details: { httpStatusCode: 501 }, + message: 'Sorry about that', + }, + }) + render() + + expect(screen.getByText('Something went wrong')).toBeVisible() + expect(screen.getByText('Sorry about that')).toBeVisible() + }) + + it('shows something went wrong if status code is not defined by error', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + error: { message: "I'm not a teapot, I'm an error message" }, + }) + render() + + expect(screen.getByText('Something went wrong')).toBeVisible() + expect( + screen.getByText("I'm not a teapot, I'm an error message") + ).toBeVisible() + }) + + it('does not show inputs if login function is not defined ', () => { + useLogin.mockReturnValue({ login: null }) + render() + + expect(screen.queryAllByRole('textbox')).toHaveLength(0) + }) + + it('hides login links and oidc login options if two fa required ', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: true, + cancelTwoFA: () => {}, + }) + render() + + expect(screen.queryByText('LOGIN LINKS')).toBe(null) + expect(screen.queryByText('OIDC LOGIN OPTIONS')).toBe(null) + }) + + it('show login links and oidc login options if two fa not (yet) required ', () => { + useLogin.mockReturnValue({ + login: () => {}, + twoFAVerificationRequired: false, + cancelTwoFA: () => {}, + }) + render() + + expect(screen.getByText('LOGIN LINKS')).toBeInTheDocument() + expect(screen.getByText('OIDC LOGIN OPTIONS')).toBeInTheDocument() + }) + + it('Shows link to password-reset page if passwordExpired and allowAccountRecovery and emailConfigured are true', () => { + useLogin.mockReturnValue({ + login: () => {}, + passwordExpired: true, + cancelTwoFA: () => {}, + }) + useLoginConfig.mockReturnValueOnce({ + allowAccountRecovery: true, + emailConfigured: true, + }) + // needs MemoryRouter because link is from react-router-dom + render( + + + + ) + + expect(screen.getByText('Password expired')).toBeInTheDocument() + expect( + screen.getByRole('link', { + name: 'You can reset your password from the password reset page.', + }) + ).toHaveAttribute('href', '/reset-password') + }) + + it('Shows password expired but no link to password-reset page if passwordExpired and allowAccountRecovery is false', () => { + useLogin.mockReturnValue({ + login: () => {}, + passwordExpired: true, + cancelTwoFA: () => {}, + }) + useLoginConfig.mockReturnValueOnce({ + allowAccountRecovery: false, + emailConfigured: true, + }) + + render() + + expect(screen.getByText('Password expired')).toBeInTheDocument() + expect( + screen.queryByRole('link', { + name: 'You can reset your password from the password reset page.', + }) + ).not.toBeInTheDocument() + }) + + it('Shows password expired but no link to password-reset page if passwordExpired and emailConfigured is false', () => { + useLogin.mockReturnValue({ + login: () => {}, + passwordExpired: true, + cancelTwoFA: () => {}, + }) + useLoginConfig.mockReturnValueOnce({ + allowAccountRecovery: true, + emailConfigured: false, + }) + + render() + + expect(screen.getByText('Password expired')).toBeInTheDocument() + expect( + screen.queryByRole('link', { + name: 'You can reset your password from the password reset page.', + }) + ).not.toBeInTheDocument() + }) + + it('Shows Account not accessible if accountInaccessible is true', () => { + useLogin.mockReturnValue({ + login: () => {}, + accountInaccessible: true, + cancelTwoFA: () => {}, + }) + + render() + + expect(screen.getByText('Account not accessible')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) + + it('Shows Something went wrong if unknownStatus is true', () => { + useLogin.mockReturnValue({ + login: () => {}, + unknownStatus: true, + cancelTwoFA: () => {}, + }) + + render() + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect( + screen.getByText('Contact your system administrator.') + ).toBeInTheDocument() + }) +}) diff --git a/src/pages/__tests__/password-reset-request.test.jsx b/src/pages/__tests__/password-reset-request.test.jsx new file mode 100644 index 0000000..575e31c --- /dev/null +++ b/src/pages/__tests__/password-reset-request.test.jsx @@ -0,0 +1,150 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { useLoginConfig } from '../../providers/use-login-config.js' +import { renderWithRouter } from '../../test-utils/render-with-router.jsx' +import PasswordResetRequestPage from '../password-reset-request.jsx' + +jest.mock('../../components/not-allowed-notice.js', () => ({ + NotAllowedNotice: () =>
NOT ALLOWED
, +})) + +jest.mock('../../components/back-to-login-button.js', () => ({ + BackToLoginButton: () =>
BACK_TO_LOGIN
, +})) + +const mockParamsGet = jest.fn() + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: jest.fn(() => [ + { + get: mockParamsGet, + }, + ]), +})) + +const mockMutate = jest.fn() + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useDataMutation: jest.fn(() => [ + mockMutate, + { loading: false, fetching: false, error: false, data: null }, + ]), +})) + +jest.mock('../../providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(), +})) + +describe('PasswordResetRequestPage', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('shows back to login button if page is available', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + expect(screen.getByText('BACK_TO_LOGIN')).toBeInTheDocument() + }) + + it('has mutation that points to auth/forgotPassword', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + expect(useDataMutation).toHaveBeenCalledWith( + expect.objectContaining({ resource: 'auth/forgotPassword' }) + ) + }) + + it('calls mutation when reset password is clicked', async () => { + const user = userEvent.setup() + mockParamsGet.mockReturnValue(null) + + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + await user.type( + screen.getByLabelText('Username or email'), + 'Snorkmaiden' + ) + await user.click( + screen.getByRole('button', { + name: /send password reset/i, + }) + ) + + expect(mockMutate).toHaveBeenCalled() + expect(mockMutate).toHaveBeenCalledWith({ + emailOrUsername: 'Snorkmaiden', + }) + }) + + it('displays not allowed notice if allowAccountRecovery:false', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: false, + emailConfigured: true, + }) + renderWithRouter() + expect(screen.getByText('NOT ALLOWED')).toBeInTheDocument() + }) + + it('displays not allowed notice if emailConfigured:false', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: false, + }) + renderWithRouter() + expect(screen.getByText('NOT ALLOWED')).toBeInTheDocument() + }) + + it('populates url from username parameters if one is provide', () => { + mockParamsGet.mockReturnValue('lakris') + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + expect(screen.getByDisplayValue('lakris')).toHaveAttribute( + 'id', + 'emailOrUsername' + ) + }) + + it('displays error message if error returned from mutation', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + useDataMutation.mockReturnValue([ + () => {}, + { error: new Error('some random error') }, + ]) + renderWithRouter() + expect(screen.getByText(/password reset failed/i)).toBeInTheDocument() + }) + + it('displays message about email sent if mutation succeeeds', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + useDataMutation.mockReturnValue([() => {}, { data: { success: true } }]) + renderWithRouter() + expect( + screen.getByText(/you will soon receive an email/i) + ).toBeInTheDocument() + }) +}) diff --git a/src/pages/__tests__/password-update.test.jsx b/src/pages/__tests__/password-update.test.jsx new file mode 100644 index 0000000..19a5f70 --- /dev/null +++ b/src/pages/__tests__/password-update.test.jsx @@ -0,0 +1,141 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { useLoginConfig } from '../../providers/use-login-config.js' +import { renderWithRouter } from '../../test-utils/render-with-router.jsx' +import PasswordUpdatePage from '../password-update.jsx' + +jest.mock('../../components/not-allowed-notice.js', () => ({ + NotAllowedNotice: () =>
NOT ALLOWED
, +})) + +const mockParamsGet = jest.fn((param) => { + if (param === 'token') { + return 'subway' + } + return null +}) + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: jest.fn(() => [ + { + get: mockParamsGet, + }, + ]), +})) + +const mockMutate = jest.fn() + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useDataMutation: jest.fn(() => [ + mockMutate, + { loading: false, fetching: false, error: false, data: null }, + ]), +})) + +jest.mock('../../providers/use-login-config.js', () => ({ + useLoginConfig: jest.fn(), +})) + +describe('PasswordUpdateForm', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('has mutation that points to auth/passwordReset', () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + expect(useDataMutation).toHaveBeenCalledWith( + expect.objectContaining({ resource: 'auth/passwordReset' }) + ) + }) + + it('calls mutation with valid password and token from url parameter', async () => { + const user = userEvent.setup() + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + await user.type(screen.getByLabelText('Password'), 'V3ry_$ecure_') + await user.click( + screen.getByRole('button', { + name: /save new password/i, + }) + ) + expect(mockMutate).toHaveBeenCalled() + expect(mockMutate).toHaveBeenCalledWith({ + newPassword: 'V3ry_$ecure_', + token: 'subway', + }) + }) + + it('does not call mutation when reset password is clicked if password input has invalid password', async () => { + const user = userEvent.setup() + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + renderWithRouter() + + await user.type( + screen.getByLabelText('Password'), + 'does not meet requirements' + ) + await user.click( + screen.getByRole('button', { + name: /save new password/i, + }) + ) + expect(mockMutate).not.toHaveBeenCalled() + }) + + it('displays not allowed notice if allowAccountRecovery:false', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: false, + emailConfigured: true, + }) + renderWithRouter() + expect(screen.getByText('NOT ALLOWED')).toBeInTheDocument() + }) + + it('displays not allowed notice if emailConfigured:false', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: false, + }) + renderWithRouter() + expect(screen.getByText('NOT ALLOWED')).toBeInTheDocument() + }) + + it('displays error message if error returned from mutation', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + useDataMutation.mockReturnValue([ + () => {}, + { error: new Error('some random error') }, + ]) + renderWithRouter() + expect(screen.getByText(/new password not saved/i)).toBeInTheDocument() + }) + + it('displays message about email sent if mutation succeeeds', async () => { + useLoginConfig.mockReturnValue({ + allowAccountRecovery: true, + emailConfigured: true, + }) + useDataMutation.mockReturnValue([() => {}, { data: { success: true } }]) + renderWithRouter() + expect(screen.getByText(/New password saved/i)).toBeInTheDocument() + }) +}) diff --git a/src/pages/complete-registration.jsx b/src/pages/complete-registration.jsx new file mode 100644 index 0000000..3735213 --- /dev/null +++ b/src/pages/complete-registration.jsx @@ -0,0 +1,115 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { + BackToLoginButton, + CreateAccountForm, + CREATE_FORM_TYPES, + FormContainer, + FormNotice, + NotAllowedNotice, +} from '../components/index.js' +import { useGetErrorIfNotAllowed } from '../hooks/index.js' +import { useLoginConfig } from '../providers/index.js' + +const selfRegisterMutation = { + resource: 'auth/invite', + type: 'create', + data: (data) => data, +} + +const CompleteRegistrationFormWrapper = ({ lngs }) => { + // depends on https://dhis2.atlassian.net/browse/DHIS2-14617 + const { selfRegistrationNoRecaptcha } = useLoginConfig() + const recaptchaRef = useRef() + const [recaptchaError, setRecaptchaError] = useState(false) + const [completeInvitation, { loading, fetching, error, data }] = + useDataMutation(selfRegisterMutation) + + const [searchParams] = useSearchParams() + const token = searchParams.get('token') + const email = searchParams.get('email') + const username = searchParams.get('username') + + if (!token || !email || !username) { + return ( + <> + + + {i18n.t( + 'Information required to process your registration is missing. Please contact your system administrator.' + )} + + + + + ) + } + + const prepopulatedFields = { email, username } + + const handleCompleteRegistration = (values) => { + setRecaptchaError(false) + const gRecaptchaResponse = selfRegistrationNoRecaptcha + ? null + : recaptchaRef.current.getValue() + if (!selfRegistrationNoRecaptcha && !gRecaptchaResponse) { + setRecaptchaError(true) + return + } + completeInvitation( + selfRegistrationNoRecaptcha + ? { ...values, token } + : { + ...values, + token, + 'g-recaptcha-response': gRecaptchaResponse, + } + ) + } + return ( + + ) +} + +CompleteRegistrationFormWrapper.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), +} + +const requiredPropsForCreateAccount = ['emailConfigured'] + +const CompleteRegistrationPage = () => { + const { lngs } = useLoginConfig() + const { notAllowed } = useGetErrorIfNotAllowed( + requiredPropsForCreateAccount + ) + + if (notAllowed) { + return + } + + return ( + + + + ) +} + +export default CompleteRegistrationPage diff --git a/src/pages/create-account.jsx b/src/pages/create-account.jsx new file mode 100644 index 0000000..f9c480d --- /dev/null +++ b/src/pages/create-account.jsx @@ -0,0 +1,74 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import React, { useRef, useState } from 'react' +import { + CreateAccountForm, + CREATE_FORM_TYPES, + FormContainer, + NotAllowedNoticeCreateAccount, +} from '../components/index.js' +import { useGetErrorIfNotAllowed } from '../hooks/index.js' +import { useLoginConfig } from '../providers/index.js' + +const selfRegisterMutation = { + resource: 'auth/registration', + type: 'create', + data: (data) => data, +} + +const CreateAccountFormWrapper = () => { + const { selfRegistrationNoRecaptcha } = useLoginConfig() + const recaptchaRef = useRef() + const [recaptchaError, setRecaptchaError] = useState(false) + const [selfRegister, { loading, fetching, error, data }] = + useDataMutation(selfRegisterMutation) + + const handleSelfRegister = (values) => { + setRecaptchaError(false) + const gRecaptchaResponse = selfRegistrationNoRecaptcha + ? null + : recaptchaRef.current.getValue() + if (!selfRegistrationNoRecaptcha && !gRecaptchaResponse) { + setRecaptchaError(true) + return + } + selfRegister( + selfRegistrationNoRecaptcha + ? values + : { ...values, 'g-recaptcha-response': gRecaptchaResponse } + ) + } + return ( + + ) +} + +const requiredPropsForCreateAccount = ['selfRegistrationEnabled'] + +const CreateAccountPage = () => { + const { lngs } = useLoginConfig() + const { notAllowed } = useGetErrorIfNotAllowed( + requiredPropsForCreateAccount + ) + + if (notAllowed) { + return + } + + return ( + + + + ) +} + +export default CreateAccountPage diff --git a/src/pages/download-html.jsx b/src/pages/download-html.jsx new file mode 100644 index 0000000..fe2979b --- /dev/null +++ b/src/pages/download-html.jsx @@ -0,0 +1,58 @@ +import i18n from '@dhis2/d2-i18n' +import { Button } from '@dhis2/ui' +import React from 'react' +import { FormContainer } from '../components/index.js' +import { standard, sidebar } from '../templates/index.js' +import styles from './download-html.module.css' + +const downloadHTML = (htmlString, htmlName) => { + const blob = htmlString + // Create blob link to download + const url = window.URL.createObjectURL(new Blob([blob])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', `${htmlName}.html`) + + document.body.appendChild(link) + + link.click() + + link.parentNode.removeChild(link) +} + +const DownloadPage = () => { + return ( + +
+

+ {i18n.t( + 'You can download the templates used by the login app here. These can be modified and reloaded as custom HTML in the settings app.' + )} +

+

{i18n.t('Refer to the documentation for more details.')}

+
+
+
+ +
+
+ +
+
+
+ ) +} + +export default DownloadPage diff --git a/src/pages/login.jsx b/src/pages/login.jsx new file mode 100644 index 0000000..eecdc30 --- /dev/null +++ b/src/pages/login.jsx @@ -0,0 +1,366 @@ +import i18n from '@dhis2/d2-i18n' +import { ReactFinalForm, InputFieldFF, Button } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState, useRef } from 'react' +import { useForm } from 'react-final-form' +import { Link } from 'react-router-dom' +import { + ApplicationNotification, + FormContainer, + FormNotice, + FormSubtitle, + LoginLinks, + OIDCLoginOptions, +} from '../components/index.js' +import { checkIsLoginFormValid, getIsRequired } from '../helpers/index.js' +import { useLogin } from '../hooks/index.js' +import { useLoginConfig } from '../providers/index.js' +import styles from './login.module.css' + +export default function LoginPage() { + return ( + <> + + + + ) +} + +const LoginErrors = ({ + lngs, + error, + twoFAIncorrect, + passwordExpired, + passwordResetEnabled, + accountInaccessible, + unknownStatus, +}) => { + if (error) { + return ( + = 500 + ? i18n.t('Something went wrong', { + lngs, + }) + : i18n.t('Incorrect username or password', { + lngs, + }) + } + error + > + {(!error.details?.httpStatusCode || + error.details.httpStatusCode >= 500) && ( + {error?.message} + )} + + ) + } + + if (twoFAIncorrect) { + return ( + + ) + } + if (passwordExpired) { + return ( + + {passwordResetEnabled ? ( + + {i18n.t( + 'You can reset your password from the password reset page.' + )} + + ) : ( + i18n.t('Contact your system administrator.') + )} + + ) + } + if (accountInaccessible) { + return ( + + {i18n.t('Contact your system administrator.')} + + ) + } + if (unknownStatus) { + return ( + + {i18n.t('Contact your system administrator.')} + + ) + } + return null +} + +LoginErrors.propTypes = { + accountInaccessible: PropTypes.bool, + error: PropTypes.object, + lngs: PropTypes.arrayOf(PropTypes.string), + passwordExpired: PropTypes.bool, + passwordResetEnabled: PropTypes.bool, + twoFAIncorrect: PropTypes.bool, + unknownStatus: PropTypes.bool, +} + +const InnerLoginForm = ({ + handleSubmit, + formSubmitted, + twoFAVerificationRequired, + cancelTwoFA, + lngs, + loading, + setFormUserName, +}) => { + const form = useForm() + const ref = useRef() + const clearTwoFA = () => { + form.change('password', undefined) + form.change('twoFA', undefined) + form.focus() + cancelTwoFA() + ref?.current?.focus() + } + const loginButtonText = twoFAVerificationRequired + ? i18n.t('Verify and log in', { lngs }) + : i18n.t('Log in', { lngs }) + const login2FAButtonText = twoFAVerificationRequired + ? i18n.t('Verifying...', { lngs }) + : i18n.t('Logging in...', { lngs }) + const isRequired = getIsRequired(lngs[0]) + return ( +
+
+ {/* onChange will not update every change, so may need to use controlled InputField here for username tracking */} + { + setFormUserName(e.value) + }} + readOnly={loading} + /> + +
+ +
+ +
+
+ + {twoFAVerificationRequired && ( + + )} +
+
+ ) +} + +InnerLoginForm.defaultProps = { + lngs: ['en'], +} + +InnerLoginForm.propTypes = { + cancelTwoFA: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + formSubmitted: PropTypes.bool, + lngs: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + setFormUserName: PropTypes.func, + twoFAVerificationRequired: PropTypes.bool, +} + +const LoginForm = ({ + login, + cancelTwoFA, + twoFAVerificationRequired, + twoFAIncorrect, + accountInaccessible, + passwordExpired, + passwordResetEnabled, + unknownStatus, + error, + loading, + setFormUserName, + lngs, +}) => { + const [formSubmitted, setFormSubmitted] = useState(false) + + if (!login) { + return null + } + + const handleLogin = (values) => { + setFormSubmitted(true) + if (!checkIsLoginFormValid(values)) { + return + } + login({ + username: values.username, + password: values.password, + twoFA: values.twoFA, + }) + } + + return ( + <> + + + + {({ handleSubmit }) => ( + + )} + + + ) +} + +LoginForm.defaultProps = { + lngs: ['en'], +} + +LoginForm.propTypes = { + accountInaccessible: PropTypes.bool, + cancelTwoFA: PropTypes.func, + error: PropTypes.object, + lngs: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + login: PropTypes.func, + passwordExpired: PropTypes.bool, + passwordResetEnabled: PropTypes.bool, + setFormUserName: PropTypes.func, + twoFAIncorrect: PropTypes.bool, + twoFAVerificationRequired: PropTypes.bool, + unknownStatus: PropTypes.bool, +} + +// this is set up this way to isolate styling from login form logic +export const LoginFormContainer = () => { + const { + login, + cancelTwoFA, + twoFAVerificationRequired, + twoFAIncorrect, + accountInaccessible, + passwordExpired, + unknownStatus, + error, + loading, + } = useLogin() + const [formUserName, setFormUserName] = useState('') + const { lngs, allowAccountRecovery, emailConfigured } = useLoginConfig() + + return ( + + {twoFAVerificationRequired && ( + +

+ {i18n.t( + 'Enter the code from your two-factor authentication app to log in.', + { lngs } + )} +

+
+ )} + + {!twoFAVerificationRequired && ( + <> + + + + )} +
+ ) +} diff --git a/src/pages/password-reset-request.jsx b/src/pages/password-reset-request.jsx new file mode 100644 index 0000000..75457a2 --- /dev/null +++ b/src/pages/password-reset-request.jsx @@ -0,0 +1,179 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { Button, ReactFinalForm, InputFieldFF } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { + BackToLoginButton, + FormContainer, + FormNotice, + FormSubtitle, + NotAllowedNotice, +} from '../components/index.js' +import { getIsRequired } from '../helpers/index.js' +import { useGetErrorIfNotAllowed } from '../hooks/index.js' +import { useLoginConfig } from '../providers/index.js' +import styles from './password-reset-request.module.css' + +const passwordResetRequestMutation = { + resource: 'auth/forgotPassword', + type: 'create', + data: ({ emailOrUsername }) => ({ emailOrUsername }), +} + +const InnerPasswordResetRequestForm = ({ + handleSubmit, + formSubmitted, + isRequired, + lngs, + loading, +}) => { + const [params] = useSearchParams() + + return ( +
+
+ +
+
+ + +
+
+ ) +} + +InnerPasswordResetRequestForm.propTypes = { + formSubmitted: PropTypes.bool, + handleSubmit: PropTypes.func, + isRequired: PropTypes.func, + lngs: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, +} + +export const PasswordResetRequestForm = ({ lngs }) => { + // depends on https://dhis2.atlassian.net/browse/DHIS2-14618 + const [resetPasswordRequest, { loading, fetching, error, data }] = + useDataMutation(passwordResetRequestMutation) + const [formSubmitted, setFormSubmitted] = useState(false) + const isRequired = getIsRequired(lngs?.[0]) + + const handlePasswordResetRequest = (values) => { + setFormSubmitted(true) + const validationError = isRequired(values.emailOrUsername) + if (validationError) { + return + } + resetPasswordRequest({ emailOrUsername: values.emailOrUsername }) + } + return ( + <> + {error && ( + + + {i18n.t( + 'Something went wrong. Please try again later, and contact your system administrator if the problem persists.', + { lngs } + )} + + + )} + {data && ( + <> + + + {i18n.t( + 'If the provided username or email is registered in the system, you will soon receive an email with a password reset link.', + { lngs } + )} + + + + + )} + {!data && ( + + {({ handleSubmit }) => ( + + )} + + )} + + ) +} + +PasswordResetRequestForm.defaultProps = { + lngs: ['en'], +} + +PasswordResetRequestForm.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), +} + +const requiredPropsForPasswordReset = [ + 'allowAccountRecovery', + 'emailConfigured', +] + +const PasswordResetRequestPage = () => { + const { lngs } = useLoginConfig() + const { notAllowed } = useGetErrorIfNotAllowed( + requiredPropsForPasswordReset + ) + + if (notAllowed) { + return + } + + return ( + + +

+ {i18n.t( + 'Enter your username below, a link to reset your password will be sent to your registered e-mail.', + { lngs } + )} +

+
+ +
+ ) +} + +export default PasswordResetRequestPage diff --git a/src/pages/password-update.jsx b/src/pages/password-update.jsx new file mode 100644 index 0000000..df71f9a --- /dev/null +++ b/src/pages/password-update.jsx @@ -0,0 +1,173 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { Button, ReactFinalForm, InputFieldFF } from '@dhis2/ui' +import { dhis2Password } from '@dhis2/ui-forms' +import PropTypes from 'prop-types' +import React from 'react' +import { useSearchParams } from 'react-router-dom' +import { + BackToLoginButton, + FormContainer, + FormNotice, + FormSubtitle, + NotAllowedNotice, +} from '../components/index.js' +import { + getIsRequired, + composeAndTranslateValidators, +} from '../helpers/index.js' +import { useGetErrorIfNotAllowed } from '../hooks/index.js' +import { useLoginConfig } from '../providers/index.js' +import styles from './password-update.module.css' + +const passwordUpdateMutation = { + resource: 'auth/passwordReset', + type: 'create', + data: (data) => ({ ...data }), +} + +const InnerPasswordUpdateForm = ({ handleSubmit, lngs, loading }) => { + const isRequired = getIsRequired(lngs?.[0]) + + return ( +
+
+ +
+
+ + +
+
+ ) +} + +InnerPasswordUpdateForm.propTypes = { + handleSubmit: PropTypes.func, + lngs: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, +} + +export const PasswordUpdateForm = ({ token, lngs }) => { + // depends on https://dhis2.atlassian.net/browse/DHIS2-14618 + const [updatePassword, { loading, fetching, error, data }] = + useDataMutation(passwordUpdateMutation) + + const handlePasswordUpdate = (values) => { + updatePassword({ newPassword: values.password, token }) + } + return ( + <> +
+
+ {error && ( + + + {i18n.t( + 'There was a problem saving your password. Try again or contact your system administrator.', + { lngs } + )} + + + )} + {data && ( + <> + + + {i18n.t( + 'New password saved. You can use it to log in to your account.', + { lngs } + )} + + + + + )} + {!data && ( + + {({ handleSubmit }) => ( + + )} + + )} +
+
+ + ) +} + +PasswordUpdateForm.defaultProps = { + lngs: ['en'], +} + +PasswordUpdateForm.propTypes = { + lngs: PropTypes.arrayOf(PropTypes.string), + token: PropTypes.string, +} + +// presumably these would need to be allowed +const requiredPropsForPasswordReset = [ + 'allowAccountRecovery', + 'emailConfigured', +] + +const PasswordUpdatePage = () => { + const { lngs } = useLoginConfig() + const [searchParams] = useSearchParams() + const token = searchParams.get('token') || '' + // display error if token is invalid? + const { notAllowed } = useGetErrorIfNotAllowed( + requiredPropsForPasswordReset + ) + + if (notAllowed) { + return + } + + return ( + + +

+ {i18n.t('Enter the new password for your account below', { + lngs, + })} +

+
+ +
+ ) +} + +export default PasswordUpdatePage diff --git a/src/pages/safe-mode.jsx b/src/pages/safe-mode.jsx new file mode 100644 index 0000000..59be7a2 --- /dev/null +++ b/src/pages/safe-mode.jsx @@ -0,0 +1,33 @@ +import i18n from '@dhis2/d2-i18n' +import { Button } from '@dhis2/ui' +import React, { useEffect } from 'react' +import { useLoginConfig } from '../providers/index.js' + +const SAFE_MODE_ENDPOINT = 'dhis-web-commons/security/login.action' + +const SafeModePage = () => { + const { baseUrl, lngs } = useLoginConfig() + const safeURL = `${baseUrl}/${SAFE_MODE_ENDPOINT}` + + useEffect(() => { + window.location.href = `${baseUrl}/${SAFE_MODE_ENDPOINT}` + }, [baseUrl]) + + return ( + <> +

+ {i18n.t( + 'You should shortly be redirected. If you are not redirected, please click redirect button.', + { lngs } + )} +

+ + + ) +} + +export default SafeModePage diff --git a/src/providers/__tests__/use-login-config.test.jsx b/src/providers/__tests__/use-login-config.test.jsx new file mode 100644 index 0000000..612dc41 --- /dev/null +++ b/src/providers/__tests__/use-login-config.test.jsx @@ -0,0 +1,223 @@ +/* eslint-disable react/prop-types */ +import { renderHook } from '@testing-library/react' +import React from 'react' +import { LoginConfigProvider } from '../login-config-provider.jsx' +import { useLoginConfig } from '../use-login-config.js' + +const mockEngineQuery = jest.fn() + +const TEST_LOCALES = [ + { locale: 'ar', displayName: 'Arabic', name: 'العربية' }, + { locale: 'en', displayName: 'English', name: 'English' }, + { locale: 'nb', displayName: 'Norwegian', name: 'norsk' }, + { locale: 'fr', displayName: 'French', name: 'français' }, +] + +const TEST_TRANSLATIONS = { + en: { + applicationDescription: "Don't forget a towel", + applicationLeftSideFooter: + 'At the end of the universe, on the left side', + applicationRightSideFooter: + 'At the end of the universe, on the right side', + applicationNotification: 'The meaning of the universe is 42', + applicationTitle: "The Hithchiker's guide to DHIS2", + }, + nb: { + applicationDescription: 'Glem ikke håndkle', + applicationLeftSideFooter: 'Der universet slutter, på venstre siden', + applicationRightSideFooter: 'Der universet slutter, på høyre siden', + applicationTitle: 'Haikerens guide til DHIS2', + }, + fr: { + applicationNotification: + "La réponse à la grande question de l'univers et tout ça: 42", + applicationTitle: 'Le guide du voyageur DHIS2', + }, + es: { + applicationTitle: 'Guía del autoestopista DHIS2', + }, +} + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useDataEngine: () => ({ query: mockEngineQuery }), + useDataQuery: jest.fn((query, variables) => { + if (query?.loginConfig?.resource === 'loginConfig') { + return { + data: { + loginConfig: { + ...TEST_TRANSLATIONS[ + variables?.variables?.locale ?? 'en' + ], + }, + }, + loading: false, + error: null, + } + } + return { + data: { localesUI: { ...TEST_LOCALES } }, + loading: false, + error: null, + } + }), +})) + +describe('useAppContext', () => { + const wrapper = ({ children }) => ( + {children} + ) + + const createWrapperWithLocation = ({ initialLocation }) => { + const WrapperWithLocation = ({ children }) => ( + + {children} + + ) + return WrapperWithLocation + } + + afterEach(() => { + jest.clearAllMocks() + localStorage.clear() + }) + + it('has refreshOnTranslation function', () => { + const { result } = renderHook(() => useLoginConfig(), { wrapper }) + + expect(result.current).toHaveProperty('refreshOnTranslation') + expect(typeof result.current.refreshOnTranslation).toBe('function') + }) + + // note: system language is English by default (if not provided by api) + it('updates uiLocale and lngs (with fallback language) on translation, keeps systemLocale unchanged ', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + expect(result.current.systemLocale).toBe('en') + expect(result.current.uiLocale).toBe('en') + expect(result.current.lngs).toEqual(['en']) + result.current.refreshOnTranslation({ locale: 'pt_BR' }) + await waitForNextUpdate() + expect(result.current.systemLocale).toBe('en') + expect(result.current.uiLocale).toBe('pt_BR') + expect(result.current.lngs).toEqual(['pt_BR', 'pt']) + }) + + it('updates translatable values on translation', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + mockEngineQuery.mockResolvedValue({ + loginConfig: { ...TEST_TRANSLATIONS.nb }, + }) + + result.current.refreshOnTranslation({ locale: 'nb' }) + await waitForNextUpdate() + expect(result.current.applicationDescription).toBe('Glem ikke håndkle') + // if value is not translated, keeps previous value + expect(result.current.applicationNotification).toBe( + 'The meaning of the universe is 42' + ) + }) + + // note: this test confirms the INCORRECT behaviour in the app + // app should fall back to default system locale values, but will persist last fetched translations + // this does not cause problems because the api returns the default values + it('falls back to default system language values on subsequent translations', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + mockEngineQuery.mockResolvedValueOnce({ + loginConfig: { ...TEST_TRANSLATIONS.nb }, + }) + + result.current.refreshOnTranslation({ locale: 'nb' }) + await waitForNextUpdate() + expect(result.current.applicationLeftSideFooter).toBe( + 'Der universet slutter, på venstre siden' + ) + + mockEngineQuery.mockResolvedValueOnce({ + loginConfig: { ...TEST_TRANSLATIONS.fr }, + }) + result.current.refreshOnTranslation({ locale: 'fr' }) + await waitForNextUpdate() + expect(result.current.applicationLeftSideFooter).toBe( + 'Der universet slutter, på venstre siden' + ) + expect(result.current.applicationTitle).toBe( + 'Le guide du voyageur DHIS2' + ) + }) + + it('persists language in local storage as ui language on refreshOnTranslation', async () => { + const spySetItem = jest.spyOn(Storage.prototype, 'setItem') + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + result.current.refreshOnTranslation({ locale: 'zh' }) + await waitForNextUpdate() + expect(spySetItem).toHaveBeenCalled() + expect(spySetItem).toHaveBeenCalledWith('dhis2.locale.ui', 'zh') + }) + + it('updates document direction on refreshOnTranslation (if applicable)', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + // uiLocale is 'en' by default, hence dir is 'ltr' + expect(document.dir).toBe('ltr') + result.current.refreshOnTranslation({ locale: 'ar' }) + await waitForNextUpdate() + expect(document.dir).toBe('rtl') + result.current.refreshOnTranslation({ locale: 'fr' }) + await waitForNextUpdate() + expect(document.dir).toBe('ltr') + }) + + it('updates document direction on refreshOnTranslation (if applicable) and handles locales', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useLoginConfig(), + { wrapper } + ) + // uiLocale is 'en' by default, hence dir is 'ltr' + expect(document.dir).toBe('ltr') + result.current.refreshOnTranslation({ locale: 'fa_IR' }) + await waitForNextUpdate() + expect(document.dir).toBe('rtl') + result.current.refreshOnTranslation({ locale: 'fr_CA' }) + await waitForNextUpdate() + expect(document.dir).toBe('ltr') + }) + + it('uses language persisted in local storage as ui language when first loaded', () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('es') + const { result } = renderHook(() => useLoginConfig(), { wrapper }) + expect(result.current.uiLocale).toBe('es') + expect(result.current.applicationTitle).toBe( + 'Guía del autoestopista DHIS2' + ) + }) + + it('has hashRedirect location as undefined by default', () => { + const { result } = renderHook(() => useLoginConfig(), { wrapper }) + expect(result.current.hashRedirect).toBe(undefined) + }) + + it('has hashRedirect determined provided window location', () => { + const { result } = renderHook(() => useLoginConfig(), { + wrapper: createWrapperWithLocation({ + initialLocation: + 'https://myInstance.org/path/to/myApp/#/hashpath', + }), + }) + expect(result.current.hashRedirect).toBe('#/hashpath') + }) +}) diff --git a/src/providers/login-config-provider.jsx b/src/providers/login-config-provider.jsx new file mode 100644 index 0000000..3c39d78 --- /dev/null +++ b/src/providers/login-config-provider.jsx @@ -0,0 +1,158 @@ +import { useDataQuery, useDataEngine, useConfig } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { Loader } from '../components/loader.jsx' +import { + getHashFromLocation, + parseLocale, + getLngsArray, +} from '../helpers/index.js' +import { LoginConfigContext } from './login-config-context.js' + +const localStorageLocaleKey = 'dhis2.locale.ui' + +const loginConfigQuery = { + loginConfig: { + resource: 'loginConfig', + params: ({ locale }) => ({ + paging: false, + locale, + }), + }, +} + +const localesQuery = { + localesUI: { + resource: 'locales/ui', + }, +} + +const translatableValues = [ + 'applicationDescription', + 'applicationLeftSideFooter', + 'applicationRightSideFooter', + 'applicationNotification', + 'applicationTitle', +] + +// defaults in case locales/ui fails +const defaultLocales = [ + { locale: 'ar', displayName: 'Arabic', name: 'العربية' }, + { locale: 'en', displayName: 'English', name: 'English' }, + { locale: 'fr', displayName: 'French', name: 'français' }, + { locale: 'pt', displayName: 'Portuguese', name: 'português' }, + { locale: 'es', displayName: 'Spanish', name: 'español' }, +] + +const LoginConfigProvider = ({ initialLocation, children }) => { + const { + data: loginConfigData, + loading: loginConfigLoading, + error: loginConfigError, + } = useDataQuery(loginConfigQuery, { + variables: { locale: localStorage.getItem(localStorageLocaleKey) }, + }) + const { + data: localesData, + loading: localesLoading, + error: localesError, + } = useDataQuery(localesQuery) + const config = useConfig() + + const hashRedirect = getHashFromLocation(initialLocation) + + const [translatedValues, setTranslatedValues] = useState() + + useEffect(() => { + // if there is a stored language, set it as i18next language + const userLanguage = + localStorage.getItem(localStorageLocaleKey) || + loginConfigData?.loginConfig?.uiLocale || + 'en' + + setTranslatedValues({ + uiLocale: userLanguage, + lngs: getLngsArray(userLanguage), + }) + // direction will not be recognized if using a java-format locale code + i18n.changeLanguage(parseLocale(userLanguage)) + document.documentElement.setAttribute('dir', i18n.dir()) + }, []) //eslint-disable-line + + const engine = useDataEngine() + + const refreshOnTranslation = async ({ locale }) => { + if (!engine) { + return + } + let updatedValues + try { + updatedValues = await engine.query(loginConfigQuery, { + variables: { locale }, + }) + } catch (e) { + console.error(e) + } + // direction will not be recognized if using a java-format locale code + i18n.changeLanguage(parseLocale(locale)) + + document.documentElement.setAttribute('dir', i18n.dir()) + + // the logic here is wrong as it falls back to previous translations (rather than defaults) + // however, the api response will fall back to default system language (so this doesn't cause issues) + const updatedTranslations = translatableValues.reduce( + (translations, currentTranslationKey) => { + if (updatedValues?.loginConfig?.[currentTranslationKey]) { + translations[currentTranslationKey] = + updatedValues?.loginConfig?.[currentTranslationKey] + } + + return translations + }, + {} + ) + setTranslatedValues({ + ...updatedTranslations, + uiLocale: locale, + lngs: getLngsArray(locale), + }), + localStorage.setItem(localStorageLocaleKey, locale) + } + + if (loginConfigLoading || localesLoading) { + return + } + + // the app will function without the appearance settings, so just console error + if (loginConfigError) { + console.error(loginConfigError) + } + + if (localesError) { + console.error(localesError) + } + + const providerValue = { + ...loginConfigData?.loginConfig, + hashRedirect, + ...translatedValues, + localesUI: localesData?.localesUI ?? defaultLocales, + systemLocale: loginConfigData?.loginConfig?.uiLocale ?? 'en', + baseUrl: config?.baseUrl, + refreshOnTranslation, + } + + return ( + +
{children}
+
+ ) +} + +LoginConfigProvider.propTypes = { + children: PropTypes.node.isRequired, + initialLocation: PropTypes.string, +} + +export { LoginConfigProvider } diff --git a/src/test-utils/render-with-router.jsx b/src/test-utils/render-with-router.jsx new file mode 100644 index 0000000..8c9be3f --- /dev/null +++ b/src/test-utils/render-with-router.jsx @@ -0,0 +1,6 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { MemoryRouter } from 'react-router-dom' + +export const renderWithRouter = (component) => + render({component})