diff --git a/README.md b/README.md index a59a54c06b..72694d8167 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Parse Dashboard +# Parse Dashboard [![Greenkeeper badge](https://badges.greenkeeper.io/parse-community/parse-dashboard.svg)](https://greenkeeper.io/) [![Build Status](https://img.shields.io/travis/parse-community/parse-dashboard/master.svg?style=flat)](https://travis-ci.org/parse-community/parse-dashboard) @@ -11,32 +11,34 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https://github.com/ParsePlatform/parse-server) apps. -* [Getting Started](#getting-started) -* [Local Installation](#local-installation) - * [Configuring Parse Dashboard](#configuring-parse-dashboard) - * [File](#file) - * [Environment variables](#environment-variables) - * [Multiple apps](#multiple-apps) - * [Single app](#single-app) - * [Managing Multiple Apps](#managing-multiple-apps) - * [GraphQL Playground](#graphql-playground) - * [App Icon Configuration](#app-icon-configuration) - * [App Background Color Configuration](#app-background-color-configuration) - * [Other Configuration Options](#other-configuration-options) - * [Prevent columns sorting](#prevent-columns-sorting) -* [Running as Express Middleware](#running-as-express-middleware) -* [Deploying Parse Dashboard](#deploying-parse-dashboard) - * [Preparing for Deployment](#preparing-for-deployment) - * [Security Considerations](#security-considerations) - * [Configuring Basic Authentication](#configuring-basic-authentication) - * [Separating App Access Based on User Identity](#separating-app-access-based-on-user-identity) - * [Use Read-Only masterKey](#use-read-only-masterKey) - * [Making an app read-only for all users](#making-an-app-read-only-for-all-users) - * [Makings users read-only](#makings-users-read-only) - * [Making user's apps readOnly](#making-users-apps-readonly) - * [Configuring Localized Push Notifications](#configuring-localized-push-notifications) - * [Run with Docker](#run-with-docker) -* [Contributing](#contributing) +- [Getting Started](#getting-started) +- [Local Installation](#local-installation) + - [Configuring Parse Dashboard](#configuring-parse-dashboard) + - [File](#file) + - [Environment variables](#environment-variables) + - [Multiple apps](#multiple-apps) + - [Single app](#single-app) + - [Managing Multiple Apps](#managing-multiple-apps) + - [GraphQL Playground](#graphql-playground) + - [App Icon Configuration](#app-icon-configuration) + - [App Background Color Configuration](#app-background-color-configuration) + - [Other Configuration Options](#other-configuration-options) + - [Prevent columns sorting](#prevent-columns-sorting) +- [Running as Express Middleware](#running-as-express-middleware) +- [Deploying Parse Dashboard](#deploying-parse-dashboard) + - [Preparing for Deployment](#preparing-for-deployment) + - [Security Considerations](#security-considerations) + - [Configuring Basic Authentication](#configuring-basic-authentication) + - [Separating App Access Based on User Identity](#separating-app-access-based-on-user-identity) + - [Use Read-Only masterKey](#use-read-only-masterkey) + - [Making an app read-only for all users](#making-an-app-read-only-for-all-users) + - [Makings users read-only](#makings-users-read-only) + - [Making user's apps readOnly](#making-users-apps-readonly) + - [Configuring Localized Push Notifications](#configuring-localized-push-notifications) + - [Run with Docker](#run-with-docker) +- [Features](#features) + - [Browse as User](#browse-as-user) +- [Contributing](#contributing) # Getting Started @@ -557,6 +559,17 @@ docker run -d -p 80:8080 -v host/path/to/config.json:/src/Parse-Dashboard/parse- If you are not familiar with Docker, ``--port 8080`` will be passed in as argument to the entrypoint to form the full command ``npm start -- --port 8080``. The application will start at port 8080 inside the container and port ``8080`` will be mounted to port ``80`` on your host machine. +# Features +*(The following is not a complete list of features but a work in progress to build a comprehensive feature list.)* + +## Browse as User + +▶️ *Core > Browser > Browse* + +This feature allows you to use the data browser as another user, respecting that user's data permissions. For example, you will only see records and fields the user has permission to see. + +> ⚠️ Logging in as another user will trigger the same Cloud Triggers as if the user logged in themselves using any other login method. Logging in as another user requires to enter that user's password. + # Contributing We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Dashboard guide](CONTRIBUTING.md). diff --git a/src/components/BrowserMenu/BrowserMenu.react.js b/src/components/BrowserMenu/BrowserMenu.react.js index 516baff850..46479fff6b 100644 --- a/src/components/BrowserMenu/BrowserMenu.react.js +++ b/src/components/BrowserMenu/BrowserMenu.react.js @@ -28,10 +28,14 @@ export default class BrowserMenu extends React.Component { let menu = null; if (this.state.open) { let position = Position.inDocument(this.node); + let titleStyle = [styles.title]; + if (this.props.active) { + titleStyle.push(styles.active); + } menu = ( this.setState({ open: false })}>
-
this.setState({ open: false })}> +
this.setState({ open: false })}> {this.props.title}
@@ -48,6 +52,9 @@ export default class BrowserMenu extends React.Component { ); } const classes = [styles.entry]; + if (this.props.active) { + classes.push(styles.active); + } if (this.props.disabled) { classes.push(styles.disabled); } @@ -55,6 +62,7 @@ export default class BrowserMenu extends React.Component { if (!this.props.disabled) { onClick = () => { this.setState({ open: true }); + this.props.setCurrent(null); }; } return ( diff --git a/src/components/BrowserMenu/BrowserMenu.scss b/src/components/BrowserMenu/BrowserMenu.scss index 189d8f1efa..4c357e980d 100644 --- a/src/components/BrowserMenu/BrowserMenu.scss +++ b/src/components/BrowserMenu/BrowserMenu.scss @@ -12,8 +12,8 @@ } .entry { - height: 22px; - padding: 8px 8px 0 8px; + height: 30px; + padding: 8px; svg { fill: #66637A; @@ -33,6 +33,15 @@ fill: #66637A; } } + + &.active { + background: $orange; + border-radius: 5px; + + svg { + fill: white; + } + } } .title { @@ -43,21 +52,28 @@ svg { fill: white; } + + &.active { + background: $orange; + border-radius: 5px; + } } .entry, .title { @include NotoSansFont; + position: relative; + bottom: -4px; font-size: 14px; color: #ffffff; cursor: pointer; svg { - vertical-align: middle; + vertical-align: top; margin-right: 4px; } span { - vertical-align: middle; + vertical-align: top; height: 14px; line-height: 14px; } diff --git a/src/components/BrowserMenu/MenuItem.react.js b/src/components/BrowserMenu/MenuItem.react.js index b0fb1fbd2b..27b1df6f9f 100644 --- a/src/components/BrowserMenu/MenuItem.react.js +++ b/src/components/BrowserMenu/MenuItem.react.js @@ -8,11 +8,17 @@ import React from 'react'; import styles from 'components/BrowserMenu/BrowserMenu.scss'; -let MenuItem = ({ text, disabled, onClick }) => { +let MenuItem = ({ text, disabled, active, greenActive, onClick }) => { let classes = [styles.item]; if (disabled) { classes.push(styles.disabled); } + if (active) { + classes.push(styles.active); + } + if (greenActive) { + classes.push(styles.greenActive); + } return
{text}
; }; diff --git a/src/components/Toggle/Toggle.react.js b/src/components/Toggle/Toggle.react.js index 5cb9d07f66..590304f40d 100644 --- a/src/components/Toggle/Toggle.react.js +++ b/src/components/Toggle/Toggle.react.js @@ -80,6 +80,10 @@ export default class Toggle extends React.Component { left = this.props.value === this.props.optionLeft; colored = this.props.colored; break; + case Toggle.Types.HIDE_LABELS: + colored = true; + left = !this.props.value; + break; default: labelLeft = 'No'; labelRight = 'Yes'; @@ -90,7 +94,10 @@ export default class Toggle extends React.Component { let switchClasses = [styles.switch]; if (colored) { - switchClasses.push(styles.colored) + switchClasses.push(styles.colored); + } + if (this.props.switchNoMargin) { + switchClasses.push(styles.switchNoMargin); } let toggleClasses = [styles.toggle, unselectable, input]; if (left) { @@ -101,9 +108,9 @@ export default class Toggle extends React.Component { } return (
- {labelLeft} + {labelLeft && {labelLeft}} - {labelRight} + {labelRight && {labelRight}}
); } diff --git a/src/components/Toggle/Toggle.scss b/src/components/Toggle/Toggle.scss index 02f2b8d607..43d70ce911 100644 --- a/src/components/Toggle/Toggle.scss +++ b/src/components/Toggle/Toggle.scss @@ -60,6 +60,10 @@ transition: background-position 0.15s ease-out; } +.switchNoMargin { + margin: 0; +} + .left { .label { &:first-of-type { diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index 71b9d26981..e7a8faf11e 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -81,7 +81,10 @@ class Browser extends DashboardView { uniqueField: null, keepAddingCols: false, markRequiredField: false, - requiredColumnFields: [] + requiredColumnFields: [], + + useMasterKey: true, + currentUser: Parse.User.current() }; this.prefetchData = this.prefetchData.bind(this); @@ -94,6 +97,9 @@ class Browser extends DashboardView { this.showDeleteRows = this.showDeleteRows.bind(this); this.showDropClass = this.showDropClass.bind(this); this.showExport = this.showExport.bind(this); + this.login = this.login.bind(this); + this.logout = this.logout.bind(this); + this.toggleMasterKeyUsage = this.toggleMasterKeyUsage.bind(this); this.showAttachRowsDialog = this.showAttachRowsDialog.bind(this); this.cancelAttachRows = this.cancelAttachRows.bind(this); this.confirmAttachRows = this.confirmAttachRows.bind(this); @@ -167,7 +173,8 @@ class Browser extends DashboardView { let relation = this.state.relation; if (isRelationRoute && !relation) { const parentObjectQuery = new Parse.Query(className); - const parent = await parentObjectQuery.get(entityId, { useMasterKey: true }); + const { useMasterKey } = this.state; + const parent = await parentObjectQuery.get(entityId, { useMasterKey }); relation = parent.relation(relationName); } await this.setState({ @@ -246,6 +253,25 @@ class Browser extends DashboardView { this.setState({ showExportDialog: true }); } + async login(username, password) { + if (Parse.User.current()) { + await Parse.User.logOut(); + } + + const currentUser = await Parse.User.logIn(username, password); + this.setState({ currentUser: currentUser, useMasterKey: false }, () => this.refresh()); + } + + async logout() { + await Parse.User.logOut(); + this.setState({ currentUser: null, useMasterKey: true }, () => this.refresh()); + } + + toggleMasterKeyUsage() { + const { useMasterKey } = this.state; + this.setState({ useMasterKey: !useMasterKey }, () => this.refresh()); + } + createClass(className) { this.props.schema.dispatch(ActionTypes.CREATE_CLASS, { className }).then(() => { this.state.counts[className] = 0; @@ -349,6 +375,7 @@ class Browser extends DashboardView { } saveNewRow(){ + const { useMasterKey } = this.state; const obj = this.state.newObject; if (!obj) { return; @@ -393,7 +420,7 @@ class Browser extends DashboardView { markRequiredField: false }); } - obj.save(null, { useMasterKey: true }).then( + obj.save(null, { useMasterKey }).then( objectSaved => { let msg = objectSaved.className + ' with id \'' + objectSaved.id + '\' created'; this.showNote(msg, false); @@ -405,7 +432,7 @@ class Browser extends DashboardView { const parentRelation = parent.relation(relation.key); parentRelation.add(obj); const targetClassName = relation.targetClassName; - parent.save(null, { useMasterKey: true }).then( + parent.save(null, { useMasterKey }).then( () => { this.setState({ newObject: null, @@ -497,6 +524,7 @@ class Browser extends DashboardView { } async fetchParseData(source, filters) { + const { useMasterKey } = this.state; const query = queryFromFilters(source, filters); const sortDir = this.state.ordering[0] === '-' ? '-' : '+'; const field = this.state.ordering.substr(sortDir === '-' ? 1 : 0) @@ -518,7 +546,7 @@ class Browser extends DashboardView { query.limit(MAX_ROWS_FETCHED); this.excludeFields(query, source); - let promise = query.find({ useMasterKey: true }); + let promise = query.find({ useMasterKey }); let isUnique = false; let uniqueField = null; filters.forEach(async (filter) => { @@ -547,7 +575,8 @@ class Browser extends DashboardView { async fetchParseDataCount(source, filters) { const query = queryFromFilters(source, filters); - const count = await query.count({ useMasterKey: true }); + const { useMasterKey } = this.state; + const count = await query.count({ useMasterKey }); return count; } @@ -650,7 +679,8 @@ class Browser extends DashboardView { query.limit(MAX_ROWS_FETCHED); this.excludeFields(query, source); - query.find({ useMasterKey: true }).then((nextPage) => { + const { useMasterKey } = this.state; + query.find({ useMasterKey }).then((nextPage) => { if (className === this.props.params.className) { this.setState((state) => ({ data: state.data.concat(nextPage) @@ -767,7 +797,9 @@ class Browser extends DashboardView { }); return; } - obj.save(null, { useMasterKey: true }).then((objectSaved) => { + + const { useMasterKey } = this.state; + obj.save(null, { useMasterKey }).then((objectSaved) => { let msg = objectSaved.className + ' with id \'' + objectSaved.id + '\' updated'; this.showNote(msg, false); const state = { data: this.state.data }; @@ -814,10 +846,11 @@ class Browser extends DashboardView { const toDeleteObjectIds = []; toDelete.forEach((obj) => { toDeleteObjectIds.push(obj.id); }); + const { useMasterKey } = this.state; let relation = this.state.relation; if (relation && toDelete.length) { relation.remove(toDelete); - relation.parent.save(null, { useMasterKey: true }).then(() => { + relation.parent.save(null, { useMasterKey }).then(() => { if (this.state.relation === relation) { for (let i = 0; i < indexes.length; i++) { this.state.data.splice(indexes[i] - i, 1); @@ -828,7 +861,7 @@ class Browser extends DashboardView { } }); } else if (toDelete.length) { - Parse.Object.destroyAll(toDelete, { useMasterKey: true }).then(() => { + Parse.Object.destroyAll(toDelete, { useMasterKey }).then(() => { let deletedNote; if (toDeleteObjectIds.length == 1) { @@ -921,11 +954,12 @@ class Browser extends DashboardView { if (!objectIds || !objectIds.length) { throw 'No objectId passed'; } + const { useMasterKey } = this.state; const relation = this.state.relation; const query = new Parse.Query(relation.targetClassName); const parent = relation.parent; query.containedIn('objectId', objectIds); - let objects = await query.find({ useMasterKey: true }); + let objects = await query.find({ useMasterKey }); const missedObjectsCount = objectIds.length - objects.length; if (missedObjectsCount) { const missedObjects = []; @@ -939,7 +973,7 @@ class Browser extends DashboardView { throw `${errorSummary} ${JSON.stringify(missedObjects)}`; } parent.relation(relation.key).add(objects); - await parent.save(null, { useMasterKey: true }); + await parent.save(null, { useMasterKey }); // remove duplication this.state.data.forEach(origin => objects = objects.filter(object => object.id !== origin.id)); this.setState({ @@ -965,13 +999,14 @@ class Browser extends DashboardView { } async confirmAttachSelectedRows(className, targetObjectId, relationName, objectIds, targetClassName) { + const { useMasterKey } = this.state; const parentQuery = new Parse.Query(className); - const parent = await parentQuery.get(targetObjectId, { useMasterKey: true }); + const parent = await parentQuery.get(targetObjectId, { useMasterKey }); const query = new Parse.Query(targetClassName || this.props.params.className); query.containedIn('objectId', objectIds); - const objects = await query.find({ useMasterKey: true }); + const objects = await query.find({ useMasterKey }); parent.relation(relationName).add(objects); - await parent.save(null, { useMasterKey: true }); + await parent.save(null, { useMasterKey }); this.setState({ selection: {}, }); @@ -990,6 +1025,7 @@ class Browser extends DashboardView { } async confirmCloneSelectedRows() { + const { useMasterKey } = this.state; const objectIds = []; for (const objectId in this.state.selection) { objectIds.push(objectId); @@ -997,13 +1033,13 @@ class Browser extends DashboardView { const className = this.props.params.className; const query = new Parse.Query(className); query.containedIn('objectId', objectIds); - const objects = await query.find({ useMasterKey: true }); + const objects = await query.find({ useMasterKey }); const toClone = []; for (const object of objects) { toClone.push(object.clone()); } try { - await Parse.Object.saveAll(toClone, { useMasterKey: true }); + await Parse.Object.saveAll(toClone, { useMasterKey }); this.setState({ selection: {}, data: [...toClone, ...this.state.data], @@ -1200,7 +1236,11 @@ class Browser extends DashboardView { onEditPermissions={this.onDialogToggle} onSaveNewRow={this.saveNewRow} onAbortAddRow={this.abortAddRow} - + currentUser={this.state.currentUser} + useMasterKey={this.state.useMasterKey} + login={this.login} + logout={this.logout} + toggleMasterKeyUsage={this.toggleMasterKeyUsage} markRequiredField={this.state.markRequiredField} requiredColumnFields={this.state.requiredColumnFields} columns={columns} @@ -1376,6 +1416,7 @@ class Browser extends DashboardView { updateRow={this.updateRow} confirmAttachSelectedRows={this.confirmAttachSelectedRows} schema={this.props.schema} + useMasterKey={this.state.useMasterKey} /> ) } diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 017ac7573b..1a738ea7fa 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -15,8 +15,10 @@ import Separator from 'components/BrowserMenu/Separator.react'; import styles from 'dashboard/Data/Browser/Browser.scss'; import Toolbar from 'components/Toolbar/Toolbar.react'; import SecurityDialog from 'dashboard/Data/Browser/SecurityDialog.react'; -import ColumnsConfiguration from 'components/ColumnsConfiguration/ColumnsConfiguration.react' +import ColumnsConfiguration from 'components/ColumnsConfiguration/ColumnsConfiguration.react'; import SecureFieldsDialog from 'dashboard/Data/Browser/SecureFieldsDialog.react'; +import LoginDialog from 'dashboard/Data/Browser/LoginDialog.react'; +import Toggle from 'components/Toggle/Toggle.react'; let BrowserToolbar = ({ className, @@ -57,6 +59,12 @@ let BrowserToolbar = ({ enableColumnManipulation, enableClassManipulation, + + currentUser, + useMasterKey, + login, + logout, + toggleMasterKeyUsage, }) => { let selectionLength = Object.keys(selection).length; let details = []; @@ -84,7 +92,7 @@ let BrowserToolbar = ({ let menu = null; if (relation) { menu = ( - + + {enableColumnManipulation ? :