diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx
index 75b2a6f8dd..68fc112abd 100644
--- a/client/app/components/HelpTrigger.jsx
+++ b/client/app/components/HelpTrigger.jsx
@@ -83,6 +83,10 @@ export const TYPES = {
'/user-guide/querying/favorites-tagging/#Favorites',
'Guide: Favorites',
],
+ MANAGE_PERMISSIONS: [
+ '/user-guide/querying/writing-queries#Managing-Query-Permissions',
+ 'Guide: Managing Query Permissions',
+ ],
};
export default class HelpTrigger extends React.Component {
diff --git a/client/app/components/permissions-editor/PermissionsEditorDialog.jsx b/client/app/components/permissions-editor/PermissionsEditorDialog.jsx
new file mode 100644
index 0000000000..caa2112f86
--- /dev/null
+++ b/client/app/components/permissions-editor/PermissionsEditorDialog.jsx
@@ -0,0 +1,179 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { each, debounce, get, find } from 'lodash';
+import Button from 'antd/lib/button';
+import List from 'antd/lib/list';
+import Modal from 'antd/lib/modal';
+import Select from 'antd/lib/select';
+import Tag from 'antd/lib/tag';
+import Tooltip from 'antd/lib/tooltip';
+import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
+import { $http } from '@/services/ng';
+import { toHuman } from '@/filters';
+import HelpTrigger from '@/components/HelpTrigger';
+import { UserPreviewCard } from '@/components/PreviewCard';
+import notification from '@/services/notification';
+import { User } from '@/services/user';
+
+import './PermissionsEditorDialog.less';
+
+const { Option } = Select;
+const DEBOUNCE_SEARCH_DURATION = 200;
+
+function useGrantees(url) {
+ const loadGrantees = useCallback(() => $http.get(url).then(({ data }) => {
+ const resultGrantees = [];
+ each(data, (grantees, accessType) => {
+ grantees.forEach((grantee) => {
+ grantee.accessType = toHuman(accessType);
+ resultGrantees.push(grantee);
+ });
+ });
+ return resultGrantees;
+ }), [url]);
+
+ const addPermission = useCallback((userId, accessType = 'modify') => $http.post(
+ url, { access_type: accessType, user_id: userId },
+ ).catch(() => notification.error('Could not grant permission to the user'), [url]));
+
+ const removePermission = useCallback((userId, accessType = 'modify') => $http.delete(
+ url, { data: { access_type: accessType, user_id: userId } },
+ ).catch(() => notification.error('Could not remove permission from the user')), [url]);
+
+ return { loadGrantees, addPermission, removePermission };
+}
+
+const searchUsers = searchTerm => User.query({ q: searchTerm }).$promise
+ .then(({ results }) => results)
+ .catch(() => []);
+
+function PermissionsEditorDialogHeader({ context }) {
+ return (
+ <>
+ Manage Permissions
+
+ {`Editing this ${context} is enabled for the users in this list and for admins. `}
+
+
+ >
+ );
+}
+
+PermissionsEditorDialogHeader.propTypes = { context: PropTypes.oneOf(['query', 'dashboard']) };
+PermissionsEditorDialogHeader.defaultProps = { context: 'query' };
+
+function UserSelect({ onSelect, shouldShowUser }) {
+ const [loadingUsers, setLoadingUsers] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const debouncedSearchUsers = useCallback(debounce(
+ search => searchUsers(search)
+ .then(setUsers)
+ .finally(() => setLoadingUsers(false)),
+ DEBOUNCE_SEARCH_DURATION,
+ ), []);
+
+ useEffect(() => {
+ setLoadingUsers(true);
+ debouncedSearchUsers(searchTerm);
+ }, [searchTerm]);
+
+ return (
+ : }
+ filterOption={false}
+ notFoundContent={null}
+ value={undefined}
+ getPopupContainer={trigger => trigger.parentNode}
+ onSelect={onSelect}
+ >
+ {users.filter(shouldShowUser).map(user => (
+
+ ))}
+
+ );
+}
+
+UserSelect.propTypes = {
+ onSelect: PropTypes.func,
+ shouldShowUser: PropTypes.func,
+};
+UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true };
+
+function PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
+ const [loadingGrantees, setLoadingGrantees] = useState(true);
+ const [grantees, setGrantees] = useState([]);
+ const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
+ const loadUsersWithPermissions = useCallback(() => {
+ setLoadingGrantees(true);
+ loadGrantees()
+ .then(setGrantees)
+ .catch(() => notification.error('Failed to load grantees list'))
+ .finally(() => setLoadingGrantees(false));
+ }, []);
+
+ const userHasPermission = useCallback(
+ user => (user.id === author.id || !!get(find(grantees, { id: user.id }), 'accessType')),
+ [grantees],
+ );
+
+ useEffect(() => {
+ loadUsersWithPermissions();
+ }, [aclUrl]);
+
+ return (
+ }
+ footer={()}
+ >
+ addPermission(userId).then(loadUsersWithPermissions)}
+ shouldShowUser={user => !userHasPermission(user)}
+ />
+
+
Users with permissions
+ {loadingGrantees && }
+
+
+ (
+
+
+ {user.id === author.id ? (Author) : (
+
+ removePermission(user.id).then(loadUsersWithPermissions)}
+ />
+
+ )}
+
+
+ )}
+ />
+
+
+ );
+}
+
+PermissionsEditorDialog.propTypes = {
+ dialog: DialogPropType.isRequired,
+ author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+ context: PropTypes.oneOf(['query', 'dashboard']),
+ aclUrl: PropTypes.string.isRequired,
+};
+
+PermissionsEditorDialog.defaultProps = { context: 'query' };
+
+export default wrapDialog(PermissionsEditorDialog);
diff --git a/client/app/components/permissions-editor/PermissionsEditorDialog.less b/client/app/components/permissions-editor/PermissionsEditorDialog.less
new file mode 100644
index 0000000000..f89212de14
--- /dev/null
+++ b/client/app/components/permissions-editor/PermissionsEditorDialog.less
@@ -0,0 +1,8 @@
+.permissions-editor-dialog {
+ .ant-select-dropdown-menu-item-disabled {
+ // make sure .text-muted has the disabled color
+ &, .text-muted {
+ color: rgba(0, 0, 0, 0.25);
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/app/components/permissions-editor/index.js b/client/app/components/permissions-editor/index.js
deleted file mode 100644
index d0a8a7631c..0000000000
--- a/client/app/components/permissions-editor/index.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import { includes, each, filter } from 'lodash';
-import notification from '@/services/notification';
-import template from './permissions-editor.html';
-
-const PermissionsEditorComponent = {
- template,
- bindings: {
- resolve: '<',
- close: '&',
- dismiss: '&',
- },
- controller($http, User) {
- 'ngInject';
-
- this.grantees = [];
- this.newGrantees = {};
- this.aclUrl = this.resolve.aclUrl.url;
- this.owner = this.resolve.owner;
-
- // List users that are granted permissions
- const loadGrantees = () => {
- $http.get(this.aclUrl).success((result) => {
- this.grantees = [];
-
- each(result, (grantees, accessType) => {
- grantees.forEach((grantee) => {
- grantee.access_type = accessType;
- this.grantees.push(grantee);
- });
- });
- });
- };
-
- loadGrantees();
-
- // Search for user
- this.findUser = (search) => {
- if (search === '') {
- this.foundUsers = [];
- return;
- }
-
- User.query({ q: search }, (response) => {
- const users = filter(response.results, u => u.id !== this.owner.id);
- const existingIds = this.grantees.map(m => m.id);
- users.forEach((user) => {
- user.alreadyGrantee = includes(existingIds, user.id);
- });
- this.foundUsers = users;
- });
- };
-
- // Add new user to grantees list
- this.addGrantee = (user) => {
- this.newGrantees = {};
- const body = { access_type: 'modify', user_id: user.id };
- $http.post(this.aclUrl, body).success(() => {
- user.alreadyGrantee = true;
- loadGrantees();
- }).catch((error) => {
- if (error.status === 403) {
- notification.error('You cannot add a user to this dashboard.', 'Ask the dashboard owner to grant them permissions.');
- } else {
- notification.error('Something went wrong.');
- }
- });
- };
-
- // Remove user from grantees list
- this.removeGrantee = (user) => {
- const body = { access_type: 'modify', user_id: user.id };
- $http({
- url: this.aclUrl,
- method: 'DELETE',
- data: body,
- headers: { 'Content-Type': 'application/json' },
- }).success(() => {
- this.grantees = this.grantees.filter(m => m !== user);
-
- if (this.foundUsers) {
- this.foundUsers.forEach((u) => {
- if (u.id === user.id) {
- u.alreadyGrantee = false;
- }
- });
- }
- });
- };
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('permissionsEditor', PermissionsEditorComponent);
-}
-
-init.init = true;
diff --git a/client/app/components/permissions-editor/permissions-editor.html b/client/app/components/permissions-editor/permissions-editor.html
deleted file mode 100644
index 8470e1c8d4..0000000000
--- a/client/app/components/permissions-editor/permissions-editor.html
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
-
-
-
-
-
{{user.name}}
-
(already has permission)
-
-
-
-
-
Who has access
-
-
-
- |
- User |
- Permission |
- |
-
-
-
-
- |
- {{ $ctrl.owner.name}} |
- Owner |
- |
-
-
- |
- {{grantee.name}} |
- {{grantee.access_type}} |
- |
-
-
-
-
-
diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js
index 8346d929de..6a5ca7e66c 100644
--- a/client/app/pages/dashboards/dashboard.js
+++ b/client/app/pages/dashboards/dashboard.js
@@ -12,6 +12,7 @@ import template from './dashboard.html';
import ShareDashboardDialog from './ShareDashboardDialog';
import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog';
import TextboxDialog from '@/components/dashboards/TextboxDialog';
+import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog';
import notification from '@/services/notification';
import './dashboard.less';
@@ -250,12 +251,11 @@ function DashboardCtrl(
};
this.showManagePermissionsModal = () => {
- $uibModal.open({
- component: 'permissionsEditor',
- resolve: {
- aclUrl: { url: `api/dashboards/${this.dashboard.id}/acl` },
- owner: this.dashboard.user,
- },
+ const aclUrl = `api/dashboards/${this.dashboard.id}/acl`;
+ PermissionsEditorDialog.showModal({
+ aclUrl,
+ context: 'dashboard',
+ author: this.dashboard.user,
});
};
diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js
index aedc2c761a..304e32e752 100644
--- a/client/app/pages/queries/view.js
+++ b/client/app/pages/queries/view.js
@@ -8,6 +8,7 @@ import ScheduleDialog from '@/components/queries/ScheduleDialog';
import { newVisualization } from '@/visualizations';
import EditVisualizationDialog from '@/visualizations/EditVisualizationDialog';
import EmbedQueryDialog from '@/components/queries/EmbedQueryDialog';
+import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog';
import notification from '@/services/notification';
import template from './query.html';
@@ -528,12 +529,11 @@ function QueryViewCtrl(
);
$scope.showManagePermissionsModal = () => {
- $uibModal.open({
- component: 'permissionsEditor',
- resolve: {
- aclUrl: { url: `api/queries/${$routeParams.queryId}/acl` },
- owner: $scope.query.user,
- },
+ const aclUrl = `api/queries/${$routeParams.queryId}/acl`;
+ PermissionsEditorDialog.showModal({
+ aclUrl,
+ context: 'query',
+ author: $scope.query.user,
});
};
}