diff --git a/README.md b/README.md index b61bb8f32b..b273835767 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https - [Deploying Parse Dashboard](#deploying-parse-dashboard) - [Preparing for Deployment](#preparing-for-deployment) - [Security Considerations](#security-considerations) + - [Security Checks](#security-checks) - [Configuring Basic Authentication](#configuring-basic-authentication) - [Multi-Factor Authentication (One-Time Password)](#multi-factor-authentication-one-time-password) - [Separating App Access Based on User Identity](#separating-app-access-based-on-user-identity) @@ -123,6 +124,7 @@ Parse Dashboard is continuously tested with the most recent releases of Node.js | `apps.scripts.cloudCodeFunction` | String | no | - | `'deleteUser'` | The name of the Parse Cloud Function to execute. | | `apps.scripts.showConfirmationDialog` | Bool | yes | `false` | `true` | Is `true` if a confirmation dialog should be displayed before the script is executed, `false` if the script should be executed immediately. | | `apps.scripts.confirmationDialogStyle` | String | yes | `info` | `critical` | The style of the confirmation dialog. Valid values: `info` (blue style), `critical` (red style). | +| `apps.cloudConfigHistoryLimit` | Integer | yes | `100` | `100` | The number of historic values that should be saved in the Cloud Config change history. Valid values: `0`...`Number.MAX_SAFE_INTEGER`. | ### File @@ -539,7 +541,7 @@ var dashboard = new ParseDashboard({ }); ``` -## Security Checks +### Security Checks You can view the security status of your Parse Server by enabling the dashboard option `enableSecurityChecks`, and visiting App Settings > Security. @@ -557,8 +559,6 @@ const dashboard = new ParseDashboard({ }); ``` - - ### Configuring Basic Authentication You can configure your dashboard for Basic Authentication by adding usernames and passwords your `parse-dashboard-config.json` configuration file: diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js index 148cc2042f..c130791561 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -20,9 +20,11 @@ import TableHeader from 'components/Table/TableHeader.react'; import TableView from 'dashboard/TableView.react'; import Toolbar from 'components/Toolbar/Toolbar.react'; import browserStyles from 'dashboard/Data/Browser/Browser.scss'; +import { CurrentApp } from 'context/currentApp'; @subscribeTo('Config', 'config') class Config extends TableView { + static contextType = CurrentApp; constructor() { super(); this.section = 'Core'; @@ -242,7 +244,7 @@ class Config extends TableView { return data; } - saveParam({ name, value, masterKeyOnly }) { + saveParam({ name, value, type, masterKeyOnly }) { this.props.config .dispatch(ActionTypes.SET, { param: name, @@ -252,6 +254,32 @@ class Config extends TableView { .then( () => { this.setState({ modalOpen: false }); + const limit = this.context.cloudConfigHistoryLimit; + const applicationId = this.context.applicationId; + let transformedValue = value; + if(type === 'Date') { + transformedValue = {__type: 'Date', iso: value}; + } + if(type === 'File') { + transformedValue = {name: value._name, url: value._url}; + } + const configHistory = localStorage.getItem(`${applicationId}_configHistory`); + if(!configHistory) { + localStorage.setItem(`${applicationId}_configHistory`, JSON.stringify({ + [name]: [{ + time: new Date(), + value: transformedValue + }] + })); + } else { + const oldConfigHistory = JSON.parse(configHistory); + localStorage.setItem(`${applicationId}_configHistory`, JSON.stringify({ + ...oldConfigHistory, + [name]: !oldConfigHistory[name] ? + [{time: new Date(), value: transformedValue}] + : [{time: new Date(), value: transformedValue}, ...oldConfigHistory[name]].slice(0, limit || 100) + })); + } }, () => { // Catch the error @@ -263,6 +291,15 @@ class Config extends TableView { this.props.config.dispatch(ActionTypes.DELETE, { param: name }).then(() => { this.setState({ showDeleteParameterDialog: false }); }); + const configHistory = localStorage.getItem('configHistory') && JSON.parse(localStorage.getItem('configHistory')); + if(configHistory) { + delete configHistory[name]; + if(Object.keys(configHistory).length === 0) { + localStorage.removeItem('configHistory'); + } else { + localStorage.setItem('configHistory', JSON.stringify(configHistory)); + } + } } createParameter() { diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js index 711b66efe0..447ba6a2a8 100644 --- a/src/dashboard/Data/Config/ConfigDialog.react.js +++ b/src/dashboard/Data/Config/ConfigDialog.react.js @@ -20,6 +20,8 @@ import Toggle from 'components/Toggle/Toggle.react'; import validateNumeric from 'lib/validateNumeric'; import styles from 'dashboard/Data/Browser/Browser.scss'; import semver from 'semver/preload.js'; +import { dateStringUTC } from 'lib/DateUtils'; +import { CurrentApp } from 'context/currentApp'; const PARAM_TYPES = ['Boolean', 'String', 'Number', 'Date', 'Object', 'Array', 'GeoPoint', 'File']; @@ -90,6 +92,7 @@ const GET_VALUE = { }; export default class ConfigDialog extends React.Component { + static contextType = CurrentApp; constructor(props) { super(); this.state = { @@ -97,6 +100,7 @@ export default class ConfigDialog extends React.Component { type: 'String', name: '', masterKeyOnly: false, + selectedIndex: null, }; if (props.param.length > 0) { this.state = { @@ -104,6 +108,7 @@ export default class ConfigDialog extends React.Component { type: props.type, value: props.value, masterKeyOnly: props.masterKeyOnly, + selectedIndex: 0, }; } } @@ -169,6 +174,7 @@ export default class ConfigDialog extends React.Component { submit() { this.props.onConfirm({ name: this.state.name, + type: this.state.type, value: GET_VALUE[this.state.type](this.state.value), masterKeyOnly: this.state.masterKeyOnly, }); @@ -190,6 +196,28 @@ export default class ConfigDialog extends React.Component { ))} ); + const configHistory = localStorage.getItem(`${this.context.applicationId}_configHistory`) && JSON.parse(localStorage.getItem(`${this.context.applicationId}_configHistory`))[this.state.name]; + const handleIndexChange = index => { + if(this.state.type === 'Date'){ + return; + } + let value = configHistory[index].value; + if(this.state.type === 'File'){ + const fileJSON = { + __type: 'File', + name: value.name, + url: value.url + }; + const file = Parse.File.fromJSON(fileJSON); + this.setState({ selectedIndex: index, value: file }); + return; + } + if(typeof value === 'object'){ + value = JSON.stringify(value); + } + this.setState({ selectedIndex: index, value }); + }; + return ( ) : null } + { + configHistory?.length > 0 && + + } + input={ + + {configHistory.map((value, i) => + + )} + + } + className={styles.addColumnToggleWrapper} + /> + } ); } diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index fdbdc196aa..f46755f084 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -48,7 +48,8 @@ export default class ParseApp { columnPreference, scripts, classPreference, - enableSecurityChecks + enableSecurityChecks, + cloudConfigHistoryLimit }) { this.name = appName; this.createdAt = created_at ? new Date(created_at) : new Date(); @@ -77,6 +78,7 @@ export default class ParseApp { this.columnPreference = columnPreference; this.scripts = scripts; this.enableSecurityChecks = !!enableSecurityChecks; + this.cloudConfigHistoryLimit = cloudConfigHistoryLimit; if (!supportedPushLocales) { console.warn(