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 //