diff --git a/Dockerfile b/Dockerfile index 5e7f52afbd..95f0f7d55c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # # --- Base Node Image --- -FROM node:8-alpine AS base +FROM node:lts-alpine AS base RUN apk update; \ apk add git; @@ -23,7 +23,7 @@ RUN npm run build # # --- Production Image --- -FROM node:8-alpine AS release +FROM node:lts-alpine AS release WORKDIR /src # Copy production node_modules diff --git a/Parse-Dashboard/Authentication.js b/Parse-Dashboard/Authentication.js index ef6bf88bae..6a859fa546 100644 --- a/Parse-Dashboard/Authentication.js +++ b/Parse-Dashboard/Authentication.js @@ -3,6 +3,7 @@ var bcrypt = require('bcryptjs'); var csrf = require('csurf'); var passport = require('passport'); var LocalStrategy = require('passport-local').Strategy; +const OTPAuth = require('otpauth') /** * Constructor for Authentication class @@ -21,14 +22,22 @@ function initialize(app, options) { options = options || {}; var self = this; passport.use('local', new LocalStrategy( - function(username, password, cb) { + {passReqToCallback:true}, + function(req, username, password, cb) { var match = self.authenticate({ name: username, - pass: password + pass: password, + otpCode: req.body.otpCode }); if (!match.matchingUsername) { return cb(null, false, { message: 'Invalid username or password' }); } + if (match.otpMissing) { + return cb(null, false, { message: 'Please enter your one-time password.' }); + } + if (!match.otpValid) { + return cb(null, false, { message: 'Invalid one-time password.' }); + } cb(null, match.matchingUsername); }) ); @@ -82,6 +91,8 @@ function authenticate(userToTest, usernameOnly) { let appsUserHasAccessTo = null; let matchingUsername = null; let isReadOnly = false; + let otpMissing = false; + let otpValid = true; //they provided auth let isAuthenticated = userToTest && @@ -91,6 +102,22 @@ function authenticate(userToTest, usernameOnly) { this.validUsers.find(user => { let isAuthenticated = false; let usernameMatches = userToTest.name == user.user; + if (usernameMatches && user.mfa && !usernameOnly) { + if (!userToTest.otpCode) { + otpMissing = true; + } else { + const totp = new OTPAuth.TOTP({ + algorithm: user.mfaAlgorithm || 'SHA1', + secret: OTPAuth.Secret.fromBase32(user.mfa) + }); + const valid = totp.validate({ + token: userToTest.otpCode + }); + if (valid === null) { + otpValid = false; + } + } + } let passwordMatches = this.useEncryptedPasswords && !usernameOnly ? bcrypt.compareSync(userToTest.pass, user.pass) : userToTest.pass == user.pass; if (usernameMatches && (usernameOnly || passwordMatches)) { isAuthenticated = true; @@ -99,13 +126,14 @@ function authenticate(userToTest, usernameOnly) { appsUserHasAccessTo = user.apps || null; isReadOnly = !!user.readOnly; // make it true/false } - return isAuthenticated; }) ? true : false; return { isAuthenticated, matchingUsername, + otpMissing, + otpValid, appsUserHasAccessTo, isReadOnly, }; diff --git a/Parse-Dashboard/parse-dashboard-config.json b/Parse-Dashboard/parse-dashboard-config.json index 018d79904d..0865981124 100644 --- a/Parse-Dashboard/parse-dashboard-config.json +++ b/Parse-Dashboard/parse-dashboard-config.json @@ -12,5 +12,6 @@ "isOwner": true } } - ] + ], + "iconsFolder": "icons" } diff --git a/README.md b/README.md index 4ad256b97a..39e4adece0 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,35 +11,40 @@ 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) -* [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) + - [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) + - [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) + - [CSV Export](#csv-export) +- [Contributing](#contributing) # Getting Started -[Node.js](https://nodejs.org) version >= 8.9 is required to run the dashboard. You also need to be using Parse Server version 2.1.4 or higher. +[Node.js](https://nodejs.org) version >= 12 is required to run the dashboard. You also need to be using Parse Server version 2.1.4 or higher. # Local Installation @@ -57,7 +62,7 @@ parse-dashboard --dev --appId yourAppId --masterKey yourMasterKey --serverURL "h You may set the host, port and mount path by supplying the `--host`, `--port` and `--mountPath` options to parse-dashboard. You can use anything you want as the app name, or leave it out in which case the app ID will be used. -NB: the `--dev` parameter is disabling production-ready security features, do not use this parameter when starting the dashboard in production. This parameter is useful if you are running on docker. +NB: the `--dev` parameter is disabling production-ready security features, do not use this parameter when starting the dashboard in production. This parameter is useful if you are running on docker. After starting the dashboard, you can visit http://localhost:4040 in your browser: @@ -241,6 +246,33 @@ You can set `appNameForURL` in the config file for each app to control the url o To change the app to production, simply set `production` to `true` in your config file. The default value is false if not specified. + ### Prevent columns sorting + +You can prevent some columns to be sortable by adding `preventSort` to columnPreference options in each app configuration + +```json + +"apps": [ + { + "appId": "local_app_id", + "columnPreference": { + "_User": [ + { + "name": "createdAt", + "visible": true, + "preventSort": true + }, + { + "name": "updatedAt", + "visible": true, + "preventSort": false + }, + ] + } + } +] +``` + # Running as Express Middleware Instead of starting Parse Dashboard with the CLI, you can also run it as an [express](https://github.com/expressjs/express) middleware. @@ -347,7 +379,33 @@ You can configure your dashboard for Basic Authentication by adding usernames an ``` You can store the password in either `plain text` or `bcrypt` formats. To use the `bcrypt` format, you must set the config `useEncryptedPasswords` parameter to `true`. -You can encrypt the password using any online bcrypt tool e.g. [https://www.bcrypt-generator.com](https://www.bcrypt-generator.com). +You can generate encrypted passwords by using `parse-dashboard --createUser`, and pasting the result in your users config. + +### Multi-Factor Authentication (One-Time Password) + +You can add an additional layer of security for a user account by requiring multi-factor authentication (MFA) for the user to login. + +With MFA enabled, a user must provide a one-time password that is typically bound to a physical device, in addition to their login password. This means in addition to knowing the login password, the user needs to have physical access to a device to generate the one-time password. This one-time password is time-based (TOTP) and only valid for a short amount of time, typically 30 seconds, until it expires. + +The user requires an authenticator app to generate the one-time password. These apps are provided by many 3rd parties and mostly for free. + +If you create a new user by running `parse-dashboard --createUser`, you will be asked whether you want to enable MFA for the new user. To enable MFA for an existing user, +run `parse-dashboard --createMFA` to generate a `mfa` secret that you then add to the existing user configuration, for example: + +```json +{ + "apps": [{"...": "..."}], + "users": [ + { + "user":"user1", + "pass":"pass", + "mfa": "lmvmOIZGMTQklhOIhveqkumss" + } + ] +} +``` + + Parse Dashboard follows the industry standard and supports the common OTP algorithm `SHA-1` by default, to be compatible with most authenticator apps. If you have specific security requirements regarding TOTP characteristics (algorithm, digit length, time period) you can customize them by using the guided configuration mentioned above. ### Separating App Access Based on User Identity If you have configured your dashboard to manage multiple applications, you can restrict the management of apps based on user identity. @@ -421,7 +479,7 @@ You can mark a user as a read-only user: "appId": "myAppId1", "masterKey": "myMasterKey1", "readOnlyMasterKey": "myReadOnlyMasterKey1", - "serverURL": "myURL1", + "serverURL": "myURL1", "port": 4040, "production": true }, @@ -429,7 +487,7 @@ You can mark a user as a read-only user: "appId": "myAppId2", "masterKey": "myMasterKey2", "readOnlyMasterKey": "myReadOnlyMasterKey2", - "serverURL": "myURL2", + "serverURL": "myURL2", "port": 4041, "production": true } @@ -464,7 +522,7 @@ You can give read only access to a user on a per-app basis: "appId": "myAppId1", "masterKey": "myMasterKey1", "readOnlyMasterKey": "myReadOnlyMasterKey1", - "serverURL": "myURL", + "serverURL": "myURL", "port": 4040, "production": true }, @@ -505,7 +563,7 @@ You can provide a list of locales or languages you want to support for your dash ## Run with Docker -The official docker image is published on [docker hub](https://hub.docker.com/r/parseplatform/parse-dashboard) +The official docker image is published on [docker hub](https://hub.docker.com/r/parseplatform/parse-dashboard) Run the image with your ``config.json`` mounted as a volume @@ -529,6 +587,25 @@ 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. + +## CSV Export + +▶️ *Core > Browser > Export* + +This feature will take either selected rows or all rows of an individual class and saves them to a CSV file, which is then downloaded. CSV headers are added to the top of the file matching the column names. + +> ⚠️ There is currently a 10,000 row limit when exporting all data. If more than 10,000 rows are present in the class, the CSV file will only contain 10,000 rows. + # 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/package.json b/package.json index dfdb9be847..de4df2d65e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,11 @@ { "name": "parse-dashboard", + "version": "3.0.0", + "repository": { + "type": "git", + "url": "https://github.com/ParsePlatform/parse-dashboard" + }, + "description": "The Parse Dashboard", "parseDashboardFeatures": [ "Data Browser", "Cloud Code Viewer", @@ -15,18 +21,12 @@ "Push Status Page", "Relation Editor" ], - "description": "The Parse Dashboard", "keywords": [ "parse", "dashboard" ], "homepage": "https://github.com/ParsePlatform/parse-dashboard", "bugs": "https://github.com/ParsePlatform/parse-dashboard/issues", - "version": "2.1.0", - "repository": { - "type": "git", - "url": "https://github.com/ParsePlatform/parse-dashboard" - }, "license": "SEE LICENSE IN LICENSE", "files": [ "Parse-Dashboard", @@ -35,7 +35,7 @@ "LICENSE" ], "dependencies": { - "@babel/runtime": "7.13.10", + "@babel/runtime": "7.15.4", "@back4app/back4app-settings": "latest", "axios": "^0.18.0", "axios-rate-limit": "^1.3.0", @@ -60,24 +60,27 @@ "intro.js": "^2.9.3", "jquery": "^3.2.1", "js-base64": "^2.5.1", - "js-beautify": "1.13.5", + "inquirer": "8.1.2", + "js-beautify": "1.14.0", "js-cookie": "^2.2.1", "json-file-plus": "3.2.0", "jstree": "^3.3.5", "lodash": "^4.17.10", "material-design-iconic-font": "^2.2.0", "moment": "^2.24.0", + "otpauth": "7.0.5", "package-json": "6.5.0", "parse": "git+https://github.com/back4app/Parse-SDK-JS.git#back4app-upstream", "passport": "0.4.1", "passport-local": "1.0.0", "popper.js": "^1.12.9", - "prismjs": "1.23.0", + "prismjs": "1.25.0", "prop-types": "15.7.2", + "qrcode": "1.4.4", "query-string": "6.14.1", "re-resizable": "^4.11.0", "react": "16.14.0", - "react-ace": "9.4.0", + "react-ace": "9.4.3", "react-dnd": "10.0.2", "react-dnd-html5-backend": "10.0.2", "react-dom": "16.14.0", @@ -86,25 +89,33 @@ "react-infinite-scroller": "^1.2.4", "react-json-view": "1.21.3", "react-player": "^1.7.0", - "react-popper-tooltip": "4.2.0", + "react-popper-tooltip": "4.3.0", "react-redux": "5.1.2", - "react-router": "5.1.2", - "react-router-dom": "5.1.2", "react-syntax-highlighter": "11.0.2", "react-tabs": "^2.2.2", - "regenerator-runtime": "0.13.5", + "react-router": "5.2.1", + "react-router-dom": "5.2.1", + "regenerator-runtime": "0.13.8", "semver": "7.3.4", "sweetalert2": "^8.0.5", "sweetalert2-react-content": "^1.0.1", "symbol-observable": "^1.2.0" }, "devDependencies": { + "@actions/core": "1.2.6", "@babel/core": "7.8.7", "@babel/plugin-proposal-decorators": "7.8.3", "@babel/plugin-transform-regenerator": "7.8.7", "@babel/plugin-transform-runtime": "7.8.3", "@babel/preset-env": "7.9.0", "@babel/preset-react": "7.8.3", + "@semantic-release/changelog": "5.0.1", + "@semantic-release/commit-analyzer": "8.0.1", + "@semantic-release/git": "9.0.0", + "@semantic-release/github": "7.2.3", + "@semantic-release/npm": "7.1.3", + "@semantic-release/release-notes-generator": "9.0.3", + "all-node-versions": "8.0.0", "babel-eslint": "10.1.0", "babel-loader": "8.1.0", "babel-plugin-transform-object-rest-spread": "6.26.0", @@ -118,22 +129,25 @@ "file-loader": "6.0.0", "http-server": "0.12.0", "jest": "24.8.0", + "madge": "5.0.1", "marked": "0.8.2", - "node-sass": "^4.13.1", "nodemon": "^1.18.3", - "null-loader": "^3.0.0", "parse-server": "4.10.3", + "node-sass": "5.0.0", + "null-loader": "3.0.0", "path-to-regexp": "3.2.0", "puppeteer": "3.0.0", "react-test-renderer": "16.13.1", "request": "2.88.2", "request-promise": "4.2.5", - "sass-loader": "8.0.0", + "sass-loader": "10.1.1", + "semantic-release": "17.4.6", "style-loader": "1.1.2", "svg-prep": "1.0.4", "terser-webpack-plugin": "^1.3.0", "webpack": "4.42.1", - "webpack-cli": "3.3.10" + "webpack-cli": "3.3.10", + "yaml": "1.10.0" }, "scripts": { "dev": "nodemon ./Parse-Dashboard/index.js & cross-env ENVIRONMENT='local' webpack --config webpack/build.config.js --env.local --devtool eval-source-map --progress --watch", @@ -155,7 +169,7 @@ "parse-dashboard": "./bin/parse-dashboard" }, "engines": { - "node": ">=10.6" + "node": ">=12.0.0 <16.0.0" }, "main": "Parse-Dashboard/app.js", "jest": { diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000000..304b172701 --- /dev/null +++ b/release.config.js @@ -0,0 +1,115 @@ +/** + * Semantic Release Config + */ + +const fs = require('fs').promises; +const path = require('path'); + +// Get env vars +const ref = process.env.GITHUB_REF; +const serverUrl = process.env.GITHUB_SERVER_URL; +const repository = process.env.GITHUB_REPOSITORY; +const repositoryUrl = serverUrl + '/' + repository; + +// Declare params +const resourcePath = './.releaserc/'; +const templates = { + main: { file: 'template.hbs', text: undefined }, + header: { file: 'header.hbs', text: undefined }, + commit: { file: 'commit.hbs', text: undefined }, + footer: { file: 'footer.hbs', text: undefined }, +}; + +// Declare semantic config +async function config() { + + // Get branch + const branch = ref.split('/').pop(); + console.log(`Running on branch: ${branch}`); + + // Set changelog file + const changelogFile = `./changelogs/CHANGELOG_${branch}.md`; + console.log(`Changelog file output to: ${changelogFile}`); + + // Load template file contents + await loadTemplates(); + + const config = { + branches: [ + 'master', + { name: 'alpha', prerelease: true }, + { name: 'beta', prerelease: true }, + 'next-major', + // Long-Term-Support branches + // { name: '1.x', range: '1.x.x', channel: '1.x' }, + // { name: '2.x', range: '2.x.x', channel: '2.x' }, + // { name: '3.x', range: '3.x.x', channel: '3.x' }, + // { name: '4.x', range: '4.x.x', channel: '4.x' }, + // { name: '5.x', range: '5.x.x', channel: '5.x' }, + // { name: '6.x', range: '6.x.x', channel: '6.x' }, + ], + dryRun: false, + debug: true, + ci: true, + tagFormat: '${version}', + plugins: [ + ['@semantic-release/commit-analyzer', { + preset: 'angular', + releaseRules: [ + { type: 'docs', scope: 'README', release: 'patch' }, + { type: 'refactor', release: 'patch' }, + { scope: 'no-release', release: false }, + ], + parserOpts: { + noteKeywords: [ 'BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING' ], + }, + }], + ['@semantic-release/release-notes-generator', { + preset: 'angular', + parserOpts: { + noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'] + }, + writerOpts: { + commitsSort: ['subject', 'scope'], + mainTemplate: templates.main.text, + headerPartial: templates.header.text, + commitPartial: templates.commit.text, + footerPartial: templates.footer.text, + }, + }], + ['@semantic-release/changelog', { + 'changelogFile': changelogFile, + }], + ['@semantic-release/npm', { + 'npmPublish': true, + }], + ['@semantic-release/git', { + assets: [changelogFile, 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'], + }], + ['@semantic-release/github', { + successComment: getReleaseComment(), + }], + ], + }; + + return config; +} + +async function loadTemplates() { + for (const template of Object.keys(templates)) { + const text = await readFile(path.resolve(__dirname, resourcePath, templates[template].file)); + templates[template].text = text; + } +} + +async function readFile(filePath) { + return await fs.readFile(filePath, 'utf-8'); +} + +function getReleaseComment() { + const url = repositoryUrl + '/releases/tag/${nextRelease.gitTag}'; + let comment = '🎉 This pull request has been released in version [${nextRelease.version}](' + url + ')'; + return comment; +} + +module.exports = config(); diff --git a/server.js b/server.js deleted file mode 100755 index 34e355f30f..0000000000 --- a/server.js +++ /dev/null @@ -1,19 +0,0 @@ -const express = require('express'); -const { ParseServer } = require('parse-server'); - -const api = new ParseServer({ - databaseURI: 'mongodb://localhost:27017/dashboard', - appId: 'hello', - masterKey: 'world', - serverURL: 'http://localhost:1338/parse', -}); - -const app = express(); -app.use('/parse', api); - -const port = 1338; -const httpServer = require('http').createServer(app); - -httpServer.listen(port, () => { - console.log(`parse-server running on port: ${port}`); -}); diff --git a/src/components/BrowserCell/BrowserCell.react.js b/src/components/BrowserCell/BrowserCell.react.js index ba5de05c82..4c775adf23 100644 --- a/src/components/BrowserCell/BrowserCell.react.js +++ b/src/components/BrowserCell/BrowserCell.react.js @@ -13,10 +13,10 @@ import Parse from 'parse'; import Pill from 'components/Pill/Pill.react'; import React, { Component } from 'react'; import styles from 'components/BrowserCell/BrowserCell.scss'; -import Tooltip from 'components/Tooltip/PopperTooltip.react'; import PropTypes from "lib/PropTypes"; import { unselectable } from 'stylesheets/base.scss'; import { DefaultColumns } from 'lib/Constants'; +import Tooltip from '../Tooltip/PopperTooltip.react'; class BrowserCell extends Component { constructor() { diff --git a/src/components/BrowserCell/BrowserCell.scss b/src/components/BrowserCell/BrowserCell.scss index 220c0609bc..6de4db1d39 100644 --- a/src/components/BrowserCell/BrowserCell.scss +++ b/src/components/BrowserCell/BrowserCell.scss @@ -45,11 +45,19 @@ height: auto; max-height: 25px; overflow-y: scroll; + padding-right: 3px; + & > li { + margin-bottom: 2px; + } +} + +.removePadding { + padding-right: 3px !important; } .hasMore::-webkit-scrollbar { -webkit-appearance: none!important; - width: 7px!important; + width: 6px!important; } .hasMore::-webkit-scrollbar-thumb { @@ -57,3 +65,17 @@ background-color: rgba(0, 0, 0, .5)!important; box-shadow: 0 0 1px rgba(255, 255, 255, .5)!important; } +.required { + position: relative; + + &:after { + position: absolute; + pointer-events: none; + content: ''; + border: 2px solid #ff395e; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} diff --git a/src/components/BrowserFilter/BrowserFilter.react.js b/src/components/BrowserFilter/BrowserFilter.react.js index c6165fa2df..f6ae389b17 100644 --- a/src/components/BrowserFilter/BrowserFilter.react.js +++ b/src/components/BrowserFilter/BrowserFilter.react.js @@ -145,9 +145,12 @@ export default class BrowserFilter extends React.Component { if (this.props.filters.size) { wrapperStyle.push(styles.active); } + if (this.props.disabled) { + buttonStyle.push(styles.disabled); + } return (
-
+
{/* {this.props.filters.size ? 'Filtered' : 'Filter'} */}
diff --git a/src/components/BrowserFilter/BrowserFilter.scss b/src/components/BrowserFilter/BrowserFilter.scss index d67b9dc0ab..85701f43c5 100644 --- a/src/components/BrowserFilter/BrowserFilter.scss +++ b/src/components/BrowserFilter/BrowserFilter.scss @@ -30,6 +30,19 @@ svg { fill: white; } + + &.disabled { + cursor: not-allowed; + color: #bbbbbb; + + svg { + fill: #bbbbbb; + } + + &:hover svg { + fill: #9593a1; + } + } } .entry.active { diff --git a/src/components/BrowserMenu/BrowserMenu.react.js b/src/components/BrowserMenu/BrowserMenu.react.js index 190ec31d7a..f36ce24ad1 100644 --- a/src/components/BrowserMenu/BrowserMenu.react.js +++ b/src/components/BrowserMenu/BrowserMenu.react.js @@ -28,18 +28,25 @@ 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 && !this.state.open) { + // titleStyle.push(styles.active); + // } menu = ( this.setState({ open: false })}>
-
this.setState({ open: false })}> +
this.setState({ open: false })}>
{React.Children.map(this.props.children, (child) => ( - React.cloneElement(child, { ...child.props, onClick: () => { - this.setState({ open: false }); - child.props.onClick(); - }}) + React.cloneElement(child, { ...child.props, + onClose: () => this.setState({ open: false }), + onClick: () => { + this.setState({ open: false }); + child.props.onClick(); + } + }) ))}
@@ -47,6 +54,9 @@ export default class BrowserMenu extends React.Component { ); } const classes = [styles.entry]; + if (this.props.active && !this.state.open) { + classes.push(styles.active); + } if (this.props.disabled) { classes.push(styles.disabled); } @@ -54,6 +64,7 @@ export default class BrowserMenu extends React.Component { if (!this.props.disabled) { onClick = () => { this.setState({ open: true }); + this.props.setCurrent(null); }; } return ( @@ -77,4 +88,7 @@ BrowserMenu.propTypes = { children: PropTypes.arrayOf(PropTypes.node).describe( 'The contents of the menu when open. It should be a set of MenuItem and Separator components.' ), + active: PropTypes.bool.describe( + 'Indicates whether it has any active item or not.' + ), }; diff --git a/src/components/BrowserMenu/BrowserMenu.scss b/src/components/BrowserMenu/BrowserMenu.scss index 3e1fb1d7b0..d11fb735ab 100644 --- a/src/components/BrowserMenu/BrowserMenu.scss +++ b/src/components/BrowserMenu/BrowserMenu.scss @@ -35,6 +35,17 @@ fill: #66637A; } } + + &.active { + height: 100%; + background: $orange; + border-radius: 5px; + padding-top: 3px; + + svg { + fill: white; + } + } } .title { @@ -45,20 +56,26 @@ svg { fill: white; } + + &.active { + background: $orange; + border-radius: 5px; + } } .entry, .title { @include NotoSansFont; + position: relative; font-size: 14px; color: #ffffff; cursor: pointer; svg { - vertical-align: middle; + vertical-align: top; } span { - vertical-align: middle; + vertical-align: top; height: 14px; line-height: 14px; } @@ -75,6 +92,7 @@ } .item { + position: relative; padding: 6px 14px; white-space: nowrap; cursor: pointer; @@ -85,6 +103,17 @@ background: $blue; } + &.open { + background: $blue; + } + + &.active { + background: $orange; + &:hover { + background: $orange !important; + } + } + &.disabled { color: rgba(0,0,0,0.2); cursor: not-allowed; @@ -100,3 +129,41 @@ height: 1px; margin: 4px 0; } + +.subMenuBody { + position: absolute; + top: -6px; + border-radius: 5px 0 0 5px; + background: #797592; + padding: 6px 0; + font-size: 14px; + & .item { + padding-left: 20px; + } + & .item::before { + content: ''; + position: absolute; + top: 12px; + left: 8px; + width: 5px; + height: 5px; + background: white; + border-radius: 50%; + } + & .item.disabled::before { + background: rgba(0,0,0,0.2); + } +} + +.rightArrowIcon { + &::before { + content: ''; + position: absolute; + @include arrow('right', 8px, 4px, white); + top: 8px; + right: 10px; + } + &.open::before { + @include arrow('left', 8px, 4px, white); + } +} \ No newline at end of file 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/BrowserMenu/SubMenuItem.react.js b/src/components/BrowserMenu/SubMenuItem.react.js new file mode 100644 index 0000000000..cccc8eff78 --- /dev/null +++ b/src/components/BrowserMenu/SubMenuItem.react.js @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import PropTypes from 'lib/PropTypes'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import styles from 'components/BrowserMenu/BrowserMenu.scss'; + +export default class SubMenuItem extends React.Component { + constructor() { + super(); + + this.state = { open: false }; + this.toggle = this.toggle.bind(this); + } + + componentDidMount() { + this.node = ReactDOM.findDOMNode(this); + } + + toggle() { + if (!this.props.disabled) { + this.setState({ open: !this.state.open }); + this.props.setCurrent(null); + } + } + + render() { + const classes = [styles.item, styles.rightArrowIcon]; + if (this.state.open && this.props.disabled) { + classes.push(styles.open); + } + if (this.props.active) { + classes.push(styles.active); + } + if (this.props.disabled) { + classes.push(styles.disabled); + } + + return ( +
+
this.setState({ open: true })} + onMouseLeave={() => this.setState({ open: false })} + // onClick={this.toggle} + title={this.props.title} + > + {this.props.title} +
e.stopPropagation()}> +
+ {React.Children.map(this.props.children, (child) => + React.cloneElement(child, { + ...child.props, + onClick: () => { + this.setState({ open: false }); // close submenu + this.props.onClose(); // close top menu + child.props.onClick(); + }, + }) + )} +
+
+
+
+ ); + } +} + +SubMenuItem.propTypes = { + title: PropTypes.string.isRequired.describe( + 'The title text of the menu.' + ), + children: PropTypes.arrayOf(PropTypes.node).describe( + 'The contents of the menu when open. It should be a set of MenuItem and Separator components.' + ), +}; diff --git a/src/components/BrowserRow/BrowserRow.react.js b/src/components/BrowserRow/BrowserRow.react.js index cb54622ce9..7212403f85 100644 --- a/src/components/BrowserRow/BrowserRow.react.js +++ b/src/components/BrowserRow/BrowserRow.react.js @@ -19,8 +19,21 @@ export default class BrowserRow extends Component { } render() { - const { className, columns, currentCol, isUnique, obj, onPointerClick, onPointerCmdClick, order, readOnlyFields, row, rowWidth, selection, selectRow, setCopyableValue, setCurrent, setEditing, setRelation, onEditSelectedRow, setContextMenu, onFilterChange, onAddRow, onAddColumn, onDeleteRows, onDeleteSelectedColumn } = this.props; + const { className, columns, currentCol, isUnique, obj, onPointerClick, onPointerCmdClick, order, readOnlyFields, row, rowWidth, selection, selectRow, setCopyableValue, setCurrent, setEditing, setRelation, onEditSelectedRow, setContextMenu, onFilterChange, markRequiredFieldRow, onAddRow, onAddColumn, onDeleteRows, onDeleteSelectedColumn } = this.props; let attributes = obj.attributes; + let requiredCols = []; + Object.entries(columns).reduce((acc, cur) => { + if (cur[1].required) { + acc.push(cur[0]); + } + return acc; + }, requiredCols); + // for dynamically changing required field on _User class + if (obj.className === '_User' && (obj.get('username') !== undefined || obj.get('password') !== undefined)) { + requiredCols = ['username', 'password']; + } else if (obj.className === '_User' && obj.get('authData') !== undefined) { + requiredCols = ['authData']; + } return (
@@ -69,6 +82,7 @@ export default class BrowserRow extends Component { } } + let isRequired = requiredCols.includes(name); return (
]; - headers.forEach(({ width, name, type, targetClass, order, visible, required }, i) => { + headers.forEach(({ width, name, type, targetClass, order, visible, required, preventSort }, i) => { if (!visible) return; let wrapStyle = { width }; if (i % 2) { @@ -36,15 +36,20 @@ export default class DataBrowserHeaderBar extends React.Component { wrapStyle.background = '#66637A'; } let onClick = null; - if (type === 'String' || type === 'Number' || type === 'Date' || type === 'Boolean') { + if (!preventSort && (type === 'String' || type === 'Number' || type === 'Date' || type === 'Boolean')) { onClick = () => updateOrdering((order === 'descending' ? '' : '-') + name); } + let className = styles.wrap; + if (preventSort) { + className += ` ${styles.preventSort} `; + } + elements.push(
diff --git a/src/components/DataBrowserHeaderBar/DataBrowserHeaderBar.scss b/src/components/DataBrowserHeaderBar/DataBrowserHeaderBar.scss index b4baef9e5f..84db3e6e29 100644 --- a/src/components/DataBrowserHeaderBar/DataBrowserHeaderBar.scss +++ b/src/components/DataBrowserHeaderBar/DataBrowserHeaderBar.scss @@ -71,3 +71,9 @@ } } } + +.preventSort { + :hover { + cursor: not-allowed; + } +} diff --git a/src/components/DateTimeEditor/DateTimeEditor.react.js b/src/components/DateTimeEditor/DateTimeEditor.react.js index 23e69ef4f4..49d263d727 100644 --- a/src/components/DateTimeEditor/DateTimeEditor.react.js +++ b/src/components/DateTimeEditor/DateTimeEditor.react.js @@ -113,6 +113,7 @@ export default class DateTimeEditor extends React.Component { ref='input' onFocus={e => e.target.select()} value={this.state.text} + onFocus={e => e.target.select()} onClick={this.toggle.bind(this)} onChange={this.inputDate.bind(this)} onBlur={this.commitDate.bind(this)} /> diff --git a/src/components/Field/Field.scss b/src/components/Field/Field.scss index 1153c1770c..116a490828 100644 --- a/src/components/Field/Field.scss +++ b/src/components/Field/Field.scss @@ -42,11 +42,12 @@ min-height: 80px; text-align: right; padding: 0; + background: #f6fafb; + display: flex; + justify-content: center; + align-items: center; } -.input { - margin: 25px 20px 0 0; -} .header { min-height: 56px; @@ -56,10 +57,6 @@ min-height: 56px; } - .input { - margin: 13px 20px 0 0; - } - & ~ .field { background: #f5f5f7; } diff --git a/src/components/FileEditor/FileEditor.react.js b/src/components/FileEditor/FileEditor.react.js index dbd839ceb6..7e438a995a 100644 --- a/src/components/FileEditor/FileEditor.react.js +++ b/src/components/FileEditor/FileEditor.react.js @@ -26,8 +26,12 @@ export default class FileEditor extends React.Component { componentDidMount() { document.body.addEventListener('click', this.checkExternalClick); document.body.addEventListener('keypress', this.handleKey); + let fileInputElement = document.getElementById('fileInput'); + if (fileInputElement) { + fileInputElement.click(); + } } - + componentWillUnmount() { document.body.removeEventListener('click', this.checkExternalClick); document.body.removeEventListener('keypress', this.handleKey); @@ -72,13 +76,11 @@ export default class FileEditor extends React.Component { render() { const file = this.props.value; return ( -
- {file && file.url() ? Download : null} + ); } diff --git a/src/components/FileInput/FileInput.react.js b/src/components/FileInput/FileInput.react.js index 85699628fa..cff626fd95 100644 --- a/src/components/FileInput/FileInput.react.js +++ b/src/components/FileInput/FileInput.react.js @@ -49,7 +49,7 @@ export default class FileInput extends React.Component { } let label = this.renderLabel(); let buttonStyles = [styles.button]; - if (this.props.disabled) { + if (this.props.disabled || this.props.uploading) { buttonStyles.push(styles.disabled); } if (label) { diff --git a/src/components/LoginForm/LoginForm.react.js b/src/components/LoginForm/LoginForm.react.js index 6312e9ec96..e651658ff1 100644 --- a/src/components/LoginForm/LoginForm.react.js +++ b/src/components/LoginForm/LoginForm.react.js @@ -33,6 +33,7 @@ export default class LoginForm extends React.Component { if (this.props.disableSubmit) { return; } + this.props.formSubmit(); this.refs.form.submit() }} className={styles.submit} diff --git a/src/components/Pill/Pill.react.js b/src/components/Pill/Pill.react.js index 0371e626ee..45c6aa9d5a 100644 --- a/src/components/Pill/Pill.react.js +++ b/src/components/Pill/Pill.react.js @@ -7,9 +7,29 @@ */ import React from 'react'; import styles from 'components/Pill/Pill.scss'; +import Icon from "components/Icon/Icon.react"; + //TODO: refactor, may want to move onClick outside or need to make onClick able to handle link/button a11y -let Pill = ({ value, onClick }) => ( - !e.metaKey && onClick()}>{value} +let Pill = ({ value, onClick, fileDownloadLink, followClick = false }) => ( + + {value} + {followClick && ( + !e.metaKey && onClick()}> + + + )} + {!followClick && fileDownloadLink && ( + + + + )} + ); export default Pill; diff --git a/src/components/Pill/Pill.scss b/src/components/Pill/Pill.scss index 156a633dc5..95987e52a6 100644 --- a/src/components/Pill/Pill.scss +++ b/src/components/Pill/Pill.scss @@ -9,20 +9,43 @@ .pill { @include MonospaceFont; - display: inline-block; - background: #D5E5F2; + display: flex; + justify-content: space-between; + align-items: center; color: #0E69A1; height: 20px; line-height: 20px; border-radius: 10px; font-size: 11px; - padding: 0 8px; width: 100%; - text-align: center; overflow: hidden; text-overflow: ellipsis; - - &:hover { - background: #BFD4E5; + white-space: nowrap; + & a { + height: 20px; + width: 20px; + background: #d6e5f2; + border-radius: 50%; + margin-left: 5px; + & svg { + transform: rotate(316deg); + } } } + +.content { + width: 80%; + text-overflow: ellipsis; + overflow: hidden; + text-align: left; + height: 100%; + white-space: nowrap; +} + +.iconAction { + cursor: pointer; +} + +.disableIconAction { + cursor: initial; +} diff --git a/src/components/Popover/Popover.react.js b/src/components/Popover/Popover.react.js index 8ae550ed2e..6e22ffd979 100644 --- a/src/components/Popover/Popover.react.js +++ b/src/components/Popover/Popover.react.js @@ -64,6 +64,10 @@ export default class Popover extends React.Component { this._popoverLayer.dataset.parentContentId = this.props.parentContentId; } + if (this.props.parentContentId) { + this._popoverLayer.dataset.parentContentId = this.props.parentContentId; + } + document.body.addEventListener('click', this._checkExternalClick); } diff --git a/src/components/Popover/Popover.scss b/src/components/Popover/Popover.scss index e4971eff69..265b247faa 100644 --- a/src/components/Popover/Popover.scss +++ b/src/components/Popover/Popover.scss @@ -14,7 +14,7 @@ bottom: 0; right: 0; pointer-events: none; - z-index: 6; + z-index: 100; // This is just +1 z-index of Sidebar & > div { position: absolute; diff --git a/src/components/Sidebar/AppName.react.js b/src/components/Sidebar/AppName.react.js index 02b56e1c8c..e3a58ed6d0 100644 --- a/src/components/Sidebar/AppName.react.js +++ b/src/components/Sidebar/AppName.react.js @@ -1,14 +1,14 @@ -import React from 'react'; -import styles from 'components/Sidebar/Sidebar.scss'; +import React from "react"; +import styles from "components/Sidebar/Sidebar.scss"; -export default ({ name, pin, onClick }) => ( +const AppName = ({ name, pin, onClick }) => (
-
- {name} -
+
{name}
{pin}
); + +export default AppName; diff --git a/src/components/Sidebar/AppsMenu.react.js b/src/components/Sidebar/AppsMenu.react.js index c3f091aeac..e7f70abb01 100644 --- a/src/components/Sidebar/AppsMenu.react.js +++ b/src/components/Sidebar/AppsMenu.react.js @@ -14,9 +14,9 @@ import styles from 'components/Sidebar/Sidebar.scss'; import { unselectable } from 'stylesheets/base.scss'; import Icon from 'components/Icon/Icon.react'; -let AppsMenu = ({ apps, current, height, onSelect, pin }) => ( +const AppsMenu = ({ apps, current, height, onSelect, onPinClick }) => (
- +
All Apps
{apps.map((app) => { diff --git a/src/components/Sidebar/FooterMenu.react.js b/src/components/Sidebar/FooterMenu.react.js index 9db33f13f5..fb0033c683 100644 --- a/src/components/Sidebar/FooterMenu.react.js +++ b/src/components/Sidebar/FooterMenu.react.js @@ -32,6 +32,14 @@ export default class FooterMenu extends React.Component { } render() { + if (this.props.isCollapsed) { + return ( +
+ +
+ ); + } + let content = null; if (this.state.show) { content = ( diff --git a/src/components/Sidebar/Pin.react.js b/src/components/Sidebar/Pin.react.js new file mode 100644 index 0000000000..035610fd00 --- /dev/null +++ b/src/components/Sidebar/Pin.react.js @@ -0,0 +1,12 @@ +import React from "react"; + +import Icon from "components/Icon/Icon.react"; +import styles from "components/Sidebar/Sidebar.scss"; + +const Pin = ({ onClick }) => ( +
+ +
+); + +export default Pin; diff --git a/src/components/Sidebar/SidebarHeader.react.js b/src/components/Sidebar/SidebarHeader.react.js index 65b6a62550..f5dbf68350 100644 --- a/src/components/Sidebar/SidebarHeader.react.js +++ b/src/components/Sidebar/SidebarHeader.react.js @@ -34,7 +34,10 @@ export default class SidebarHeader extends React.Component {
Parse Dashboard {version}
- {this.state.dashboardUser} + Parse Dashboard {version} +
+ {this.state.dashboardUser} +
diff --git a/src/components/Sidebar/SidebarToggle.react.js b/src/components/Sidebar/SidebarToggle.react.js deleted file mode 100644 index d1fb02660b..0000000000 --- a/src/components/Sidebar/SidebarToggle.react.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2016-present, Parse, LLC - * All rights reserved. - * - * This source code is licensed under the license found in the LICENSE file in - * the root directory of this source tree. - */ -import React from 'react'; -import styles from 'components/Sidebar/Sidebar.scss'; -import Icon from 'components/Icon/Icon.react'; - -function toggleSidebarExpansion() { - if (document.body.className.indexOf(' expanded') > -1) { - document.body.className = document.body.className.replace(' expanded', ''); - } else { - document.body.className += ' expanded'; - } -} - -let SidebarToggle = () => ; - -export default SidebarToggle; 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/Dashboard.js b/src/dashboard/Dashboard.js index 72be4b0659..ecb7a5eb46 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -161,6 +161,8 @@ export default class Dashboard extends React.Component { }; setBasePath(props.path); this.updateApp = this.updateApp.bind(this); + sessionStorage.removeItem('username'); + sessionStorage.removeItem('password'); } componentDidMount() { diff --git a/src/dashboard/Dashboard.scss b/src/dashboard/Dashboard.scss index 5a031cec1c..92c08b348b 100644 --- a/src/dashboard/Dashboard.scss +++ b/src/dashboard/Dashboard.scss @@ -18,4 +18,4 @@ body:global(.expanded) { .content { margin-left: 54px; } -} +} \ No newline at end of file diff --git a/src/dashboard/Data/Browser/AddColumnDialog.react.js b/src/dashboard/Data/Browser/AddColumnDialog.react.js index 57763ae73f..1c64b77c44 100644 --- a/src/dashboard/Data/Browser/AddColumnDialog.react.js +++ b/src/dashboard/Data/Browser/AddColumnDialog.react.js @@ -84,8 +84,8 @@ export default class AddColumnDialog extends React.Component { try { await parseFile.save(); return parseFile; - } catch (error) { - this.props.showNote(error.message, true); + } catch (err) { + this.props.showNote(err.message, true); return parseFile; } finally { this.setState({ diff --git a/src/dashboard/Data/Browser/B4ABrowserToolbar.react.js b/src/dashboard/Data/Browser/B4ABrowserToolbar.react.js index 0651bd0629..eb30d91040 100644 --- a/src/dashboard/Data/Browser/B4ABrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/B4ABrowserToolbar.react.js @@ -1,17 +1,21 @@ -import BrowserFilter from 'components/BrowserFilter/BrowserFilter.react'; -import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; -import Icon from 'components/Icon/Icon.react'; -import MenuItem from 'components/BrowserMenu/MenuItem.react'; -import prettyNumber from 'lib/prettyNumber'; -import React from 'react'; -import SecurityDialog from 'dashboard/Data/Browser/SecurityDialog.react'; -import Separator from 'components/BrowserMenu/Separator.react'; -import styles from 'dashboard/Data/Browser/Browser.scss'; -import Toolbar from 'components/Toolbar/Toolbar.react'; -import Button from 'components/Button/Button.react' -import VideoTutorialButton from 'components/VideoTutorialButton/VideoTutorialButton.react'; +import BrowserFilter from 'components/BrowserFilter/BrowserFilter.react'; +import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; +import Icon from 'components/Icon/Icon.react'; +import LoginDialog from 'dashboard/Data/Browser/LoginDialog.react'; +import MenuItem from 'components/BrowserMenu/MenuItem.react'; +import prettyNumber from 'lib/prettyNumber'; +import React, { useRef } from 'react'; +import SecurityDialog from 'dashboard/Data/Browser/SecurityDialog.react'; +import SecureFieldsDialog from 'dashboard/Data/Browser/SecureFieldsDialog.react'; +import Separator from 'components/BrowserMenu/Separator.react'; +import styles from 'dashboard/Data/Browser/Browser.scss'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import Toggle from 'components/Toggle/Toggle.react'; +import Button from 'components/Button/Button.react' +import VideoTutorialButton from 'components/VideoTutorialButton/VideoTutorialButton.react'; import ColumnsConfiguration from 'components/ColumnsConfiguration/ColumnsConfiguration.react'; +import SubMenuItem from '../../../components/BrowserMenu/SubMenuItem.react'; const apiDocsButtonStyle = { display: 'inline-block', @@ -34,6 +38,7 @@ let B4ABrowserToolbar = ({ className, classNameForEditors, count, + editCloneRows, perms, schema, filters, @@ -47,6 +52,8 @@ let B4ABrowserToolbar = ({ onAddClass, onAttachRows, onAttachSelectedRows, + onCancelPendingEditRows, + onExportSelectedRows, onImport, onImportRelation, onCloneSelectedRows, @@ -74,9 +81,15 @@ let B4ABrowserToolbar = ({ onClickSecurity, columns, onShowPointerKey, - newObject + + currentUser, + useMasterKey, + login, + logout, + toggleMasterKeyUsage, }) => { let selectionLength = Object.keys(selection).length; + let isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; let details = [], lockIcon = false; if (count !== undefined) { if (count === 1) { @@ -106,21 +119,30 @@ let B4ABrowserToolbar = ({ } } } + + let protectedDialogRef = useRef(null); + let loginDialogRef = useRef(null); + + const showProtected = () => protectedDialogRef.current.handleOpen(); + const showLogin = () => loginDialogRef.current.handleOpen(); + let menu = null; if (relation) { menu = ( - + onDeleteRows(selection)} /> @@ -128,40 +150,69 @@ let B4ABrowserToolbar = ({ ); } else { menu = ( - - + + {isPendingEditCloneRows ? + <> + + + :