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 (
+
+ )
+}
+
+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 (
+ <>
+
+
+ {buttonText ??
+ i18n.t('Back to log in page', { lng: uiLocale })}
+
+
+ >
+ )
+}
+
+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 ? (
+
+ ) : null
+}
+
+export const Logo = () => {
+ const { loginPageLayout, loginPageLogo } = useLoginConfig()
+ const { baseUrl } = useConfig()
+
+ if (!loginPageLogo && loginPageLayout === 'SIDEBAR') {
+ return
+ }
+
+ return (
+
+ )
+}
+
+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) => (
+ }
+ key={oidc?.id}
+ onClick={() => {
+ redirectToOIDC({ baseUrl, endpoint: oidc?.url })
+ }}
+ >
+ {loginTextStrings[oidc?.loginText]
+ ? i18n.t(loginTextStrings[oidc.loginText], {
+ lngs,
+ })
+ : oidc?.loginText}
+
+ ))}
+
+ )
+}
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)}
+
+
+
+ {i18n.t('I agree')}
+
+
+
+
+ )
+}
+
+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 = ''
+ const after = ''
+ expect(sanitizeMainHTML(before)).toBe(after)
+ })
+})
+
+describe('convertHTML', () => {
+ it('converts into an array of HTML elements', () => {
+ const before = 'one
'
+ const after = [
+ one
,
+ ,
+ ]
+ 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.')}
+
+
+
+ {
+ downloadHTML(standard, 'standard')
+ }}
+ >
+ {i18n.t('Download default template')}
+
+
+
+ {
+ downloadHTML(sidebar, 'sidebar')
+ }}
+ >
+ {i18n.t('Download sidebar template')}
+
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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} )