Skip to content

Commit 228d839

Browse files
authored
feat: Add visual configurator for Parse Dashboard settings (#2406)
1 parent 5abcbc9 commit 228d839

File tree

5 files changed

+297
-5
lines changed

5 files changed

+297
-5
lines changed

src/dashboard/Dashboard.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { setBasePath } from 'lib/AJAX';
5151
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
5252
import { Helmet } from 'react-helmet';
5353
import Playground from './Data/Playground/Playground.react';
54+
import DashboardSettings from './Settings/DashboardSettings/DashboardSettings.react';
5455

5556
const ShowSchemaOverview = false; //In progress features. Change false to true to work on this feature.
5657

@@ -199,12 +200,13 @@ export default class Dashboard extends React.Component {
199200

200201
const SettingsRoute = (
201202
<Route element={<SettingsData />}>
203+
<Route path='dashboard' element={<DashboardSettings />} />
202204
<Route path='general' element={<GeneralSettings />} />
203205
<Route path='keys' element={<SecuritySettings />} />
204206
<Route path='users' element={<UsersSettings />} />
205207
<Route path='push' element={<PushSettings />} />
206208
<Route path='hosting' element={<HostingSettings />} />
207-
<Route index element={<Navigate replace to='general' />} />
209+
<Route index element={<Navigate replace to='dashboard' />} />
208210
</Route>
209211
)
210212

src/dashboard/DashboardView.react.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ export default class DashboardView extends React.Component {
198198
}
199199
*/
200200

201-
let settingsSections = [];
201+
const settingsSections = [{
202+
name: 'Dashboard',
203+
link: '/settings/dashboard'
204+
}];
202205

203206
// Settings - nothing remotely like this in parse-server yet. Maybe it will arrive soon.
204207
/*
@@ -292,7 +295,7 @@ export default class DashboardView extends React.Component {
292295
);
293296

294297
let content = <div className={styles.content}>{this.renderContent()}</div>;
295-
const canRoute = [...coreSubsections, ...pushSubsections]
298+
const canRoute = [...coreSubsections, ...pushSubsections, ...settingsSections]
296299
.map(({ link }) => link.split('/')[1])
297300
.includes(this.state.route);
298301

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import DashboardView from 'dashboard/DashboardView.react';
2+
import Field from 'components/Field/Field.react';
3+
import Fieldset from 'components/Fieldset/Fieldset.react';
4+
import FlowView from 'components/FlowView/FlowView.react';
5+
import FormButton from 'components/FormButton/FormButton.react';
6+
import Label from 'components/Label/Label.react';
7+
import Button from 'components/Button/Button.react';
8+
import React from 'react';
9+
import styles from 'dashboard/Settings/DashboardSettings/DashboardSettings.scss';
10+
import TextInput from 'components/TextInput/TextInput.react';
11+
import Toggle from 'components/Toggle/Toggle.react';
12+
import Icon from 'components/Icon/Icon.react';
13+
import Dropdown from 'components/Dropdown/Dropdown.react';
14+
import Option from 'components/Dropdown/Option.react';
15+
import Toolbar from 'components/Toolbar/Toolbar.react';
16+
import CodeSnippet from 'components/CodeSnippet/CodeSnippet.react';
17+
import Notification from 'dashboard/Data/Browser/Notification.react';
18+
import * as ColumnPreferences from 'lib/ColumnPreferences';
19+
import bcrypt from 'bcryptjs';
20+
import * as OTPAuth from 'otpauth';
21+
import QRCode from 'qrcode';
22+
23+
export default class DashboardSettings extends DashboardView {
24+
constructor() {
25+
super();
26+
this.section = 'App Settings';
27+
this.subsection = 'Dashboard Configuration';
28+
29+
this.state = {
30+
createUserInput: false,
31+
username: '',
32+
password: '',
33+
encrypt: true,
34+
mfa: false,
35+
mfaDigits: 6,
36+
mfaPeriod: 30,
37+
mfaAlgorithm: 'SHA1',
38+
message: null,
39+
passwordInput: '',
40+
passwordHidden: true,
41+
columnData: {
42+
data: '',
43+
show: false,
44+
},
45+
newUser: {
46+
data: '',
47+
show: false,
48+
mfa: '',
49+
},
50+
};
51+
}
52+
53+
getColumns() {
54+
const data = ColumnPreferences.getAllPreferences(this.context.applicationId);
55+
this.setState({
56+
columnData: { data: JSON.stringify(data, null, 2), show: true },
57+
});
58+
}
59+
60+
copy(data, label) {
61+
navigator.clipboard.writeText(data);
62+
this.showNote(`${label} copied to clipboard`);
63+
}
64+
65+
createUser() {
66+
if (!this.state.username) {
67+
this.showNote('Please enter a username');
68+
return;
69+
}
70+
if (!this.state.password) {
71+
this.showNote('Please enter a password');
72+
return;
73+
}
74+
75+
let pass = this.state.password;
76+
if (this.state.encrypt) {
77+
const salt = bcrypt.genSaltSync(10);
78+
pass = bcrypt.hashSync(pass, salt);
79+
}
80+
81+
const user = {
82+
username: this.state.username,
83+
pass,
84+
};
85+
86+
let mfa;
87+
if (this.state.mfa) {
88+
const secret = new OTPAuth.Secret();
89+
const totp = new OTPAuth.TOTP({
90+
issuer: this.context.name,
91+
label: user.username,
92+
algorithm: this.state.mfaAlgorithm || 'SHA1',
93+
digits: this.state.mfaDigits || 6,
94+
period: this.state.mfaPeriod || 30,
95+
secret,
96+
});
97+
mfa = totp.toString();
98+
user.mfa = secret.base32;
99+
if (totp.algorithm !== 'SHA1') {
100+
user.mfaAlgorithm = totp.algorithm;
101+
}
102+
if (totp.digits != 6) {
103+
user.mfaDigits = totp.digits;
104+
}
105+
if (totp.period != 30) {
106+
user.mfaPeriod = totp.period;
107+
}
108+
109+
setTimeout(() => {
110+
const canvas = document.getElementById('canvas');
111+
QRCode.toCanvas(canvas, mfa);
112+
}, 10);
113+
}
114+
115+
this.setState({
116+
newUser: {
117+
show: true,
118+
data: JSON.stringify(user, null, 2),
119+
mfa,
120+
},
121+
});
122+
}
123+
124+
generatePassword() {
125+
let chars = '0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()ABCDEFGHIJKLMNOPQRSTUVWXYZ';
126+
let pwordLength = 20;
127+
let password = '';
128+
129+
const array = new Uint32Array(chars.length);
130+
window.crypto.getRandomValues(array);
131+
132+
for (let i = 0; i < pwordLength; i++) {
133+
password += chars[array[i] % chars.length];
134+
}
135+
this.setState({ password });
136+
}
137+
138+
showNote(message) {
139+
if (!message) {
140+
return;
141+
}
142+
143+
clearTimeout(this.noteTimeout);
144+
145+
this.setState({ message });
146+
147+
this.noteTimeout = setTimeout(() => {
148+
this.setState({ message: null });
149+
}, 3500);
150+
}
151+
152+
renderForm() {
153+
const createUserInput = (
154+
<Fieldset legend="New User">
155+
<Field label={<Label text="Username" />} input={<TextInput value={this.state.username} placeholder="Username" onChange={(username) => this.setState({ username })} />} />
156+
<Field
157+
label={
158+
<Label
159+
text={
160+
<div className={styles.password}>
161+
<span>Password</span>
162+
<a onClick={() => this.setState({ passwordHidden: !this.state.passwordHidden })}>
163+
<Icon name={this.state.passwordHidden ? 'visibility' : 'visibility_off'} width={18} height={18} fill="rgba(0,0,0,0.4)" />
164+
</a>
165+
</div>
166+
}
167+
description={<a onClick={() => this.generatePassword()}>Generate strong password</a>}
168+
/>
169+
}
170+
input={<TextInput hidden={this.state.passwordHidden} value={this.state.password} placeholder="Password" onChange={(password) => this.setState({ password })} />}
171+
/>
172+
<Field label={<Label text="Encrypt Password" />} input={<Toggle value={this.state.encrypt} type={Toggle.Types.YES_NO} onChange={(encrypt) => this.setState({ encrypt })} />} />
173+
<Field label={<Label text="Enable MFA" />} input={<Toggle value={this.state.mfa} type={Toggle.Types.YES_NO} onChange={(mfa) => this.setState({ mfa })} />} />
174+
{this.state.mfa && (
175+
<Field
176+
label={<Label text="MFA Algorithm" />}
177+
input={
178+
<Dropdown value={this.state.mfaAlgorithm} onChange={(mfaAlgorithm) => this.setState({ mfaAlgorithm })}>
179+
{['SHA1', 'SHA224', 'SHA256', 'SHA384', 'SHA512', 'SHA3-224', 'SHA3-256', 'SHA3-384', 'SHA3-512'].map((column) => (
180+
<Option key={column} value={column}>
181+
{column}
182+
</Option>
183+
))}
184+
</Dropdown>
185+
}
186+
/>
187+
)}
188+
{this.state.mfa && <Field label={<Label text="MFA Digits" description="How many digits long should the MFA code be" />} input={<TextInput value={`${this.state.mfaDigits}`} placeholder="6" onChange={(mfaDigits) => this.setState({ mfaDigits })} />} />}
189+
{this.state.mfa && <Field label={<Label text="MFA Period" description="How many long should the MFA last for" />} input={<TextInput value={`${this.state.mfaPeriod}`} placeholder="30" onChange={(mfaPeriod) => this.setState({ mfaPeriod })} />} />}
190+
<Field input={<Button color="blue" value="Create" width="120px" onClick={() => this.createUser()} />} />
191+
</Fieldset>
192+
);
193+
const columnPreferences = (
194+
<div>
195+
<div className={styles.columnData}>
196+
<CodeSnippet source={this.state.columnData.data} language="json" />
197+
</div>
198+
<div className={styles.footer}>
199+
<Button color="blue" value="Copy" width="120px" onClick={() => this.copy(this.state.columnData.data, 'Column Preferences')} />
200+
<Button primary={true} value="Done" width="120px" onClick={() => this.setState({ columnData: { data: '', show: false } })} />
201+
</div>
202+
</div>
203+
);
204+
const userData = (
205+
<div className={styles.userData}>
206+
Add the following data to your Parse Dashboard configuration "users":
207+
{this.state.encrypt && <div>Make sure the dashboard option useEncryptedPasswords is set to true.</div>}
208+
<div className={styles.newUser}>
209+
<CodeSnippet source={this.state.newUser.data} language="json" />
210+
</div>
211+
{this.state.mfa && (
212+
<div className={styles.mfa}>
213+
<div>Share this MFA Data with your user:</div>
214+
<a onClick={() => this.copy(this.state.newUser.mfa, 'MFA Data')}>{this.state.newUser.mfa}</a>
215+
<canvas id="canvas" />
216+
</div>
217+
)}
218+
<div className={styles.footer}>
219+
<Button color="blue" value="Copy" width="120px" onClick={() => this.copy(this.state.newUser.data, 'New User')} />
220+
<Button primary={true} value="Done" width="120px" onClick={() => this.setState({ username: '', password: '', passwordHidden: true, mfaAlgorithm: 'SHA1', mfaDigits: 6, mfaPeriod: 30, encrypt: true, createUserInput: false, newUser: { data: '', show: false } })} />
221+
</div>
222+
</div>
223+
);
224+
return (
225+
<div className={styles.settings_page}>
226+
<Fieldset legend="Dashboard Configuration">
227+
<Field label={<Label text="Export Column Preferences" />} input={<FormButton color="blue" value="Export" onClick={() => this.getColumns()} />} />
228+
<Field label={<Label text="Create New User" />} input={<FormButton color="blue" value="Create" onClick={() => this.setState({ createUserInput: true })} />} />
229+
</Fieldset>
230+
{this.state.columnData.show && columnPreferences}
231+
{this.state.createUserInput && createUserInput}
232+
{this.state.newUser.show && userData}
233+
<Toolbar section="Settings" subsection="Dashboard Configuration" />
234+
<Notification note={this.state.message} isErrorNote={false} />
235+
</div>
236+
);
237+
}
238+
239+
renderContent() {
240+
return <FlowView initialFields={{}} initialChanges={{}} footerContents={() => {}} onSubmit={() => {}} renderForm={() => this.renderForm()} />;
241+
}
242+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.columnData {
2+
max-height: 50vh;
3+
overflow-y: scroll;
4+
}
5+
.newUser {
6+
max-height: 100px;
7+
overflow-y: scroll;
8+
}
9+
.settings_page {
10+
padding: 120px 0 80px 0;
11+
}
12+
.footer {
13+
display: flex;
14+
padding: 10px;
15+
justify-content: end;
16+
gap: 10px;
17+
}
18+
.password {
19+
display: flex;
20+
gap: 4px;
21+
}
22+
.userData {
23+
padding: 10px;
24+
}
25+
.mfa {
26+
display: block;
27+
margin-top: 10px;
28+
}

src/lib/ColumnPreferences.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ export function getPreferences(appId, className) {
4545
}
4646
}
4747

48+
export function getAllPreferences(appId) {
49+
const storageKeys = Object.keys(localStorage);
50+
const result = {};
51+
for (const key of storageKeys) {
52+
const split = key.split(':')
53+
if (split.length <= 1) {
54+
continue;
55+
}
56+
const className = split.at(-1);
57+
const preferences = getPreferences(appId, className);
58+
if (preferences) {
59+
result[className] = preferences;
60+
}
61+
}
62+
return result;
63+
}
64+
4865
export function getColumnSort(sortBy, appId, className) {
4966
let cachedSort = getPreferences(appId, COLUMN_SORT) || [ { name: className, value: DEFAULT_COLUMN_SORT } ];
5067
let ordering = [].concat(cachedSort);
@@ -74,7 +91,7 @@ export function getColumnSort(sortBy, appId, className) {
7491
export function getOrder(cols, appId, className, defaultPrefs) {
7592

7693
let prefs = getPreferences(appId, className) || [ { name: 'objectId', width: DEFAULT_WIDTH, visible: true, cached: true } ];
77-
94+
7895
if (defaultPrefs) {
7996

8097
// Check that every default pref is in the prefs array.
@@ -85,7 +102,7 @@ export function getOrder(cols, appId, className, defaultPrefs) {
85102
}
86103
});
87104

88-
// Iterate over the current prefs
105+
// Iterate over the current prefs
89106
prefs = prefs.map((prefsItem) => {
90107
// Get the default prefs item.
91108
const defaultPrefsItem = defaultPrefs.find(defaultPrefsItem => defaultPrefsItem.name === prefsItem.name) || {};

0 commit comments

Comments
 (0)