diff --git a/src/dashboard/CustomParseOptions/CustomParseOptions.react.js b/src/dashboard/CustomParseOptions/CustomParseOptions.react.js
index c79c4f602a..5573e06a4e 100644
--- a/src/dashboard/CustomParseOptions/CustomParseOptions.react.js
+++ b/src/dashboard/CustomParseOptions/CustomParseOptions.react.js
@@ -26,6 +26,8 @@ import TextInputSettings from 'components/TextInputSettings/TextInputSettings.re
import Button from 'components/Button/Button.react';
import B4aTooltip from 'components/Tooltip/B4aTooltip.react';
import Icon from 'components/Icon/Icon.react';
+import B4aModal from 'components/B4aModal/B4aModal.react';
+import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react';
import { Link } from 'react-router-dom';
import deepmerge from 'deepmerge';
@@ -34,6 +36,162 @@ import CustomParseOptionsValidations from './CustomParseOptionsValidations';
import getError from 'dashboard/Settings/Util/getError';
import semver from 'semver';
+const CUSTOM_PAGES_KEYS = [
+ 'choosePassword',
+ 'verifyEmailSuccess',
+ 'parseFrameURL',
+ 'passwordResetSuccess',
+ 'invalidLink',
+ 'invalidVerificationLink',
+ 'linkSendSuccess',
+ 'linkSendFail',
+];
+
+// Recursively sort object keys so the JSON editor shows properties in a
+// deterministic, alphabetical order whenever we rebuild the text ourselves
+// (modal open, reset, etc.). User-driven edits bypass this, preserving caret
+// position while they're typing.
+const sortKeysDeep = (value) => {
+ if (Array.isArray(value)) {
+ return value.map(sortKeysDeep);
+ }
+ if (value && typeof value === 'object') {
+ const sorted = {};
+ for (const key of Object.keys(value).sort()) {
+ sorted[key] = sortKeysDeep(value[key]);
+ }
+ return sorted;
+ }
+ return value;
+};
+
+const stringifyAlpha = (value) => JSON.stringify(sortKeysDeep(value), null, 2);
+
+const getActualChanges = (changes, initial) => {
+ const result = {};
+ for (const key of Object.keys(changes)) {
+ const val = changes[key];
+ const ref = initial ? initial[key] : undefined;
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
+ const nested = getActualChanges(val, ref || {});
+ if (Object.keys(nested).length > 0) {
+ result[key] = nested;
+ }
+ } else if (val !== ref) {
+ result[key] = val;
+ }
+ }
+ return result;
+};
+
+const transformCustomOptionsForPayload = (customOptionsInput) => {
+ if (!customOptionsInput) {
+ return undefined;
+ }
+ const payload = JSON.parse(JSON.stringify(customOptionsInput));
+ delete payload.databaseURI;
+ if (payload.maxUploadSize != null && payload.maxUploadSize !== '') {
+ payload.maxUploadSize = `${payload.maxUploadSize}mb`;
+ }
+ const customPages = { ...(payload.customPages || {}) };
+ CUSTOM_PAGES_KEYS.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(payload, key)) {
+ customPages[key] = payload[key] === '' ? undefined : payload[key];
+ delete payload[key];
+ }
+ });
+ if (Object.keys(customPages).length > 0) {
+ payload.customPages = customPages;
+ }
+ for (const key of Object.keys(payload)) {
+ if (typeof payload[key] === 'string' && payload[key].trim() === '') {
+ payload[key] = undefined;
+ }
+ }
+ return payload;
+};
+
+const buildSaveParseOptionsPayload = (fields, initialFields) => {
+ const changes = getActualChanges(fields, initialFields);
+ return {
+ customOptions: transformCustomOptionsForPayload(changes.customOptions),
+ clientPush: Object.prototype.hasOwnProperty.call(changes, 'clientPush')
+ ? changes.clientPush
+ : undefined,
+ clientClassCreation: Object.prototype.hasOwnProperty.call(changes, 'clientClassCreation')
+ ? changes.clientClassCreation
+ : undefined,
+ };
+};
+
+// Keys that live alongside `customOptions` at the top of the saveParseOptionsAndSettings
+// payload. Everything else in the flat JSON editor gets grouped under `customOptions`.
+const TOP_LEVEL_PAYLOAD_KEYS = ['clientPush', 'clientClassCreation'];
+
+// Builds the flat shape shown in the JSON editor: all customOptions keys are
+// merged into the root next to clientPush/clientClassCreation so the user
+// doesn't need to know which keys belong under `customOptions`.
+// The `?? default` guards ensure these two always render (JSON.stringify
+// drops properties whose value is `undefined`).
+const buildFlatEditorPayload = (fields) => {
+ const transformed = transformCustomOptionsForPayload(fields.customOptions) || {};
+ return {
+ ...transformed,
+ clientPush: fields.clientPush ?? false,
+ clientClassCreation: fields.clientClassCreation ?? true,
+ };
+};
+
+// Inverse of `buildFlatEditorPayload` — groups every non top-level key back
+// under `customOptions` so we can send the correct saveParseOptionsAndSettings
+// shape.
+const unflattenEditorPayload = (flatParsed) => {
+ const result = {
+ customOptions: {},
+ clientPush: undefined,
+ clientClassCreation: undefined,
+ };
+ if (!flatParsed || typeof flatParsed !== 'object' || Array.isArray(flatParsed)) {
+ return result;
+ }
+ for (const key of Object.keys(flatParsed)) {
+ if (TOP_LEVEL_PAYLOAD_KEYS.indexOf(key) !== -1) {
+ result[key] = flatParsed[key];
+ } else {
+ result.customOptions[key] = flatParsed[key];
+ }
+ }
+ return result;
+};
+
+// Reverse of `transformCustomOptionsForPayload` so JSON edits from the modal
+// can be mapped back onto the form fields (which use raw shapes like a numeric
+// maxUploadSize and flat custom-page keys).
+const reverseTransformCustomOptions = (payloadCustomOptions) => {
+ if (!payloadCustomOptions || typeof payloadCustomOptions !== 'object') {
+ return {};
+ }
+ const result = JSON.parse(JSON.stringify(payloadCustomOptions));
+
+ if (typeof result.maxUploadSize === 'string') {
+ const parsed = parseInt(result.maxUploadSize, 10);
+ if (!Number.isNaN(parsed)) {
+ result.maxUploadSize = parsed;
+ }
+ }
+
+ if (result.customPages && typeof result.customPages === 'object') {
+ CUSTOM_PAGES_KEYS.forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(result.customPages, key)) {
+ result[key] = result.customPages[key];
+ }
+ });
+ delete result.customPages;
+ }
+
+ return result;
+};
+
const LabelInfoTooltip = ({ description, children }) => {
const [visible, setVisible] = React.useState(false);
const [placement, setPlacement] = React.useState('top');
@@ -112,6 +270,11 @@ class CustomParseOptions extends DashboardView {
canChangeCustomParseOptions: false,
hasOtherConfigsPermission: true,
isOwner: false,
+ showPayloadModal: false,
+ copyStatus: '',
+ payloadJson: '',
+ isSavingPayload: false,
+ payloadSaveError: '',
};
this.onRefresh = this.onRefresh.bind(this);
}
@@ -222,7 +385,7 @@ class CustomParseOptions extends DashboardView {
initialFields: {
customOptions,
clientPush: response.clientPush ?? false,
- clientClassCreation: response.clientClassCreation !== 'undefined' ? response.clientClassCreation : true,
+ clientClassCreation: response.clientClassCreation ?? true,
},
});
} catch (error) {
@@ -239,7 +402,7 @@ class CustomParseOptions extends DashboardView {
);
}
- renderParseOptionsForm({ fields, setField, setFieldJson, errors }) {
+ renderParseOptionsForm({ fields, setField, setFieldJson, resetFields, errors }) {
const customOptions = fields.customOptions || {};
const clientPush = fields.clientPush === true;
const clientClassCreation = fields.clientClassCreation;
@@ -269,10 +432,22 @@ class CustomParseOptions extends DashboardView {
const isOwner = this.state.isOwner;
return (
+ <>
Parse Server Options
-
Configure advanced settings of your Parse Server instance, including server behavior, authentication, and security rules.
+
+ Configure advanced settings of your Parse Server instance, including server behavior, authentication, and security rules.
+ Check available option in {' '}
+
+ Documentation
+ .
+
Warning: Changes apply immediately and may affect your app’s availability or client connections.