diff --git a/i18n/en.pot b/i18n/en.pot
index 42c95169..eee26c51 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 00000000..35f64bcc
--- /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 00000000..1625f9ba
--- /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 15d42443..cc6eaa24 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 00000000..e2b76778
--- /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 a2a059aa..4130ac70 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 82a32d8e..17bdebd1 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 a35d5e99..3de04792 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()
},
}
}