From a52cab02fb8ff8b463896477f10a67d2bc06db48 Mon Sep 17 00:00:00 2001 From: Thomas Zemp Date: Wed, 15 Jan 2025 11:48:46 +0100 Subject: [PATCH] feat: add email verification in user list [DHIS2-18613] --- i18n/en.pot | 22 +++- src/hooks/useFeatureToggle.js | 12 ++ src/hooks/useFeatureToggle.test.js | 46 +++++++ src/pages/UserList/Filters.js | 30 ++++- src/pages/UserList/UserList.js | 55 +++++++- src/pages/UserList/UserList.test.js | 182 +++++++++++++++++++++++++++ src/pages/UserList/UserTable.js | 17 +++ src/pages/UserList/UserTable.test.js | 74 +++++++++++ src/pages/UserList/useFilters.js | 7 ++ 9 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useFeatureToggle.js create mode 100644 src/hooks/useFeatureToggle.test.js create mode 100644 src/pages/UserList/UserList.test.js diff --git a/i18n/en.pot b/i18n/en.pot index 42c951692..eee26c51c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-12-18T07:54:11.061Z\n" -"PO-Revision-Date: 2024-12-18T07:54:11.062Z\n" +"POT-Creation-Date: 2025-01-15T10:47:12.485Z\n" +"PO-Revision-Date: 2025-01-15T10:47:12.486Z\n" msgid "Yes" msgstr "Yes" @@ -973,6 +973,18 @@ msgstr "{{months}} months" msgid "Time inactive" msgstr "Time inactive" +msgid "Email verification" +msgstr "Email verification" + +msgid "All" +msgstr "All" + +msgid "Email verified" +msgstr "Email verified" + +msgid "Email not verified" +msgstr "Email not verified" + msgid "Invitation" msgstr "Invitation" @@ -1006,6 +1018,12 @@ msgstr "Last login" msgid "Status" msgstr "Status" +msgid "Verified" +msgstr "Verified" + +msgid "Not verified" +msgstr "Not verified" + msgid "Disabled" msgstr "Disabled" diff --git a/src/hooks/useFeatureToggle.js b/src/hooks/useFeatureToggle.js new file mode 100644 index 000000000..35f64bcc4 --- /dev/null +++ b/src/hooks/useFeatureToggle.js @@ -0,0 +1,12 @@ +import { useConfig } from '@dhis2/app-runtime' + +export const useFeatureToggle = () => { + const config = useConfig() + const minorVersion = config?.serverVersion?.minor + const emailConfigured = config?.systemInfo?.emailConfigured + return { + displayEmailVerifiedStatus: Boolean( + emailConfigured && Number(minorVersion) >= 42 + ), + } +} diff --git a/src/hooks/useFeatureToggle.test.js b/src/hooks/useFeatureToggle.test.js new file mode 100644 index 000000000..1625f9baa --- /dev/null +++ b/src/hooks/useFeatureToggle.test.js @@ -0,0 +1,46 @@ +import { useConfig } from '@dhis2/app-runtime' +import { renderHook } from '@testing-library/react-hooks' +import { useFeatureToggle } from './useFeatureToggle.js' + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(), +})) + +describe('useFeatureToggle', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('has displayEmailVerifiedStatus:false if email is not configured', () => { + useConfig.mockReturnValue({ + serverVersion: { minor: '42' }, + systemInfo: { emailConfigured: false }, + }) + const { result } = renderHook(() => useFeatureToggle()) + expect(result.current.displayEmailVerifiedStatus).toBe(false) + }) + + it('has displayEmailVerifiedStatus:false if api version is 41 or earlier ', () => { + useConfig.mockReturnValue({ + serverVersion: { minor: '41' }, + systemInfo: { emailConfigured: true }, + }) + const { result } = renderHook(() => useFeatureToggle()) + expect(result.current.displayEmailVerifiedStatus).toBe(false) + }) + + it('has displayEmailVerifiedStatus:false if api version is 42 or greater and email is configured ', () => { + useConfig.mockReturnValue({ + serverVersion: { minor: '42' }, + systemInfo: { emailConfigured: true }, + }) + const { result } = renderHook(() => useFeatureToggle()) + expect(result.current.displayEmailVerifiedStatus).toBe(true) + }) + + it('has displayEmailVerifiedStatus:false if config is missing information', () => { + useConfig.mockReturnValue({}) + const { result } = renderHook(() => useFeatureToggle()) + expect(result.current.displayEmailVerifiedStatus).toBe(false) + }) +}) diff --git a/src/pages/UserList/Filters.js b/src/pages/UserList/Filters.js index 15d424434..cc6eaa247 100644 --- a/src/pages/UserList/Filters.js +++ b/src/pages/UserList/Filters.js @@ -60,7 +60,10 @@ const Filters = ({ onSelfRegisteredChange, organisationUnits, onOrganisationUnitsChange, + emailVerificationStatus, + onEmailVerificationStatusChange, onClear, + displayEmailVerifiedStatus, }) => (
+ {displayEmailVerifiedStatus && ( + + onEmailVerificationStatusChange(selected) + } + className={styles.input} + dense + > + + + + + )} { + const organisationUnitsFilter = + organisationUnits.length > 0 + ? `organisationUnits.id:in:[${organisationUnits.map( + ({ id }) => id + )}]` + : undefined + // email verification filter is ignored if feature is not accessible + const emailVerificationFilter = + !displayEmailVerifiedStatus || + !['true', 'false'].includes(emailVerificationStatus) + ? undefined + : `emailVerified:eq:${emailVerificationStatus}` + + if ( + organisationUnitsFilter === undefined && + emailVerificationFilter === undefined + ) { + return undefined + } + return [organisationUnitsFilter, emailVerificationFilter] + .filter((f) => f) + .join(',') +} + const usersQuery = { users: { resource: 'users', @@ -28,12 +58,15 @@ const usersQuery = { selfRegistered, nameSortDirection, organisationUnits, + emailVerificationStatus, + displayEmailVerifiedStatus, }) => ({ fields: [ 'id', 'displayName', 'access', 'email', + 'emailVerified', 'twoFactorEnabled', 'username', 'disabled', @@ -52,12 +85,11 @@ const usersQuery = { inactiveMonths, invitationStatus, selfRegistered, - filter: - organisationUnits.length > 0 - ? `organisationUnits.id:in:[${organisationUnits.map( - ({ id }) => id - )}]` - : undefined, + filter: getUsersQueryFilters({ + organisationUnits, + emailVerificationStatus, + displayEmailVerifiedStatus, + }), }), }, } @@ -85,9 +117,12 @@ const UserList = () => { toggleNameSortDirection, organisationUnits, setOrganisationUnits, + emailVerificationStatus, + setEmailVerificationStatus, clearFilters, } = useFilters() const [debouncedQuery] = useDebounce(query, 375) + const { displayEmailVerifiedStatus } = useFeatureToggle() const refetchUsers = () => { setPrevUsers(users) refetch({ @@ -99,6 +134,8 @@ const UserList = () => { selfRegistered, nameSortDirection, organisationUnits, + emailVerificationStatus, + displayEmailVerifiedStatus, }) } @@ -113,6 +150,8 @@ const UserList = () => { selfRegistered, nameSortDirection, JSON.stringify(organisationUnits), + emailVerificationStatus, + displayEmailVerifiedStatus, ]) return ( @@ -129,7 +168,10 @@ const UserList = () => { onSelfRegisteredChange={setSelfRegistered} organisationUnits={organisationUnits} onOrganisationUnitsChange={setOrganisationUnits} + emailVerificationStatus={emailVerificationStatus} + onEmailVerificationStatusChange={setEmailVerificationStatus} onClear={clearFilters} + displayEmailVerifiedStatus={displayEmailVerifiedStatus} />
@@ -148,6 +190,7 @@ const UserList = () => { refetch={refetchUsers} nameSortDirection={nameSortDirection} onNameSortDirectionToggle={toggleNameSortDirection} + displayEmailVerifiedStatus={displayEmailVerifiedStatus} /> {(loading ? prevUsers?.users.length > 0 diff --git a/src/pages/UserList/UserList.test.js b/src/pages/UserList/UserList.test.js new file mode 100644 index 000000000..e2b767780 --- /dev/null +++ b/src/pages/UserList/UserList.test.js @@ -0,0 +1,182 @@ +import { Provider, CustomDataProvider } from '@dhis2/app-runtime' +import { render, screen, within, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PropTypes from 'prop-types' +import React from 'react' +import { MemoryRouter, Route } from 'react-router-dom' +import { QueryParamProvider } from 'use-query-params' +import { useFeatureToggle } from '../../hooks/useFeatureToggle.js' +import UserList from './UserList.js' + +jest.mock('../../hooks/useFeatureToggle.js', () => ({ + useFeatureToggle: jest.fn(), +})) + +const CONFIG_DEFAULTS = { + baseUrl: 'https://debug.dhis2.org/dev', + apiVersion: '42', + systemInfo: { + serverTimeZoneId: 'Etc/UTC', + }, +} + +const mockUsersGet = jest.fn() + +const DEFAULT_USERS_RESPONSE = { + users: [ + { + id: 'user-1', + displayName: 'User One', + access: { + read: true, + update: true, + }, + username: 'user1', + lastLogin: '2021-10-15T12:34:56Z', + disabled: false, + emailVerified: true, + }, + { + id: 'user-2', + displayName: 'Another User', + access: { + read: true, + update: true, + }, + username: 'user2', + lastLogin: '2021-09-14T12:34:56Z', + disabled: true, + emailVerified: false, + }, + ], +} + +const CUSTOM_PROVIDER_DATA = { + users: (type, query) => { + mockUsersGet(query) + return DEFAULT_USERS_RESPONSE + }, +} + +const EXPECTED_QUERY = { + resource: 'users', + id: undefined, + data: undefined, + params: { + fields: [ + 'id', + 'displayName', + 'access', + 'email', + 'emailVerified', + 'twoFactorEnabled', + 'username', + 'disabled', + 'lastLogin', + 'teiSearchOrganisationUnits[id,path]', + ], + order: ['firstName:asc', 'surname:asc'], + userOrgUnits: true, + includeChildren: true, + page: 1, + pageSize: 50, + query: '', + inactiveMonths: undefined, + invitationStatus: undefined, + selfRegistered: false, + filter: '', + }, +} + +const RenderWrapper = ({ children }) => ( + + + + + {children} + + + + +) + +RenderWrapper.propTypes = { + children: PropTypes.node, +} + +describe('UserList', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('does not show email verification filter if not feature toggled', () => { + useFeatureToggle.mockReturnValue({ displayEmailVerifiedStatus: false }) + render( + + + + ) + expect(screen.queryByText('Email verification')).toBe(null) + }) + + it('shows email verification filter with options if feature toggled', async () => { + useFeatureToggle.mockReturnValue({ displayEmailVerifiedStatus: true }) + render( + + + + ) + const EMAIL_VERIFICATION_TEXT = 'Email verification' + const emailVerificationDropdown = await screen.findByText( + EMAIL_VERIFICATION_TEXT + ) + await waitFor(() => { + userEvent.click(emailVerificationDropdown) + }) + const email_verify_options = within( + await screen.findByTestId('dhis2-uicore-select-menu-menuwrapper') + ).getAllByTestId('dhis2-uicore-singleselectoption') + expect(email_verify_options).toHaveLength(3) + + expect(screen.getByText('All')).toBeInTheDocument() + expect(screen.getByText('Email verified')).toBeInTheDocument() + expect(screen.getByText('Email not verified')).toBeInTheDocument() + }) + + it.each([ + ['Email verified', 'emailVerified:eq:true'], + ['Email not verified', 'emailVerified:eq:false'], + ['All', undefined], + ])( + 'fires request when %s is selected with filter value of %s', + async (filterOption, resultingFilter) => { + useFeatureToggle.mockReturnValue({ + displayEmailVerifiedStatus: true, + }) + render( + + + + ) + const EMAIL_VERIFICATION_TEXT = 'Email verification' + const emailVerificationDropdown = await screen.findByText( + EMAIL_VERIFICATION_TEXT + ) + await waitFor(() => { + userEvent.click(emailVerificationDropdown) + }) + + await waitFor(() => { + userEvent.click(screen.getByText(filterOption)) + }) + + expect(screen.getByText(filterOption)).toBeInTheDocument() + expect(mockUsersGet).toHaveBeenCalledTimes(2) + const filteredQuery = { ...EXPECTED_QUERY } + filteredQuery.params.filter = resultingFilter + expect(mockUsersGet).toHaveBeenNthCalledWith(2, filteredQuery) + } + ) +}) diff --git a/src/pages/UserList/UserTable.js b/src/pages/UserList/UserTable.js index a2a059aa8..4130ac702 100644 --- a/src/pages/UserList/UserTable.js +++ b/src/pages/UserList/UserTable.js @@ -28,9 +28,11 @@ const UserTable = ({ refetch, nameSortDirection, onNameSortDirectionToggle, + displayEmailVerifiedStatus, }) => { const { fromServerDate } = useTimeZoneConversion() const { setReferrer } = useReferrerInfo() + if (loading && !users) { return ( @@ -89,6 +91,11 @@ const UserTable = ({ {i18n.t('Last login')} + {displayEmailVerifiedStatus && ( + + {i18n.t('Email verification')} + + )} {i18n.t('Status')} @@ -106,6 +113,7 @@ const UserTable = ({ username, lastLogin, disabled, + emailVerified, } = user const lastLoginClient = fromServerDate(lastLogin) @@ -133,6 +141,14 @@ const UserTable = ({ )} + {displayEmailVerifiedStatus && ( + + {emailVerified + ? i18n.t('Verified') + : i18n.t('Not verified')} + + )} + {disabled ? i18n.t('Disabled') @@ -153,6 +169,7 @@ const UserTable = ({ } UserTable.propTypes = { + displayEmailVerifiedStatus: PropTypes.bool.isRequired, nameSortDirection: PropTypes.oneOf(['asc', 'desc']).isRequired, refetch: PropTypes.func.isRequired, onNameSortDirectionToggle: PropTypes.func.isRequired, diff --git a/src/pages/UserList/UserTable.test.js b/src/pages/UserList/UserTable.test.js index 82a32d8e7..17bdebd16 100644 --- a/src/pages/UserList/UserTable.test.js +++ b/src/pages/UserList/UserTable.test.js @@ -30,6 +30,7 @@ describe('', () => { refetch={() => {}} nameSortDirection="asc" onNameSortDirectionToggle={() => {}} + displayEmailVerifiedStatus={false} /> ) @@ -47,6 +48,7 @@ describe('', () => { refetch={() => {}} nameSortDirection="asc" onNameSortDirectionToggle={() => {}} + displayEmailVerifiedStatus={false} /> ) @@ -67,6 +69,7 @@ describe('', () => { refetch={() => {}} nameSortDirection="asc" onNameSortDirectionToggle={() => {}} + displayEmailVerifiedStatus={false} /> ) @@ -108,6 +111,7 @@ describe('', () => { refetch={() => {}} nameSortDirection="asc" onNameSortDirectionToggle={() => {}} + displayEmailVerifiedStatus={false} /> ) @@ -200,6 +204,7 @@ describe('', () => { refetch={() => {}} nameSortDirection="asc" onNameSortDirectionToggle={toggleNameSortDirection} + displayEmailVerifiedStatus={false} /> ) @@ -210,4 +215,73 @@ describe('', () => { ) expect(toggleNameSortDirection).toHaveBeenCalled() }) + + it('renders email verification information if displayEmailVerifiedStatus is true ', () => { + const users = [ + { + id: 'user-1', + displayName: 'User One', + access: { + read: true, + update: true, + }, + username: 'user1', + lastLogin: '2021-10-15T12:34:56Z', + disabled: false, + emailVerified: true, + }, + { + id: 'user-2', + displayName: 'Another User', + access: { + read: true, + update: true, + }, + username: 'user2', + lastLogin: '2021-09-14T12:34:56Z', + disabled: true, + emailVerified: false, + }, + ] + + render( + {}} + nameSortDirection="asc" + onNameSortDirectionToggle={() => {}} + displayEmailVerifiedStatus={true} + /> + ) + + expect(screen.getAllByRole('columnheader')).toHaveLength(6) + expect( + screen.getByRole('columnheader', { name: 'Email verification' }) + ).toBeInTheDocument() + + const rows = within( + screen.getByTestId('dhis2-uicore-tablebody') + ).getAllByRole('row') + expect(rows).toHaveLength(users.length) + users.forEach((user, index) => { + const { emailVerified } = user + + const row = rows[index] + expect(within(row).getAllByRole('cell')).toHaveLength(6) + if (emailVerified) { + expect( + within(row).getByRole('cell', { + name: 'Verified', + }) + ).toBeInTheDocument() + } else { + expect( + within(row).queryByRole('cell', { + name: 'Not verified', + }) + ).toBeInTheDocument() + } + }) + }) }) diff --git a/src/pages/UserList/useFilters.js b/src/pages/UserList/useFilters.js index a35d5e99b..3de04792b 100644 --- a/src/pages/UserList/useFilters.js +++ b/src/pages/UserList/useFilters.js @@ -29,6 +29,10 @@ export const useFilters = () => { 'invitationStatus', StringParam ) + const [emailVerificationStatus, setEmailVerificationStatus] = useQueryParam( + 'emailVerification', + StringParam + ) const [selfRegistered, setSelfRegistered] = useQueryParam( 'selfRegistered', withDefault(BooleanParam, false) @@ -47,6 +51,8 @@ export const useFilters = () => { setOrganisationUnits: withClearPager(setOrganisationUnits), inactiveMonths, setInactiveMonths: withClearPager(setInactiveMonths), + emailVerificationStatus, + setEmailVerificationStatus: withClearPager(setEmailVerificationStatus), invitationStatus, setInvitationStatus: withClearPager(setInvitationStatus), selfRegistered, @@ -59,6 +65,7 @@ export const useFilters = () => { setOrganisationUnits([]) setInactiveMonths() setInvitationStatus() + setEmailVerificationStatus() }, } }