From 520b1c76fd8705eb311813c1bea0a9277aef8759 Mon Sep 17 00:00:00 2001 From: Thomas Zemp Date: Mon, 12 Feb 2024 14:57:52 +0100 Subject: [PATCH] fix: add back OAUTH2 [DHIS2-15326] (#1300) --- cypress/integration/oauth2.feature | 18 ++ i18n/en.pot | 100 +++++++++-- src/oauth2-client-editor/ClientForm.js | 162 ++++++++++++++++++ .../ClientForm.module.css | 3 + src/oauth2-client-editor/ClientsList.js | 78 +++++++++ .../ClientsList.module.css | 3 + .../OAuth2ClientEditor.component.js | 149 ++++++++++++++++ .../OAuth2ClientEditor.module.css | 7 + .../oauth2Client.actions.js | 56 ++++++ .../oauth2Client.store.js | 3 + src/settingsCategories.js | 8 + src/settingsFields.component.js | 6 + src/settingsKeyMapping.js | 12 ++ 13 files changed, 588 insertions(+), 17 deletions(-) create mode 100644 cypress/integration/oauth2.feature create mode 100644 src/oauth2-client-editor/ClientForm.js create mode 100644 src/oauth2-client-editor/ClientForm.module.css create mode 100644 src/oauth2-client-editor/ClientsList.js create mode 100644 src/oauth2-client-editor/ClientsList.module.css create mode 100644 src/oauth2-client-editor/OAuth2ClientEditor.component.js create mode 100644 src/oauth2-client-editor/OAuth2ClientEditor.module.css create mode 100644 src/oauth2-client-editor/oauth2Client.actions.js create mode 100644 src/oauth2-client-editor/oauth2Client.store.js 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/i18n/en.pot b/i18n/en.pot index c334d475..70e8ef84 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-01-23T20:33:57.618Z\n" -"PO-Revision-Date: 2024-01-23T20:33:57.618Z\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}}" @@ -89,6 +89,84 @@ msgstr "Don't sync metadata if DHIS versions differ" msgid "Metadata Versioning" msgstr "Metadata Versioning" +msgid "This client ID is already taken" +msgstr "This client ID is already taken" + +msgid "Name" +msgstr "Name" + +msgid "Required" +msgstr "Required" + +msgid "Client ID" +msgstr "Client ID" + +msgid "Client Secret" +msgstr "Client Secret" + +msgid "Grant Types" +msgstr "Grant Types" + +msgid "Password" +msgstr "Password" + +msgid "Refresh token" +msgstr "Refresh token" + +msgid "Authorization code" +msgstr "Authorization code" + +msgid "One URL per line" +msgstr "One URL per line" + +msgid "Redirect URIs" +msgstr "Redirect URIs" + +msgid "This field should contain a list of URLs" +msgstr "This field should contain a list of URLs" + +msgid "Create new OAuth2 Client" +msgstr "Create new OAuth2 Client" + +msgid "Edit OAuth2 Client" +msgstr "Edit OAuth2 Client" + +msgid "Save" +msgstr "Save" + +msgid "Cancel" +msgstr "Cancel" + +msgid "There are currently no OAuth2 clients registered" +msgstr "There are currently no OAuth2 clients registered" + +msgid "Edit" +msgstr "Edit" + +msgid "Delete" +msgstr "Delete" + +msgid "OAuth2 client saved" +msgstr "OAuth2 client saved" + +msgid "Failed to save OAuth2 client" +msgstr "Failed to save OAuth2 client" + +msgid "Add OAuth2 client" +msgstr "Add OAuth2 client" + +msgid "Yes" +msgstr "Yes" + +msgid "No" +msgstr "No" + +msgid "OAuth2 client deleted" +msgstr "OAuth2 client deleted" + +msgid "Failed to delete OAuth2 client" +msgstr "Failed to delete OAuth2 client" + msgid "Settings updated" msgstr "Settings updated" @@ -146,6 +224,9 @@ msgstr "Synchronization" msgid "Synchronization settings" msgstr "Synchronization settings" +msgid "OAuth2 Clients" +msgstr "OAuth2 Clients" + msgid "Scheduled jobs" msgstr "Scheduled jobs" @@ -155,9 +236,6 @@ msgstr "This field is required" msgid "This field should be a URL" msgstr "This field should be a URL" -msgid "This field should contain a list of URLs" -msgstr "This field should contain a list of URLs" - msgid "This field should be a number" msgstr "This field should be a number" @@ -554,9 +632,6 @@ msgstr "Database language" msgid "Property to display in analysis modules" msgstr "Property to display in analysis modules" -msgid "Name" -msgstr "Name" - msgid "Short name" msgstr "Short name" @@ -599,9 +674,6 @@ msgstr "Port" msgid "Username" msgstr "Username" -msgid "Password" -msgstr "Password" - msgid "TLS" msgstr "TLS" @@ -665,9 +737,6 @@ msgstr "Minimum characters in password" msgid "CORS whitelist" msgstr "CORS whitelist" -msgid "One URL per line" -msgstr "One URL per line" - msgid "reCAPTCHA Site Key" msgstr "reCAPTCHA Site Key" @@ -713,9 +782,6 @@ msgstr "" "system unusable. If you have entered data, it is strongly recommended that " "you do not change your calendar setting." -msgid "Cancel" -msgstr "Cancel" - msgid "Yes, change calendar" msgstr "Yes, change calendar" 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 39047eb7..ddf48c36 100644 --- a/src/settingsCategories.js +++ b/src/settingsCategories.js @@ -11,6 +11,7 @@ export const categoryOrder = [ 'import', 'sync', 'scheduledJobs', + 'oauth2', ] export const categories = { @@ -172,4 +173,11 @@ export const categories = { '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 a8a139c0..6c29feb0 100644 --- a/src/settingsKeyMapping.js +++ b/src/settingsKeyMapping.js @@ -686,6 +686,18 @@ const settingsKeyMapping = { type: 'metadataSettings', }, /* ============================================================================================================ */ + /* Category: oAuth2 clients */ + /* ============================================================================================================ */ + oauth2clients: { + type: 'oauth2clients', + searchLabels: [ + 'oauth2_clients', + 'password', + 'refresh_token', + 'authorization_code', + ], + }, + /* ============================================================================================================ */ /* Category: Scheduled jobs */ /* ============================================================================================================ */ jobsRescheduleAfterMinutes: {