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(