diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8afbd280..4ed8f906 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,24 @@
+## [29.16.2](https://github.com/dhis2/settings-app/compare/v29.16.1...v29.16.2) (2024-02-12)
+
+
+### Bug Fixes
+
+* add back OAUTH2 [DHIS2-15326] ([#1300](https://github.com/dhis2/settings-app/issues/1300)) ([520b1c7](https://github.com/dhis2/settings-app/commit/520b1c76fd8705eb311813c1bea0a9277aef8759))
+
+## [29.16.1](https://github.com/dhis2/settings-app/compare/v29.16.0...v29.16.1) (2024-01-24)
+
+
+### Bug Fixes
+
+* remove keyAnalyticsMaintenanceMode [DHIS2-16534] ([#1296](https://github.com/dhis2/settings-app/issues/1296)) ([d9fdbc8](https://github.com/dhis2/settings-app/commit/d9fdbc8a502c0636353df368f26ebb9459e32e00))
+
+# [29.16.0](https://github.com/dhis2/settings-app/compare/v29.15.9...v29.16.0) (2024-01-23)
+
+
+### Features
+
+* add in scheduling settings to settings app [DHIS2-15765] ([#1295](https://github.com/dhis2/settings-app/issues/1295)) ([ba6f2a1](https://github.com/dhis2/settings-app/commit/ba6f2a17c364b16c967a9f548ed718163179b58b))
+
## [29.15.9](https://github.com/dhis2/settings-app/compare/v29.15.8...v29.15.9) (2023-12-14)
diff --git a/cypress/integration/oauth2.feature b/cypress/integration/oauth2.feature
new file mode 100644
index 00000000..4dd974d7
--- /dev/null
+++ b/cypress/integration/oauth2.feature
@@ -0,0 +1,18 @@
+Feature: Users should be able to add OAuth2 clients
+
+ Scenario: User adds OAuth2 client
+ Given the user visits the 'OAuth2 Clients' page
+ And the user clicks on the 'Add OAuth2 client' button
+ And the user enters the relevant details in the form that appears
+ Then a snackbar message should appear telling the user that the client was saved
+ And the page should show the new client in the table of clients
+
+ Scenario: No OAuth2 clients are present
+ Given the user visits the 'OAuth2 Clients' page
+ And there are no OAuth2 clients
+ Then the message 'There are currently no OAuth2 clients registered' should be shown
+
+ Scenario: Some OAuth2 clients are present
+ Given the user visits the 'OAuth2 Clients' page
+ And there are some OAuth2 clients
+ Then a table showing all clients should be present
diff --git a/d2.config.js b/d2.config.js
index 95fd5979..142b21ef 100644
--- a/d2.config.js
+++ b/d2.config.js
@@ -3,6 +3,7 @@ const config = {
name: 'settings',
title: 'Settings',
coreApp: true,
+ minDHIS2Version: '2.41',
entryPoints: {
app: './src/App.js',
diff --git a/i18n/en.pot b/i18n/en.pot
index 5793e553..14e2cd5d 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: 2023-12-15T15:25:33.200Z\n"
-"PO-Revision-Date: 2023-12-15T15:25:33.200Z\n"
+"POT-Creation-Date: 2024-02-09T16:13:02.251Z\n"
+"PO-Revision-Date: 2024-02-09T16:13:02.251Z\n"
msgid "Failed to load: {{error}}"
msgstr "Failed to load: {{error}}"
@@ -146,6 +146,12 @@ msgstr "Synchronization"
msgid "Synchronization settings"
msgstr "Synchronization settings"
+msgid "OAuth2 Clients"
+msgstr "OAuth2 Clients"
+
+msgid "Scheduled jobs"
+msgstr "Scheduled jobs"
+
msgid "This field is required"
msgstr "This field is required"
@@ -461,9 +467,6 @@ msgstr "Last 9 years"
msgid "Respect category option start and end date in analytics table export"
msgstr "Respect category option start and end date in analytics table export"
-msgid "Put analytics in maintenance mode"
-msgstr "Put analytics in maintenance mode"
-
msgid "Include zero data values in analytics tables"
msgstr "Include zero data values in analytics tables"
@@ -751,3 +754,23 @@ msgstr "Remote server username"
msgid "Remote server password"
msgstr "Remote server password"
+
+msgid "Number of minutes after which a stale job is reset to scheduled state (1-60)"
+msgstr "Number of minutes after which a stale job is reset to scheduled state (1-60)"
+
+msgid "Number of minutes after which a completed ad-hoc job is deleted (1+)"
+msgstr "Number of minutes after which a completed ad-hoc job is deleted (1+)"
+
+msgid ""
+"Maximum number of hours a job may trigger after its intended time if job "
+"has not yet run (1-24)"
+msgstr ""
+"Maximum number of hours a job may trigger after its intended time if job "
+"has not yet run (1-24)"
+
+msgid ""
+"Job execution interval (seconds) below which a job will be logged at debug "
+"(rather than info) level (20+)"
+msgstr ""
+"Job execution interval (seconds) below which a job will be logged at debug "
+"(rather than info) level (20+)"
diff --git a/package.json b/package.json
index b666cf64..9e2bbd02 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "settings-app",
- "version": "29.15.9",
+ "version": "29.16.2",
"description": "",
"license": "BSD-3-Clause",
"private": true,
diff --git a/src/oauth2-client-editor/ClientForm.js b/src/oauth2-client-editor/ClientForm.js
new file mode 100644
index 00000000..99791af1
--- /dev/null
+++ b/src/oauth2-client-editor/ClientForm.js
@@ -0,0 +1,162 @@
+import i18n from '@dhis2/d2-i18n'
+import { Button, Modal, ModalTitle, ModalContent } from '@dhis2/ui'
+import { getInstance as getD2 } from 'd2'
+import FormBuilder from 'd2-ui/lib/forms/FormBuilder.component.js'
+import { isUrlArray, isRequired } from 'd2-ui/lib/forms/Validators.js'
+import PropTypes from 'prop-types'
+import React from 'react'
+import MultiToggle from '../form-fields/multi-toggle.js'
+import TextField from '../form-fields/text-field.js'
+import styles from './ClientForm.module.css'
+
+const formFieldStyle = {
+ width: '100%',
+}
+
+const validateClientID = async (v) => {
+ const d2 = await getD2()
+ const list = await d2.models.oAuth2Clients.list({
+ paging: false,
+ filter: [`cid:eq:${v}`],
+ })
+ if (list.size > 0) {
+ throw i18n.t('This client ID is already taken')
+ }
+}
+
+const ClientForm = ({ clientModel, onUpdate, onSave, onCancel }) => {
+ const grantTypes = ((clientModel && clientModel.grantTypes) || []).reduce(
+ (curr, prev) => {
+ curr[prev] = true
+ return curr
+ },
+ {}
+ )
+
+ const fields = [
+ {
+ name: 'name',
+ value: clientModel.name,
+ component: TextField,
+ props: {
+ floatingLabelText: i18n.t('Name'),
+ style: formFieldStyle,
+ changeEvent: 'onBlur',
+ },
+ validators: [
+ {
+ validator: isRequired,
+ message: i18n.t('Required'),
+ },
+ ],
+ },
+ {
+ name: 'cid',
+ value: clientModel.cid,
+ component: TextField,
+ props: {
+ floatingLabelText: i18n.t('Client ID'),
+ style: formFieldStyle,
+ changeEvent: 'onBlur',
+ },
+ validators: [
+ {
+ validator: isRequired,
+ message: i18n.t('Required'),
+ },
+ {
+ validator: (v) => v.toString().trim().length > 0,
+ message: i18n.t('Required'),
+ },
+ ],
+ asyncValidators: [validateClientID],
+ },
+ {
+ name: 'secret',
+ value: clientModel && clientModel.secret,
+ component: TextField,
+ props: {
+ floatingLabelText: i18n.t('Client Secret'),
+ disabled: true,
+ style: formFieldStyle,
+ },
+ },
+ {
+ name: 'grantTypes',
+ component: MultiToggle,
+ style: formFieldStyle,
+ props: {
+ label: i18n.t('Grant Types'),
+ items: [
+ {
+ name: 'password',
+ text: i18n.t('Password'),
+ value: grantTypes.password,
+ },
+ {
+ name: 'refresh_token',
+ text: i18n.t('Refresh token'),
+ value: grantTypes.refresh_token,
+ },
+ {
+ name: 'authorization_code',
+ text: i18n.t('Authorization code'),
+ value: grantTypes.authorization_code,
+ },
+ ],
+ },
+ },
+ {
+ name: 'redirectUris',
+ value: (clientModel.redirectUris || []).join('\n'),
+ component: TextField,
+ props: {
+ hintText: i18n.t('One URL per line'),
+ floatingLabelText: i18n.t('Redirect URIs'),
+ multiLine: true,
+ style: formFieldStyle,
+ changeEvent: 'onBlur',
+ },
+ validators: [
+ {
+ validator: isUrlArray,
+ message: i18n.t('This field should contain a list of URLs'),
+ },
+ ],
+ },
+ ]
+
+ const headerText =
+ clientModel.id === undefined
+ ? i18n.t('Create new OAuth2 Client')
+ : i18n.t('Edit OAuth2 Client')
+ return (
+
+ {headerText}
+
+
+
+
+
+
+
+
+ )
+}
+
+ClientForm.propTypes = {
+ clientModel: PropTypes.object.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onUpdate: PropTypes.func.isRequired,
+}
+
+export default ClientForm
diff --git a/src/oauth2-client-editor/ClientForm.module.css b/src/oauth2-client-editor/ClientForm.module.css
new file mode 100644
index 00000000..8a4219fb
--- /dev/null
+++ b/src/oauth2-client-editor/ClientForm.module.css
@@ -0,0 +1,3 @@
+.cancelBtn {
+ float: right;
+}
diff --git a/src/oauth2-client-editor/ClientsList.js b/src/oauth2-client-editor/ClientsList.js
new file mode 100644
index 00000000..b76a3148
--- /dev/null
+++ b/src/oauth2-client-editor/ClientsList.js
@@ -0,0 +1,78 @@
+import i18n from '@dhis2/d2-i18n'
+import {
+ CenteredContent,
+ Table,
+ TableBody,
+ TableCell,
+ TableCellHead,
+ TableHead,
+ TableRow,
+ TableRowHead,
+ Button,
+} from '@dhis2/ui'
+import PropTypes from 'prop-types'
+import React from 'react'
+import styles from './ClientsList.module.css'
+
+const ClientsList = ({ clients, onClientEdit, onClientDelete }) => {
+ if (clients.length === 0) {
+ return (
+
+
+ {i18n.t('There are currently no OAuth2 clients registered')}
+
+
+ )
+ }
+
+ return (
+
+
+
+ {i18n.t('Name')}
+ {i18n.t('Password')}
+ {i18n.t('Refresh token')}
+
+ {i18n.t('Authorization code')}
+
+ {/* Buttons column */}
+
+
+
+ {clients.map((client) => (
+
+ {client.name}
+ {client.password}
+ {client.refresh_token}
+ {client.authorization_code}
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+ClientsList.propTypes = {
+ clients: PropTypes.array.isRequired,
+ onClientDelete: PropTypes.func.isRequired,
+ onClientEdit: PropTypes.func.isRequired,
+}
+
+export default ClientsList
diff --git a/src/oauth2-client-editor/ClientsList.module.css b/src/oauth2-client-editor/ClientsList.module.css
new file mode 100644
index 00000000..2269cda4
--- /dev/null
+++ b/src/oauth2-client-editor/ClientsList.module.css
@@ -0,0 +1,3 @@
+.editBtn {
+ margin-right: var(--spacers-dp16);
+}
diff --git a/src/oauth2-client-editor/OAuth2ClientEditor.component.js b/src/oauth2-client-editor/OAuth2ClientEditor.component.js
new file mode 100644
index 00000000..ce431a04
--- /dev/null
+++ b/src/oauth2-client-editor/OAuth2ClientEditor.component.js
@@ -0,0 +1,149 @@
+import i18n from '@dhis2/d2-i18n'
+import { CircularLoader, CenteredContent, Button } from '@dhis2/ui'
+import { getInstance as getD2 } from 'd2'
+import React, { Component } from 'react'
+import settingsActions from '../settingsActions.js'
+import ClientForm from './ClientForm.js'
+import ClientsList from './ClientsList.js'
+import oa2Actions from './oauth2Client.actions.js'
+import oa2ClientStore from './oauth2Client.store.js'
+import styles from './OAuth2ClientEditor.module.css'
+
+function generateSecret() {
+ const alphabet = '0123456789abcdef'
+ let uid = ''
+ for (let i = 0; i < 32; i++) {
+ uid += alphabet.charAt(Math.random() * alphabet.length)
+ if (i === 8 || i === 12 || i === 16 || i === 20) {
+ uid += '-'
+ }
+ }
+ return uid
+}
+
+class OAuth2ClientEditor extends Component {
+ state = {
+ showForm: false,
+ saving: false,
+ }
+
+ componentDidMount() {
+ this.subscriptions = []
+ this.subscriptions.push(
+ oa2ClientStore.subscribe(() => {
+ this.forceUpdate()
+ })
+ )
+
+ this.subscriptions.push(
+ oa2Actions.delete.subscribe(() => {
+ this.setState({ saving: false })
+ })
+ )
+
+ oa2Actions.load()
+ }
+
+ componentWillUnmount() {
+ this.subscriptions.forEach((sub) => {
+ sub.unsubscribe()
+ })
+ }
+
+ cancelAction = () => {
+ this.clientModel = undefined
+ oa2Actions.load()
+ this.setState({ showForm: false })
+ }
+
+ newAction = () => {
+ getD2().then((d2) => {
+ this.clientModel = d2.models.oAuth2Client.create()
+ this.clientModel.secret = generateSecret()
+ this.setState({ showForm: true })
+ })
+ }
+
+ editAction = (model) => {
+ this.clientModel = model
+ this.setState({ showForm: true })
+ }
+
+ deleteAction = (model) => {
+ this.setState({ showForm: false, saving: true })
+ oa2Actions.delete(model.id ? model : this.clientModel)
+ this.clientModel = undefined
+ }
+
+ saveAction = () => {
+ this.clientModel.name = this.clientModel.name || ''
+ this.clientModel.cid = this.clientModel.cid || ''
+ this.setState({ saving: true })
+ this.clientModel
+ .save()
+ .then((importReport) => {
+ if (importReport.status !== 'OK') {
+ throw new Error(importReport)
+ }
+
+ settingsActions.showSnackbarMessage(
+ i18n.t('OAuth2 client saved')
+ )
+ oa2Actions.load()
+ this.setState({ showForm: false, saving: false })
+ })
+ .catch(() => {
+ settingsActions.showSnackbarMessage(
+ i18n.t('Failed to save OAuth2 client')
+ )
+ this.setState({ saving: false })
+ })
+ }
+
+ formUpdateAction = (field, v) => {
+ let value = v
+ if (field === 'redirectUris') {
+ value = v.split('\n').filter((a) => a.trim().length > 0)
+ }
+ this.clientModel[field] = value
+ this.forceUpdate()
+ }
+
+ render() {
+ const clients = oa2ClientStore.state
+ if (!clients || this.state.saving) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {this.state.showForm && (
+
+ )}
+
+ )
+ }
+}
+
+export default OAuth2ClientEditor
diff --git a/src/oauth2-client-editor/OAuth2ClientEditor.module.css b/src/oauth2-client-editor/OAuth2ClientEditor.module.css
new file mode 100644
index 00000000..20d8fce6
--- /dev/null
+++ b/src/oauth2-client-editor/OAuth2ClientEditor.module.css
@@ -0,0 +1,7 @@
+.wrapper {
+ margin: var(--spacers-dp24) 0;
+}
+
+.addClientBtn {
+ margin-top: var(--spacers-dp24);
+}
diff --git a/src/oauth2-client-editor/oauth2Client.actions.js b/src/oauth2-client-editor/oauth2Client.actions.js
new file mode 100644
index 00000000..b7f28471
--- /dev/null
+++ b/src/oauth2-client-editor/oauth2Client.actions.js
@@ -0,0 +1,56 @@
+import i18n from '@dhis2/d2-i18n'
+import { getInstance as getD2 } from 'd2'
+import Action from 'd2-ui/lib/action/Action.js'
+import settingsActions from '../settingsActions.js'
+import oa2Store from './oauth2Client.store.js'
+
+const oa2Actions = Action.createActionsFromNames(['load', 'delete'])
+
+oa2Actions.load.subscribe(() => {
+ getD2().then((d2) => {
+ d2.models.oAuth2Client
+ .list({ paging: false, fields: ':all', order: 'displayName' })
+ .then((oa2ClientCollection) => {
+ const yes = i18n.t('Yes')
+ const no = i18n.t('No')
+ // Map grant types to object props in order to display them in the data table
+ oa2Store.setState(
+ oa2ClientCollection.toArray().map((oa2c) =>
+ Object.assign(oa2c, {
+ password:
+ oa2c.grantTypes.indexOf('password') !== -1
+ ? yes
+ : no,
+ refresh_token:
+ oa2c.grantTypes.indexOf('refresh_token') !== -1
+ ? yes
+ : no,
+ authorization_code:
+ oa2c.grantTypes.indexOf(
+ 'authorization_code'
+ ) !== -1
+ ? yes
+ : no,
+ })
+ )
+ )
+ })
+ })
+})
+
+oa2Actions.delete.subscribe((e) => {
+ e.data
+ .delete()
+ .then(() => {
+ oa2Actions.load()
+ settingsActions.showSnackbarMessage(i18n.t('OAuth2 client deleted'))
+ })
+ .catch((err) => {
+ console.error('Error when deleting OAuth2 client:', err)
+ settingsActions.showSnackbarMessage(
+ i18n.t('Failed to delete OAuth2 client')
+ )
+ })
+})
+
+export default oa2Actions
diff --git a/src/oauth2-client-editor/oauth2Client.store.js b/src/oauth2-client-editor/oauth2Client.store.js
new file mode 100644
index 00000000..140efaf4
--- /dev/null
+++ b/src/oauth2-client-editor/oauth2Client.store.js
@@ -0,0 +1,3 @@
+import Store from 'd2-ui/lib/store/Store.js'
+
+export default Store.create()
diff --git a/src/settingsCategories.js b/src/settingsCategories.js
index 63a43349..ddf48c36 100644
--- a/src/settingsCategories.js
+++ b/src/settingsCategories.js
@@ -10,6 +10,8 @@ export const categoryOrder = [
'calendar',
'import',
'sync',
+ 'scheduledJobs',
+ 'oauth2',
]
export const categories = {
@@ -54,7 +56,6 @@ export const categories = {
'keyAnalyticsCacheProgressiveTtlFactor',
'keyIgnoreAnalyticsApprovalYearThreshold',
'keyRespectMetaDataStartEndDatesInAnalyticsTableExport',
- 'keyAnalyticsMaintenanceMode',
'keyIncludeZeroValuesInAnalytics',
'keyDashboardContextMenuItemSwitchViewType',
'keyDashboardContextMenuItemOpenInRelevantApp',
@@ -161,4 +162,22 @@ export const categories = {
'keyMetadataDataVersioning',
],
},
+ scheduledJobs: {
+ label: i18n.t('Scheduled jobs'),
+ icon: 'schedule',
+ pageLabel: i18n.t('Scheduled jobs'),
+ settings: [
+ 'jobsRescheduleAfterMinutes',
+ 'jobsCleanupAfterMinutes',
+ 'jobsMaxCronDelayHours',
+ 'jobsLogDebugBelowSeconds',
+ ],
+ },
+ oauth2: {
+ label: i18n.t('OAuth2 Clients'),
+ icon: 'vpn_lock',
+ pageLabel: i18n.t('OAuth2 Clients'),
+ authority: 'F_OAUTH2_CLIENT_MANAGE',
+ settings: ['oauth2clients'],
+ },
}
diff --git a/src/settingsFields.component.js b/src/settingsFields.component.js
index c33057ea..a5a1629f 100644
--- a/src/settingsFields.component.js
+++ b/src/settingsFields.component.js
@@ -18,6 +18,7 @@ import FileUpload from './form-fields/file-upload.js'
import TextField from './form-fields/text-field.js'
import LocalizedAppearance from './localized-text/LocalizedAppearanceEditor.component.js'
import metadataSettings from './metadata-settings/metadataSettings.component.js'
+import Oauth2ClientEditor from './oauth2-client-editor/OAuth2ClientEditor.component.js'
import settingsActions from './settingsActions.js'
import { categories } from './settingsCategories.js'
import classes from './SettingsFields.module.css'
@@ -243,6 +244,11 @@ class SettingsFields extends React.Component {
},
})
+ case 'oauth2clients':
+ return Object.assign({}, fieldBase, {
+ component: Oauth2ClientEditor,
+ })
+
case 'localizedAppearance':
return Object.assign({}, fieldBase, {
component: LocalizedAppearance,
diff --git a/src/settingsKeyMapping.js b/src/settingsKeyMapping.js
index 79372c72..e105349e 100644
--- a/src/settingsKeyMapping.js
+++ b/src/settingsKeyMapping.js
@@ -248,10 +248,6 @@ const settingsKeyMapping = {
),
type: 'checkbox',
},
- keyAnalyticsMaintenanceMode: {
- label: i18n.t('Put analytics in maintenance mode'),
- type: 'checkbox',
- },
keyIncludeZeroValuesInAnalytics: {
label: i18n.t('Include zero data values in analytics tables'),
type: 'checkbox',
@@ -689,7 +685,61 @@ const settingsKeyMapping = {
label: i18n.t('Metadata Versioning'),
type: 'metadataSettings',
},
-
+ /* ============================================================================================================ */
+ /* Category: oAuth2 clients */
+ /* ============================================================================================================ */
+ oauth2clients: {
+ type: 'oauth2clients',
+ searchLabels: [
+ 'oauth2_clients',
+ 'password',
+ 'refresh_token',
+ 'authorization_code',
+ ],
+ },
+ /* ============================================================================================================ */
+ /* Category: Scheduled jobs */
+ /* ============================================================================================================ */
+ jobsRescheduleAfterMinutes: {
+ label: i18n.t(
+ 'Number of minutes after which a stale job is reset to scheduled state (1-60)'
+ ),
+ type: 'textfield',
+ inputType: 'number',
+ minValue: 1,
+ maxValue: 60,
+ validators: ['positive_number'],
+ },
+ jobsCleanupAfterMinutes: {
+ label: i18n.t(
+ 'Number of minutes after which a completed ad-hoc job is deleted (1+)'
+ ),
+ type: 'textfield',
+ inputType: 'number',
+ minValue: 1,
+ maxValue: 2147483647,
+ validators: ['positive_number'],
+ },
+ jobsMaxCronDelayHours: {
+ label: i18n.t(
+ 'Maximum number of hours a job may trigger after its intended time if job has not yet run (1-24)'
+ ),
+ type: 'textfield',
+ inputType: 'number',
+ minValue: 1,
+ maxValue: 24,
+ validators: ['positive_number'],
+ },
+ jobsLogDebugBelowSeconds: {
+ label: i18n.t(
+ 'Job execution interval (seconds) below which a job will be logged at debug (rather than info) level (20+)'
+ ),
+ type: 'textfield',
+ inputType: 'number',
+ minValue: 20,
+ maxValue: 2147483647,
+ validators: ['positive_number'],
+ },
/* ============================================================================================================ */
// The following keys are present in the demo database but are not managed by dhis-web-maintenance-settings
//