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 ( + + ); +} + +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 @@ - - 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, }); }; }