From 3ce3c35bf5e9d6d0c22d22dac6717c4d5fea0a84 Mon Sep 17 00:00:00 2001 From: alxnddr Date: Fri, 6 Aug 2021 04:14:24 +0300 Subject: [PATCH] New permissions pages --- .../sandboxes/components/GTAPModal.jsx | 12 +- .../metabase-enterprise/sandboxes/index.js | 67 +- .../src/metabase-enterprise/snippets/index.js | 2 +- .../CollectionPermissionsModal.jsx | 170 ++++ .../components/FixedHeaderGrid.css | 4 - .../components/FixedHeaderGrid.jsx | 157 --- .../components/PermissionsEditor.jsx | 109 -- .../PermissionsEditor/PermissionsEditor.jsx | 103 ++ .../PermissionsEditor.styled.js | 5 + .../PermissionsEditorBreadcrumbs.jsx | 45 + .../PermissionsEditorBreadcrumbs.styled.js | 20 + .../PermissionsEditorEmptyState.jsx | 18 + .../components/PermissionsEditor/index.js | 2 + .../components/PermissionsGrid.jsx | 509 ---------- .../PermissionsEditBar.jsx | 61 ++ .../PermissionsPageLayout.jsx | 32 + .../PermissionsPageLayout.styled.jsx | 6 + .../PermissionsPageLayout/PermissionsTabs.jsx | 28 + .../components/PermissionsPageLayout/index.js | 1 + .../PermissionsSelect/PermissionsSelect.jsx | 130 +++ .../PermissionsSelect.styled.jsx | 56 ++ .../PermissionsSelectOption.jsx | 34 + .../PermissionsSelectOption.styled.jsx | 26 + .../components/PermissionsSelect/index.js | 1 + .../PermissionsSidebar/PermissionsSidebar.jsx | 156 +++ .../PermissionsSidebar.styled.jsx | 35 + .../components/PermissionsSidebar/index.js | 1 + .../PermissionsTable/PermissionsTable.jsx | 161 +++ .../PermissionsTable.styled.jsx | 44 + .../components/PermissionsTable/index.js | 1 + .../components/PermissionsTabs.jsx | 22 - .../constants/collections-permissions.js | 23 + .../permissions/constants/data-permissions.js | 29 + .../admin/permissions/constants/messages.js | 4 + .../containers/CollectionPermissionsModal.jsx | 152 --- .../containers/CollectionsPermissionsApp.jsx | 73 -- .../containers/DataPermissionsApp.jsx | 31 - .../containers/DatabasesPermissionsApp.jsx | 32 - .../permissions/containers/PermissionsApp.jsx | 97 -- .../containers/SchemasPermissionsApp.jsx | 32 - .../containers/TablesPermissionsApp.jsx | 32 - .../containers/TogglePropagateAction.jsx | 31 - .../hooks/use-leave-confirmation.jsx | 53 + .../CollectionPermissionsPage.jsx | 140 +++ .../DataPermissionsPage.jsx | 101 ++ .../DatabasesPermissionsPage.jsx | 120 +++ .../GroupsPermissionsPage.jsx | 169 ++++ .../metabase/admin/permissions/permissions.js | 299 ++++-- .../src/metabase/admin/permissions/routes.jsx | 58 +- .../metabase/admin/permissions/selectors.js | 936 ------------------ .../selectors/collection-permissions.js | 261 +++++ .../permissions/selectors/confirmations.js | 131 +++ .../permissions/selectors/data-permissions.js | 669 +++++++++++++ frontend/src/metabase/components/Radio.jsx | 3 + .../src/metabase/components/Radio.styled.js | 29 +- .../src/metabase/components/TextInput.jsx | 131 +-- .../metabase/components/TextInput.styled.jsx | 74 ++ .../src/metabase/components/tree/Tree.jsx | 12 +- .../src/metabase/components/tree/TreeNode.jsx | 8 +- .../components/tree/TreeNode.styled.jsx | 32 +- .../metabase/components/tree/TreeNodeList.jsx | 8 +- frontend/src/metabase/hoc/ModalRoute.jsx | 21 +- frontend/src/metabase/plugins/index.js | 1 + frontend/src/metabase/routes.jsx | 2 +- .../selectors.unit.spec.fixtures.js | 127 --- .../admin/permissions/selectors.unit.spec.js | 632 ------------ .../admin/permissions/sandboxes.cy.spec.js | 9 +- .../snippets/snippet-permissions.cy.spec.js | 7 +- .../scenarios/question/view.cy.spec.js | 6 +- 69 files changed, 3299 insertions(+), 3294 deletions(-) create mode 100644 frontend/src/metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal.jsx delete mode 100644 frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.css delete mode 100644 frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.jsx delete mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.styled.js create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.styled.js create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorEmptyState.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsEditor/index.js delete mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsEditBar.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.styled.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsTabs.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/index.js create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.styled.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSelect/index.js create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.styled.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsSidebar/index.js create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx create mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsTable/index.js delete mode 100644 frontend/src/metabase/admin/permissions/components/PermissionsTabs.jsx create mode 100644 frontend/src/metabase/admin/permissions/constants/collections-permissions.js create mode 100644 frontend/src/metabase/admin/permissions/constants/data-permissions.js create mode 100644 frontend/src/metabase/admin/permissions/constants/messages.js delete mode 100644 frontend/src/metabase/admin/permissions/containers/CollectionPermissionsModal.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/DatabasesPermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/SchemasPermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/TablesPermissionsApp.jsx delete mode 100644 frontend/src/metabase/admin/permissions/containers/TogglePropagateAction.jsx create mode 100644 frontend/src/metabase/admin/permissions/hooks/use-leave-confirmation.jsx create mode 100644 frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx create mode 100644 frontend/src/metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage.jsx create mode 100644 frontend/src/metabase/admin/permissions/pages/DatabasePermissionsPage/DatabasesPermissionsPage.jsx create mode 100644 frontend/src/metabase/admin/permissions/pages/GroupDataPermissionsPage/GroupsPermissionsPage.jsx delete mode 100644 frontend/src/metabase/admin/permissions/selectors.js create mode 100644 frontend/src/metabase/admin/permissions/selectors/collection-permissions.js create mode 100644 frontend/src/metabase/admin/permissions/selectors/confirmations.js create mode 100644 frontend/src/metabase/admin/permissions/selectors/data-permissions.js create mode 100644 frontend/src/metabase/components/TextInput.styled.jsx delete mode 100644 frontend/test/metabase/admin/permissions/selectors.unit.spec.fixtures.js delete mode 100644 frontend/test/metabase/admin/permissions/selectors.unit.spec.js diff --git a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/GTAPModal.jsx b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/GTAPModal.jsx index c7588543fc855..361b572b991dd 100644 --- a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/GTAPModal.jsx +++ b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/GTAPModal.jsx @@ -27,6 +27,7 @@ import Dimension from "metabase-lib/lib/Dimension"; import _ from "underscore"; import { jt, t } from "ttag"; +import { getParentPath } from "metabase/hoc/ModalRoute"; const mapStateToProps = () => ({}); const mapDispatchToProps = { @@ -91,15 +92,8 @@ export default class GTAPModal extends React.Component { } close = () => { - const { - push, - params: { databaseId, schemaName }, - } = this.props; - push( - `/admin/permissions/databases/${databaseId}` + - (schemaName ? `/schemas/${encodeURIComponent(schemaName)}` : ``) + - `/tables`, - ); + const { push, route, location } = this.props; + return push(getParentPath(route, location)); }; _getCanonicalGTAP() { diff --git a/enterprise/frontend/src/metabase-enterprise/sandboxes/index.js b/enterprise/frontend/src/metabase-enterprise/sandboxes/index.js index cd73c894d2e2f..eb2420b3c724d 100644 --- a/enterprise/frontend/src/metabase-enterprise/sandboxes/index.js +++ b/enterprise/frontend/src/metabase-enterprise/sandboxes/index.js @@ -1,6 +1,7 @@ import { PLUGIN_ADMIN_USER_FORM_FIELDS, PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES, + PLUGIN_ADMIN_PERMISSIONS_TABLE_GROUP_ROUTES, PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS, PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS, PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION, @@ -12,43 +13,46 @@ import { push } from "react-router-redux"; import { t } from "ttag"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import { color, alpha } from "metabase/lib/colors"; +import { color } from "metabase/lib/colors"; import { ModalRoute } from "metabase/hoc/ModalRoute"; import LoginAttributesWidget from "./components/LoginAttributesWidget"; import GTAPModal from "./components/GTAPModal"; -const OPTION_BLUE = { - iconColor: color("brand"), - bgColor: alpha(color("brand"), 0.15), -}; - const OPTION_SEGMENTED = { - ...OPTION_BLUE, + label: t`Sandboxed`, value: "controlled", - title: t`Grant sandboxed access`, - tooltip: t`Sandboxed access`, icon: "permissions_limited", + iconColor: color("brand"), }; -const getEditSegementedAccessUrl = ( +const getDatabaseViewSandboxModalUrl = ({ groupId, - { databaseId, schemaName, tableId }, -) => - `/admin/permissions` + - `/databases/${databaseId}` + - (schemaName ? `/schemas/${encodeURIComponent(schemaName)}` : "") + - `/tables/${tableId}/segmented/group/${groupId}`; + databaseId, + schemaName, + tableId, +}) => + `/admin/permissions/data/database/${databaseId}/schema/${encodeURIComponent( + schemaName, + )}/table/${tableId}/segmented/group/${groupId}`; -const getEditSegementedAccessAction = (groupId, entityId) => ({ - ...OPTION_BLUE, - title: t`Edit sandboxed access`, - icon: "pencil", - value: push(getEditSegementedAccessUrl(groupId, entityId)), -}); +const getGroupViewSandboxModalUrl = ({ + groupId, + databaseId, + schemaName, + tableId, +}) => + `/admin/permissions/data/group/${groupId}/database/${databaseId}/schema/${encodeURIComponent( + schemaName, + )}/${tableId}/segmented`; -const getEditSegmentedAcessPostAction = (groupId, entityId) => - push(getEditSegementedAccessUrl(groupId, entityId)); +const getEditSegementedAccessUrl = (params, view) => + view === "database" + ? getDatabaseViewSandboxModalUrl(params) + : getGroupViewSandboxModalUrl(params); + +const getEditSegmentedAcessPostAction = (params, view) => + push(getEditSegementedAccessUrl(params, view)); if (hasPremiumFeature("sandboxes")) { PLUGIN_ADMIN_USER_FORM_FIELDS.push({ @@ -57,12 +61,19 @@ if (hasPremiumFeature("sandboxes")) { type: LoginAttributesWidget, }); PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES.push( - , + , ); - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS.push(OPTION_SEGMENTED); - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS["controlled"].push( - getEditSegementedAccessAction, + PLUGIN_ADMIN_PERMISSIONS_TABLE_GROUP_ROUTES.push( + , ); + PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS.push(OPTION_SEGMENTED); + PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS["controlled"].push({ + label: t`Edit sandboxed access`, + iconColor: color("brand"), + icon: "pencil", + actionCreator: (groupId, entityId, view) => + push(getEditSegementedAccessUrl({ ...entityId, groupId }, view)), + }); PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION[ "controlled" ] = getEditSegmentedAcessPostAction; diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/index.js b/enterprise/frontend/src/metabase-enterprise/snippets/index.js index 011b246ec2119..ec09fca5febf5 100644 --- a/enterprise/frontend/src/metabase-enterprise/snippets/index.js +++ b/enterprise/frontend/src/metabase-enterprise/snippets/index.js @@ -9,7 +9,7 @@ import { } from "metabase/plugins"; import MetabaseSettings from "metabase/lib/settings"; -import CollectionPermissionsModal from "metabase/admin/permissions/containers/CollectionPermissionsModal"; +import CollectionPermissionsModal from "metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal"; import Modal from "metabase/components/Modal"; import CollectionRow from "./components/CollectionRow"; diff --git a/frontend/src/metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal.jsx b/frontend/src/metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal.jsx new file mode 100644 index 0000000000000..24c811bb61846 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal.jsx @@ -0,0 +1,170 @@ +import React, { useEffect, useCallback } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import _ from "underscore"; + +import * as Urls from "metabase/lib/urls"; + +import Collections from "metabase/entities/collections"; +import SnippetCollections from "metabase/entities/snippet-collections"; + +import { isPersonalCollectionChild } from "metabase/collections/utils"; + +import ModalContent from "metabase/components/ModalContent"; +import Button from "metabase/components/Button"; +import Link from "metabase/components/Link"; + +import { PermissionsTable } from "../PermissionsTable"; +import Groups from "metabase/entities/groups"; +import { + getDiff, + getIsDirty, + getCollectionsPermissionEditor, +} from "../../selectors/collection-permissions"; +import { + initializeCollectionPermissions, + updateCollectionPermission, + saveCollectionPermissions, +} from "../../permissions"; +import { permissionEditorPropTypes } from "../PermissionsEditor/PermissionsEditor"; + +const getDefaultTitle = namespace => + namespace === "snippets" + ? t`Permissions for this folder` + : t`Permissions for this collection`; + +const propTypes = { + permissionEditor: PropTypes.shape(permissionEditorPropTypes), + namespace: PropTypes.string, + isDirty: PropTypes.bool, + onClose: PropTypes.func.isRequired, + collection: PropTypes.object, + collectionsList: PropTypes.arrayOf(PropTypes.object), + initialize: PropTypes.func.isRequired, + updateCollectionPermission: PropTypes.func.isRequired, + saveCollectionPermissions: PropTypes.func.isRequired, +}; + +const CollectionPermissionsModal = ({ + permissionEditor, + isDirty, + onClose, + namespace, + collection, + collectionsList, + + initialize, + updateCollectionPermission, + saveCollectionPermissions, +}) => { + useEffect(() => { + initialize(namespace); + }, [initialize, namespace]); + + useEffect(() => { + const isPersonalCollectionLoaded = + collection && + Array.isArray(collectionsList) && + (collection.personal_owner_id || + isPersonalCollectionChild(collection, collectionsList)); + + if (isPersonalCollectionLoaded || collection.archived) { + onClose(); + } + }, [collectionsList, collection, onClose]); + + const handleSave = async () => { + await saveCollectionPermissions(namespace); + onClose(); + }; + + const modalTitle = collection?.name + ? t`Permissions for ${collection.name}` + : getDefaultTitle(namespace); + + const handlePermissionChange = useCallback( + (item, _permission, value, toggleState) => { + updateCollectionPermission({ + groupId: item.id, + collection, + value, + shouldPropagate: toggleState, + }); + }, + [collection, updateCollectionPermission], + ); + + return ( + + {t`See all collection permissions`} + , + ]), + , + , + ]} + > +
+ {permissionEditor && ( + + )} +
+
+ ); +}; + +CollectionPermissionsModal.propTypes = propTypes; + +const getCollectionEntity = props => + props.namespace === "snippets" ? SnippetCollections : Collections; + +const mapStateToProps = (state, props) => { + const collectionId = Urls.extractCollectionId(props.params.slug); + return { + permissionEditor: getCollectionsPermissionEditor(state, { + namespace: props.namespace, + params: { collectionId }, + }), + collection: getCollectionEntity(props).selectors.getObject(state, { + entityId: collectionId, + }), + collectionsList: Collections.selectors.getList(state, { + entityQuery: { tree: true }, + }), + diff: getDiff(state, props), + isDirty: getIsDirty(state, props), + }; +}; + +const mapDispatchToProps = { + initialize: initializeCollectionPermissions, + updateCollectionPermission, + saveCollectionPermissions, +}; + +export default _.compose( + Collections.loadList({ + query: () => ({ tree: true }), + }), + Groups.loadList(), + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(CollectionPermissionsModal); diff --git a/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.css b/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.css deleted file mode 100644 index 01c5667a0b84a..0000000000000 --- a/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.css +++ /dev/null @@ -1,4 +0,0 @@ -/* disable focus rings on react-virtualized's Grids */ -:local(.fixedHeaderGrid) [tabindex] { - outline: none !important; -} diff --git a/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.jsx b/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.jsx deleted file mode 100644 index 8148cb1cfbd62..0000000000000 --- a/frontend/src/metabase/admin/permissions/components/FixedHeaderGrid.jsx +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable react/display-name */ -/* eslint-disable react/prop-types */ -import React from "react"; - -import { Grid, ScrollSync } from "react-virtualized"; -import "react-virtualized/styles.css"; -import S from "./FixedHeaderGrid.css"; - -import cx from "classnames"; - -const FixedHeaderGrid = ({ - className, - rowCount, - columnCount, - renderCell, - columnWidth, - rowHeight, - columnHeaderHeight, - rowHeaderWidth, - renderColumnHeader = () => null, - renderRowHeader = () => null, - renderCorner = () => null, - width, - height, - paddingBottom = 0, - paddingRight = 0, -}) => ( -
- - {({ - clientHeight, - clientWidth, - onScroll, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }) => ( -
- {/* CORNER */} -
- {renderCorner()} -
- {/* COLUMN HEADERS */} -
- ( -
- {/* HACK: pad the right with a phantom cell */} - {columnIndex >= columnCount - ? null - : renderColumnHeader({ columnIndex })} -
- )} - columnCount={columnCount + 1} - columnWidth={({ index }) => - index >= columnCount ? paddingRight : columnWidth - } - rowCount={1} - rowHeight={columnHeaderHeight} - onScroll={({ scrollLeft }) => onScroll({ scrollLeft })} - scrollLeft={scrollLeft} - /> -
- {/* ROW HEADERS */} -
- ( -
- {/* HACK: pad the bottom with a phantom cell */} - {rowIndex >= rowCount ? null : renderRowHeader({ rowIndex })} -
- )} - columnCount={1} - columnWidth={rowHeaderWidth} - rowCount={rowCount + 1} - rowHeight={({ index }) => - index >= rowCount ? paddingBottom : rowHeight - } - onScroll={({ scrollTop }) => onScroll({ scrollTop })} - scrollTop={scrollTop} - /> -
- {/* CELLS */} -
- ( -
- {/* HACK: pad the bottom/right with a phantom cell */} - {rowIndex >= rowCount || columnIndex >= columnCount - ? null - : renderCell({ columnIndex, rowIndex })} -
- )} - columnCount={columnCount + 1} - columnWidth={({ index }) => - index >= columnCount ? paddingRight : columnWidth - } - rowCount={rowCount + 1} - rowHeight={({ index }) => - index >= rowCount ? paddingBottom : rowHeight - } - onScroll={({ scrollTop, scrollLeft }) => - onScroll({ scrollTop, scrollLeft }) - } - scrollTop={scrollTop} - scrollLeft={scrollLeft} - /> -
-
- )} -
-
-); - -export default FixedHeaderGrid; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx deleted file mode 100644 index 6c972fe42958f..0000000000000 --- a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; - -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; -import Confirm from "metabase/components/Confirm"; -import PermissionsGrid from "../components/PermissionsGrid"; -import PermissionsConfirm from "../components/PermissionsConfirm"; -import PermissionsTabs from "../components/PermissionsTabs"; -import EditBar from "metabase/components/EditBar"; -import Breadcrumbs from "metabase/components/Breadcrumbs"; -import Button from "metabase/components/Button"; -import { t } from "ttag"; -import cx from "classnames"; - -import _ from "underscore"; - -const PermissionsEditor = ({ - tab, - admin, - grid, - onUpdatePermission, - onSave, - onCancel, - onChangeTab, - confirmCancel, - isDirty, - diff, - location, - children, -}) => { - const saveButton = ( - } - triggerClasses={cx({ disabled: !isDirty })} - key="save" - > - - - ); - - const cancelButton = confirmCancel ? ( - - - - ) : ( - - ); - - return ( - - {() => ( -
- {isDirty && ( - - )} - {tab && ( -
- -
- )} - {grid && grid.crumbs && grid.crumbs.length > 0 ? ( -
- -
- ) : null} - {children} - -
- )} -
- ); -}; - -PermissionsEditor.defaultProps = { - admin: true, -}; - -function getEntityAndGroupIdFromLocation({ query = {} } = {}) { - query = _.mapObject(query, value => - isNaN(value) ? value : parseFloat(value), - ); - const entityId = _.omit(query, "groupId"); - const groupId = query.groupId; - return { - groupId: groupId || null, - entityId: Object.keys(entityId).length > 0 ? entityId : null, - }; -} - -export default PermissionsEditor; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.jsx new file mode 100644 index 0000000000000..1b216248f4629 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.jsx @@ -0,0 +1,103 @@ +import React, { useState, useMemo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import { Box } from "grid-styled"; + +import { PermissionsTable } from "../PermissionsTable"; +import Subhead from "metabase/components/type/Subhead"; +import Text from "metabase/components/type/Text"; +import TextInput from "metabase/components/TextInput"; +import Icon from "metabase/components/Icon"; +import EmptyState from "metabase/components/EmptyState"; + +import { PermissionsEditorRoot } from "./PermissionsEditor.styled"; +import { PermissionsEditorBreadcrumbs } from "./PermissionsEditorBreadcrumbs"; + +export const permissionEditorPropTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string, + columns: PropTypes.array, + entities: PropTypes.array, + filterPlaceholder: PropTypes.string.isRequired, + onChange: PropTypes.func, + onSelect: PropTypes.func, + onAction: PropTypes.func, + onBreadcrumbsItemSelect: PropTypes.func, + breadcrumbs: PropTypes.array, +}; + +export function PermissionsEditor({ + title, + description, + entities, + columns, + filterPlaceholder, + breadcrumbs, + onBreadcrumbsItemSelect, + onChange, + onSelect, + onAction, +}) { + const [filter, setFilter] = useState(""); + + const handleFilterChange = text => setFilter(text); + + const filteredEntities = useMemo(() => { + const trimmedFilter = filter.trim().toLowerCase(); + + if (trimmedFilter.length === 0) { + return null; + } + + return entities.filter(entity => + entity.name.toLowerCase().includes(trimmedFilter), + ); + }, [entities, filter]); + + return ( + + + + {title}{" "} + {breadcrumbs && ( + + )} + + + {description && {description}} + + + } + /> + + + + + + + } + /> + + ); +} + +PermissionsEditor.propTypes = permissionEditorPropTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.styled.js b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.styled.js new file mode 100644 index 0000000000000..fe575cea12f8e --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditor.styled.js @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +export const PermissionsEditorRoot = styled.div` + flex-grow: 1; +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.jsx new file mode 100644 index 0000000000000..066fcf1a3f392 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import Icon from "metabase/components/Icon"; + +import { + BreadcrumbsLink, + BreadcrumbsSeparator, +} from "./PermissionsEditorBreadcrumbs.styled"; + +const propTypes = { + items: PropTypes.array, + onBreadcrumbsItemSelect: PropTypes.func, +}; + +export const PermissionsEditorBreadcrumbs = ({ + items, + onBreadcrumbsItemSelect, +}) => { + return ( + + {items.map((item, index) => { + const isLast = index === items.length - 1; + return ( + + {isLast ? ( + item.text + ) : ( + + onBreadcrumbsItemSelect(item)}> + {item.text} + + + + + + )} + + ); + })} + + ); +}; + +PermissionsEditorBreadcrumbs.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.styled.js b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.styled.js new file mode 100644 index 0000000000000..7893d8c67ecf4 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorBreadcrumbs.styled.js @@ -0,0 +1,20 @@ +import styled from "styled-components"; +import { color, lighten } from "metabase/lib/colors"; + +export const BreadcrumbsSeparator = styled.div` + display: inline-block; + color: ${color("bg-dark")}; + position: relative; + margin: 0 6px; + top: 2px; +`; + +export const BreadcrumbsLink = styled.a` + cursor: pointer; + color: ${color("accent7")}; + transition: color 200ms; + + &:hover { + color: ${lighten("accent7", 0.2)}; + } +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorEmptyState.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorEmptyState.jsx new file mode 100644 index 0000000000000..f1ca52d4f2e51 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/PermissionsEditorEmptyState.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Flex } from "grid-styled"; + +import EmptyState from "metabase/components/EmptyState"; + +const propTypes = { + icon: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, +}; + +export const PermissionsEditorEmptyState = props => ( + + + +); + +PermissionsEditorEmptyState.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor/index.js b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/index.js new file mode 100644 index 0000000000000..a725ca6142b74 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor/index.js @@ -0,0 +1,2 @@ +export * from "./PermissionsEditor"; +export * from "./PermissionsEditorEmptyState"; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx deleted file mode 100644 index 7135dedf9c168..0000000000000 --- a/frontend/src/metabase/admin/permissions/components/PermissionsGrid.jsx +++ /dev/null @@ -1,509 +0,0 @@ -/* eslint-disable react/display-name */ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { connect } from "react-redux"; - -import { Link } from "react-router"; - -import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; -import Tooltip from "metabase/components/Tooltip"; -import Icon from "metabase/components/Icon"; -import ConfirmContent from "metabase/components/ConfirmContent"; -import Modal from "metabase/components/Modal"; - -import FixedHeaderGrid from "./FixedHeaderGrid"; -import { AutoSizer } from "react-virtualized"; - -import { isAdminGroup, getGroupNameLocalized } from "metabase/lib/groups"; -import cx from "classnames"; -import _ from "underscore"; - -import { color } from "metabase/lib/colors"; - -const LIGHT_BORDER = color("text-light"); -const DARK_BORDER = color("text-medium"); -const BORDER_RADIUS = 4; - -const getBorderStyles = ({ - isFirstColumn, - isLastColumn, - isFirstRow, - isLastRow, -}) => ({ - overflow: "hidden", - border: "1px solid " + LIGHT_BORDER, - borderTopWidth: isFirstRow ? 1 : 0, - borderRightWidth: isLastColumn ? 1 : 0, - borderLeftColor: isFirstColumn ? LIGHT_BORDER : DARK_BORDER, - borderTopRightRadius: isLastColumn && isFirstRow ? BORDER_RADIUS : 0, - borderTopLeftRadius: isFirstColumn && isFirstRow ? BORDER_RADIUS : 0, - borderBottomRightRadius: isLastColumn && isLastRow ? BORDER_RADIUS : 0, - borderBottomLeftRadius: isFirstColumn && isLastRow ? BORDER_RADIUS : 0, -}); - -const DEFAULT_CELL_HEIGHT = 100; -const CELL_WIDTH = 246; -const HEADER_HEIGHT = 65; -const HEADER_WIDTH = 240; - -const DEFAULT_OPTION = { - icon: "unknown", - iconColor: color("text-medium"), - bgColor: color("bg-medium"), -}; - -const PermissionsHeader = ({ permissions, isFirst, isLast }) => ( -
- {permissions.map((permission, index) => ( -
- {permission.header && ( -
- {permission.header} -
- )} -
- ))} -
-); - -const GroupHeader = ({ - group, - permissions, - isColumn, - isRow, - isFirst, - isLast, -}) => ( -
-

- {getGroupNameLocalized(group)} - {group.tooltip && ( - - - - )} -

- {permissions && ( - - )} -
-); - -const EntityHeader = ({ - entity, - icon, - permissions, - isRow, - isColumn, - isFirst, - isLast, -}) => ( -
-
- -
-

{entity.name}

- {entity.subtitle && ( -
- {entity.subtitle} -
- )} - {entity.link && ( -
- - {entity.link.name} - -
- )} -
-
- - {permissions && ( - - )} -
-); - -const PermissionsCell = ({ - group, - permissions, - entity, - onUpdatePermission, - cellHeight, - isFirstRow, - isLastRow, - isFirstColumn, - isLastColumn, - isFaded, -}) => ( -
- {permissions.map(permission => ( - - ))} -
-); - -const ActionsList = connect()(({ actions, dispatch }) => ( -
    - {actions.map((action, index) => ( -
  • - {typeof action === "function" ? ( - action() - ) : ( - - )} -
  • - ))} -
-)); - -class GroupPermissionCell extends Component { - constructor(props, context) { - super(props, context); - this.state = { - confirmations: null, - confirmAction: null, - hovered: false, - }; - this.popover = React.createRef(); - } - hoverEnter() { - // only change the hover state if the group is not the admin - // this helps indicate to users that the admin group is different - if (this.props.isEditable) { - return this.setState({ hovered: true }); - } - return false; - } - hoverExit() { - if (this.props.isEditable) { - return this.setState({ hovered: false }); - } - return false; - } - render() { - const { - permission, - group, - entity, - onUpdatePermission, - isFaded, - cellHeight, - } = this.props; - const { confirmations } = this.state; - - const value = permission.getter(group.id, entity.id); - const actions = - permission.actions && permission.actions(group.id, entity.id); - const options = permission.options(group.id, entity.id); - const warning = - permission.warning && permission.warning(group.id, entity.id); - - const isEditable = - this.props.isEditable && - options.filter(option => option.value !== value).length > 0; - const option = _.findWhere(options, { value }) || DEFAULT_OPTION; - - return ( - -
this.hoverEnter()} - onMouseLeave={() => this.hoverExit()} - > - - {confirmations && confirmations.length > 0 && ( - - - // if it's the last one call confirmAction, otherwise remove the confirmation that was just confirmed - confirmations.length === 1 - ? this.setState( - { confirmations: null, confirmAction: null }, - this.state.confirmAction, - ) - : this.setState({ - confirmations: confirmations.slice(1), - }) - } - onCancel={() => - this.setState({ - confirmations: null, - confirmAction: null, - }) - } - /> - - )} - {warning && ( -
- - - -
- )} -
- - } - > - { - const confirmAction = () => { - onUpdatePermission({ - groupId: group.id, - entityId: entity.id, - value: value, - updater: permission.updater, - postAction: permission.postAction, - }); - }; - const confirmations = ( - (permission.confirm && - permission.confirm(group.id, entity.id, value)) || - [] - ).filter(c => c); - if (confirmations.length > 0) { - this.setState({ confirmations, confirmAction }); - } else { - confirmAction(); - } - this.popover.current.close(); - }} - /> - {actions && actions.length > 0 ? ( - - ) : null} -
- ); - } -} - -const AccessOption = ({ value, option, onChange }) => ( -
onChange(option.value)} - > - - {option.title} -
-); - -const AccessOptionList = ({ value, options, onChange }) => ( -
    - {options.map(option => { - if (value !== option.value) { - return ( -
  • - -
  • - ); - } - })} -
-); - -const PermissionsGrid = ({ - className, - grid, - onUpdatePermission, - entityId, - groupId, - isPivoted = false, - showHeader = true, - cellHeight = DEFAULT_CELL_HEIGHT, -}) => { - const permissions = Object.entries(grid.permissions).map( - ([id, permission]) => ({ id: id, ...permission }), - ); - - let rowCount, columnCount, headerHeight; - if (isPivoted) { - rowCount = grid.groups.length; - columnCount = grid.entities.length; - headerHeight = - HEADER_HEIGHT + - Math.max( - ...grid.entities.map( - entity => (entity.subtitle ? 15 : 0) + (entity.link ? 15 : 0), - ), - ); - } else { - rowCount = grid.entities.length; - columnCount = grid.groups.length; - headerHeight = HEADER_HEIGHT; - } - return ( -
- - {({ height, width }) => ( - { - const group = grid.groups[isPivoted ? rowIndex : columnIndex]; - const entity = grid.entities[isPivoted ? columnIndex : rowIndex]; - return ( - - ); - }} - renderColumnHeader={ - showHeader - ? ({ columnIndex }) => ( -
- {isPivoted ? ( - - ) : ( - - )} -
- ) - : undefined - } - renderRowHeader={({ rowIndex }) => ( -
- {isPivoted ? ( - - ) : ( - - )} -
- )} - /> - )} -
-
- ); -}; - -export default PermissionsGrid; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsEditBar.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsEditBar.jsx new file mode 100644 index 0000000000000..9398e659e147f --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsEditBar.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; + +import Confirm from "metabase/components/Confirm"; +import EditBar from "metabase/components/EditBar"; +import Button from "metabase/components/Button"; + +import PermissionsConfirm from "../PermissionsConfirm"; + +const propTypes = { + onSave: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + shouldConfirmCancel: PropTypes.bool, + isDirty: PropTypes.bool.isRequired, + diff: PropTypes.object, +}; + +export function PermissionsEditBar({ + diff, + isDirty, + shouldConfirmCancel, + onSave, + onCancel, +}) { + const saveButton = ( + } + triggerClasses={cx({ disabled: !isDirty })} + key="save" + > + + + ); + + const cancelButton = shouldConfirmCancel ? ( + + + + ) : ( + + ); + + return ( + + ); +} + +PermissionsEditBar.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.jsx new file mode 100644 index 0000000000000..a772e060a202b --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import fitViewport from "metabase/hoc/FitViewPort"; + +import { PermissionsTabs } from "./PermissionsTabs"; +import { FullHeightContainer } from "./PermissionsPageLayout.styled"; + +const propTypes = { + children: PropTypes.node.isRequired, + tab: PropTypes.oneOf(["data", "collections"]).isRequired, + onChangeTab: PropTypes.func.isRequired, + confirmBar: PropTypes.node, +}; + +export const PermissionsPageLayout = fitViewport( + function PermissionsPageLayout({ children, tab, onChangeTab, confirmBar }) { + return ( + + {confirmBar} +
+ +
+ + {children} + +
+ ); + }, +); + +PermissionsPageLayout.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.styled.jsx new file mode 100644 index 0000000000000..e8eebbd561035 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsPageLayout.styled.jsx @@ -0,0 +1,6 @@ +import styled from "styled-components"; +import { Flex } from "grid-styled"; + +export const FullHeightContainer = styled(Flex)` + height: 100%; +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsTabs.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsTabs.jsx new file mode 100644 index 0000000000000..add307485661e --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/PermissionsTabs.jsx @@ -0,0 +1,28 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; + +import Radio from "metabase/components/Radio"; + +const propTypes = { + tab: PropTypes.oneOf(["data", "collections"]).isRequired, + onChangeTab: PropTypes.func.isRequired, +}; + +export const PermissionsTabs = ({ tab, onChangeTab }) => ( +
+ +
+); + +PermissionsTabs.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/index.js b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/index.js new file mode 100644 index 0000000000000..24ad98b2248c2 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsPageLayout/index.js @@ -0,0 +1 @@ +export * from "./PermissionsPageLayout"; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.jsx new file mode 100644 index 0000000000000..75b736abcfdee --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.jsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; + +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; +import { lighten } from "metabase/lib/colors"; +import Icon from "metabase/components/Icon"; +import Toggle from "metabase/components/Toggle"; +import Tooltip from "metabase/components/Tooltip"; + +import { + PermissionsSelectOption, + optionShape, +} from "./PermissionsSelectOption"; + +import { + PermissionsSelectRoot, + OptionsList, + OptionsListItem, + ActionsList, + ToggleContainer, + ToggleLabel, + WarningIcon, +} from "./PermissionsSelect.styled"; + +const propTypes = { + options: PropTypes.arrayOf(PropTypes.shape(optionShape)).isRequired, + actions: PropTypes.arrayOf(PropTypes.shape(optionShape)), + value: PropTypes.string.isRequired, + toggleLabel: PropTypes.string, + colorScheme: PropTypes.oneOf(["default", "admin"]), + onChange: PropTypes.func.isRequired, + onAction: PropTypes.func, + isDisabled: PropTypes.bool, + disabledTooltip: PropTypes.string, + warning: PropTypes.string, +}; + +export function PermissionsSelect({ + options, + actions, + value, + toggleLabel, + colorScheme, + onChange, + onAction, + isDisabled, + disabledTooltip, + warning, +}) { + const [toggleState, setToggleState] = useState(false); + const selected = options.find(option => option.value === value); + const selectableOptions = options.filter(option => option !== selected); + + const shouldShowDisabledTooltip = isDisabled; + const selectedValue = ( + + + + {warning && ( + + + + )} + + + + ); + + const actionsForCurrentValue = actions?.[selected.value] || []; + const hasActions = actionsForCurrentValue.length > 0; + + return ( + + {({ onClose }) => ( + + + {selectableOptions.map(option => ( + { + onClose(); + onChange(option.value, toggleLabel ? toggleState : null); + }} + > + + + ))} + + {hasActions && ( + + {actionsForCurrentValue.map((action, index) => ( + { + onClose(); + onAction(action); + }} + > + + + ))} + + )} + + {toggleLabel && ( + + {toggleLabel} + + + )} + + )} + + ); +} + +PermissionsSelect.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx new file mode 100644 index 0000000000000..3cdcd906e2c72 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx @@ -0,0 +1,56 @@ +import styled from "styled-components"; +import Label from "metabase/components/type/Label"; +import { color, lighten } from "metabase/lib/colors"; +import Icon from "metabase/components/Icon"; + +export const PermissionsSelectRoot = styled.div` + display: flex; + align-items: center; + width: 180px; + cursor: ${props => (props.isDisabled ? "default" : "pointer")}; + opacity: ${props => (props.isDisabled ? "0.6" : "1")}; +`; + +export const PermissionsSelectText = styled(Label)` + flex-grow: 1; +`; + +export const OptionsList = styled.ul` + min-width: 210px; + padding: 0.5rem 0; +`; + +export const OptionsListItem = styled.li` + cursor: pointer; + padding: 0.5rem 1rem; + + &:hover { + color: ${color("white")}; + background-color: ${lighten("accent7", 0.1)}; + } +`; + +export const ActionsList = styled(OptionsList)` + border-top: 1px solid ${color("border")}; +`; + +export const ToggleContainer = styled.div` + display: flex; + align-items: center; + background-color: ${color("bg-medium")}; + padding: 0.5rem 1rem; + justify-content: flex-end; +`; + +export const ToggleLabel = styled.label` + font-size: 12px; + margin-right: 1rem; +`; + +export const WarningIcon = styled(Icon).attrs({ + size: 18, + name: "warning", +})` + margin-right: 0.25rem; + color: ${color("text-light")}; +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.jsx new file mode 100644 index 0000000000000..ab6da45c3f29b --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.jsx @@ -0,0 +1,34 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import Icon from "metabase/components/Icon"; + +import { + IconContainer, + PermissionsSelectOptionRoot, + PermissionsSelectLabel, +} from "./PermissionsSelectOption.styled"; + +export const optionShape = { + label: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + iconColor: PropTypes.string.isRequired, +}; + +const propTypes = { + ...optionShape, + className: PropTypes.string, +}; + +export function PermissionsSelectOption({ label, icon, iconColor, className }) { + return ( + + + + + {label} + + ); +} + +PermissionsSelectOption.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.styled.jsx new file mode 100644 index 0000000000000..64d8fb1ee4d21 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelectOption.styled.jsx @@ -0,0 +1,26 @@ +import styled from "styled-components"; +import colors from "metabase/lib/colors"; + +export const PermissionsSelectOptionRoot = styled.div` + display: flex; + align-items: center; + width: 100%; +`; + +export const IconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + width: 20px; + height: 20px; + color: ${colors["white"]}; + background-color: ${props => props.color}; +`; + +export const PermissionsSelectLabel = styled.div` + font-size: 14px; + font-weight: 700; + margin: 0; + padding: 0 0.5rem; +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/index.js b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/index.js new file mode 100644 index 0000000000000..d831a817d4e64 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/index.js @@ -0,0 +1 @@ +export * from "./PermissionsSelect"; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.jsx new file mode 100644 index 0000000000000..172bf500206c0 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.jsx @@ -0,0 +1,156 @@ +import React, { useState, useMemo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import { Box, Flex } from "grid-styled"; + +import EmptyState from "metabase/components/EmptyState"; +import Radio from "metabase/components/Radio"; +import TextInput from "metabase/components/TextInput"; +import Icon from "metabase/components/Icon"; +import Label from "metabase/components/type/Label"; +import Text from "metabase/components/type/Text"; +import { Tree } from "metabase/components/tree"; + +import { + SidebarRoot, + SidebarHeader, + SidebarContent, + EntityGroupsDivider, + BackButton, +} from "./PermissionsSidebar.styled"; + +const propTypes = { + title: PropTypes.string, + description: PropTypes.string, + filterPlaceholder: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + onBack: PropTypes.func, + selectedId: PropTypes.oneOfType([ + PropTypes.string.isRequired, + PropTypes.number.isRequired, + ]), + entityGroups: PropTypes.arrayOf(PropTypes.array), + onEntityChange: PropTypes.func, + entitySwitch: PropTypes.shape({ + value: PropTypes.string.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }), + ), + }), +}; + +const searchItems = (items, filter) => { + const matchingItems = items.filter(item => + item.name.toLowerCase().includes(filter), + ); + + const children = items + .map(c => c.children) + .filter(Boolean) + .flat(); + + const childrenMatches = + children.length > 0 ? searchItems(children, filter) : []; + + return [...matchingItems, ...childrenMatches]; +}; + +export function PermissionsSidebar({ + title, + description, + filterPlaceholder, + entityGroups, + entitySwitch, + selectedId, + onEntityChange, + onSelect, + onBack, +}) { + const [filter, setFilter] = useState(""); + + const handleFilterChange = text => setFilter(text); + + const filteredList = useMemo(() => { + const trimmedFilter = filter.trim().toLowerCase(); + + if (trimmedFilter.length === 0) { + return null; + } + + return searchItems(entityGroups.flat(), trimmedFilter); + }, [entityGroups, filter]); + + return ( + + + + {onBack && ( + + + + )} + {title && } + + + {description} + + {entitySwitch && ( + + + + )} + } + /> + + + {filteredList && ( + + + + } + /> + )} + {!filteredList && + entityGroups.map((entities, index) => { + const isLastGroup = index === entityGroups.length - 1; + return ( + + + {!isLastGroup && } + + ); + })} + + + ); +} + +PermissionsSidebar.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.styled.jsx new file mode 100644 index 0000000000000..493a7a8bd31d7 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/PermissionsSidebar.styled.jsx @@ -0,0 +1,35 @@ +import styled from "styled-components"; +import { color } from "metabase/lib/colors"; + +export const SidebarRoot = styled.aside` + width: 300px; + border-right: 1px solid ${color("border")}; +`; + +export const SidebarHeader = styled.div` + padding: 1rem; + border-bottom: 1px solid ${color("border")}; +`; + +export const SidebarContent = styled.div` + padding: 1rem 0; + overflow-y: auto; +`; + +export const EntityGroupsDivider = styled.hr` + margin: 1rem 1.5rem; + border: 0; + border-top: 1px solid ${color("border")}; +`; + +export const BackButton = styled.button` + color: ${color("text-light")}; + cursor: pointer; + transition: color 200ms; + margin-left: 0.75rem; + margin-right: 0.25rem; + + &:hover { + color: ${color("text-dark")}; + } +`; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/index.js b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/index.js new file mode 100644 index 0000000000000..378f1014b1240 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSidebar/index.js @@ -0,0 +1 @@ +export * from "./PermissionsSidebar"; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx new file mode 100644 index 0000000000000..4a71704626464 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.jsx @@ -0,0 +1,161 @@ +import React, { useState, useRef } from "react"; +import PropTypes from "prop-types"; + +import Label from "metabase/components/type/Label"; +import Icon from "metabase/components/Icon"; +import Tooltip from "metabase/components/Tooltip"; +import Modal from "metabase/components/Modal"; +import ConfirmContent from "metabase/components/ConfirmContent"; + +import { PermissionsSelect } from "../PermissionsSelect"; +import { + PermissionsTableRoot, + PermissionsTableRow, + PermissionsTableCell, + EntityNameCell, + EntityNameLink, + EntityName, +} from "./PermissionsTable.styled"; + +const propTypes = { + entities: PropTypes.arrayOf(PropTypes.object), + columns: PropTypes.arrayOf(PropTypes.string), + emptyState: PropTypes.node, + onSelect: PropTypes.func, + onChange: PropTypes.func, + onAction: PropTypes.func, + colorScheme: PropTypes.oneOf(["default", "admin"]), + horizontalPadding: PropTypes.oneOf(["sm", "lg"]), +}; + +export function PermissionsTable({ + entities, + columns, + onSelect, + onAction, + onChange, + horizontalPadding = "sm", + colorScheme, + emptyState = null, +}) { + const [confirmations, setConfirmations] = useState(null); + const confirmActionRef = useRef(null); + + const handleChange = (value, toggleState, entity, permission) => { + const confirmAction = () => { + onChange(entity, permission, value, toggleState); + }; + const confirmations = + permission.confirmations?.(value).filter(Boolean) || []; + if (confirmations.length > 0) { + setConfirmations(confirmations); + confirmActionRef.current = confirmAction; + } else { + confirmAction(); + } + }; + + const handleConfirm = () => { + if (confirmations.length === 1) { + confirmActionRef.current(); + setConfirmations(null); + confirmActionRef.current = null; + } else { + setConfirmations(prev => prev.slice(1)); + } + }; + + const handleCancelConfirm = () => { + setConfirmations(null); + confirmActionRef.current = null; + }; + + const hasItems = entities.length > 0; + + return ( + + + + + {columns.map((column, index) => { + const isFirst = index === 0; + const isLast = index === columns.length - 1; + + const width = isFirst ? "340px" : isLast ? null : "200px"; + + return ( + + + + ); + })} + + + + {entities.map(entity => { + return ( + + + {onSelect ? ( + onSelect && onSelect(entity)} + > + {entity.name} + + ) : ( + {entity.name} + )} + + {entity.hint && ( + + + + )} + + + {entity.permissions.map(permission => { + return ( + + + handleChange(value, toggleState, entity, permission) + } + onAction={actionCreator => + onAction(actionCreator, entity) + } + colorScheme={colorScheme} + /> + + ); + })} + + ); + })} + + + {!hasItems && emptyState} + {confirmations?.length > 0 && ( + + + + )} + + ); +} + +PermissionsTable.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx new file mode 100644 index 0000000000000..5cd1b3ad4539c --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx @@ -0,0 +1,44 @@ +import styled from "styled-components"; +import { color, alpha } from "metabase/lib/colors"; +import Link from "metabase/components/Link"; + +const HORIZONTAL_PADDING_VARIANTS = { + sm: "0.5rem", + lg: "3rem", +}; + +export const PermissionsTableRoot = styled.table` + border-collapse: collapse; + width: 100%; +`; + +export const PermissionsTableRow = styled.tr` + border-top: 1px solid ${alpha(color("border"), 0.5)}; +`; + +export const PermissionsTableCell = styled.td` + padding: 0.5rem 0.5rem; + + &:first-of-type { + padding: 0.5rem + ${props => HORIZONTAL_PADDING_VARIANTS[props.horizontalPadding]}; + } +`; + +export const EntityNameCell = styled(PermissionsTableCell)` + min-width: 280px; + display: flex; + align-items: center; +`; + +export const EntityName = styled.div` + font-weight: 700; +`; + +export const EntityNameLink = styled(Link)` + font-weight: 700; + text-decoration: underline; + color: ${color("admin-navbar")}; +`; + +export const PermissionTableHeaderRow = styled.tr``; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTable/index.js b/frontend/src/metabase/admin/permissions/components/PermissionsTable/index.js new file mode 100644 index 0000000000000..6bf4f7f1faff1 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/components/PermissionsTable/index.js @@ -0,0 +1 @@ +export * from "./PermissionsTable"; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTabs.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsTabs.jsx deleted file mode 100644 index 40cb5accc8be9..0000000000000 --- a/frontend/src/metabase/admin/permissions/components/PermissionsTabs.jsx +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; - -import { t } from "ttag"; - -import Radio from "metabase/components/Radio"; - -const PermissionsTabs = ({ tab, onChangeTab }) => ( -
- -
-); -export default PermissionsTabs; diff --git a/frontend/src/metabase/admin/permissions/constants/collections-permissions.js b/frontend/src/metabase/admin/permissions/constants/collections-permissions.js new file mode 100644 index 0000000000000..0da188b380c64 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/constants/collections-permissions.js @@ -0,0 +1,23 @@ +import { t } from "ttag"; +import { color } from "metabase/lib/colors"; + +export const COLLECTION_OPTIONS = { + write: { + label: t`Curate`, + value: "write", + icon: "check", + iconColor: color("success"), + }, + read: { + label: t`View`, + value: "read", + icon: "eye", + iconColor: color("warning"), + }, + none: { + label: t`No access`, + value: "none", + icon: "close", + iconColor: color("danger"), + }, +}; diff --git a/frontend/src/metabase/admin/permissions/constants/data-permissions.js b/frontend/src/metabase/admin/permissions/constants/data-permissions.js new file mode 100644 index 0000000000000..3d7f1009505b9 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/constants/data-permissions.js @@ -0,0 +1,29 @@ +import { t } from "ttag"; +import { color } from "metabase/lib/colors"; + +export const DATA_PERMISSION_OPTIONS = { + all: { + label: t`Allowed`, + value: "all", + icon: "check", + iconColor: color("success"), + }, + controlled: { + label: t`Limited`, + value: "controlled", + icon: "permissions_limited", + iconColor: color("warning"), + }, + none: { + label: t`No access`, + value: "none", + icon: "close", + iconColor: color("danger"), + }, + write: { + label: t`Allowed`, + value: "write", + icon: "check", + iconColor: color("success"), + }, +}; diff --git a/frontend/src/metabase/admin/permissions/constants/messages.js b/frontend/src/metabase/admin/permissions/constants/messages.js new file mode 100644 index 0000000000000..25a4f9bf48a05 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/constants/messages.js @@ -0,0 +1,4 @@ +import { t } from "ttag"; + +export const UNABLE_TO_CHANGE_ADMIN_PERMISSIONS = t`Cannot change the data access for Administrators`; +export const DATA_ACCESS_IS_REQUIRED = t`Data access must be allowed`; diff --git a/frontend/src/metabase/admin/permissions/containers/CollectionPermissionsModal.jsx b/frontend/src/metabase/admin/permissions/containers/CollectionPermissionsModal.jsx deleted file mode 100644 index 6bc27f817ca79..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/CollectionPermissionsModal.jsx +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { connect } from "react-redux"; -import { t } from "ttag"; -import _ from "underscore"; - -import * as Urls from "metabase/lib/urls"; -import { CollectionsApi } from "metabase/services"; - -import Collections from "metabase/entities/collections"; -import SnippetCollections from "metabase/entities/snippet-collections"; - -import { isPersonalCollectionChild } from "metabase/collections/utils"; - -import ModalContent from "metabase/components/ModalContent"; -import Button from "metabase/components/Button"; -import Link from "metabase/components/Link"; - -import PermissionsGrid from "../components/PermissionsGrid"; - -import { - getCollectionsPermissionsGrid, - getIsDirty, - getDiff, -} from "../selectors"; -import { initialize, updatePermission, savePermissions } from "../permissions"; - -const getCollectionEntity = props => - props.namespace === "snippets" ? SnippetCollections : Collections; - -const mapStateToProps = (state, props) => { - const collectionId = Urls.extractCollectionId(props.params.slug); - return { - grid: getCollectionsPermissionsGrid(state, { - collectionId, - singleCollectionMode: true, - namespace: props.namespace, - }), - isDirty: getIsDirty(state, props), - diff: getDiff(state, props), - collection: getCollectionEntity(props).selectors.getObject(state, { - entityId: collectionId, - }), - collectionsList: getCollectionEntity(props).selectors.getList(state, props), - }; -}; - -const mapDispatchToProps = (dispatch, props) => - _.mapObject( - { - initialize, - loadCollections: getCollectionEntity(props).actions.fetchList, - onUpdatePermission: updatePermission, - onSave: savePermissions, - }, - f => (...args) => dispatch(f(...args)), - ); - -@connect( - mapStateToProps, - mapDispatchToProps, -) -export default class CollectionPermissionsModal extends Component { - UNSAFE_componentWillMount() { - const { namespace, loadCollections, initialize } = this.props; - initialize( - () => CollectionsApi.graph({ namespace }), - graph => CollectionsApi.updateGraph({ ...graph, namespace }), - ); - loadCollections(); - } - - componentDidUpdate() { - const { collection, collectionsList, onClose } = this.props; - - const loadedPersonalCollection = - collection && - Array.isArray(collectionsList) && - (collection.personal_owner_id || - isPersonalCollectionChild(collection, collectionsList)); - - if (loadedPersonalCollection) { - onClose(); - } - } - - render() { - const { - grid, - onUpdatePermission, - isDirty, - onClose, - onSave, - namespace, - collection, - } = this.props; - return ( - - {t`See all collection permissions`} - , - ]), - , - , - ]} - > -
- {grid && ( - - )} -
-
- ); - } -} diff --git a/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx deleted file mode 100644 index f5922f97edeaa..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { connect } from "react-redux"; - -import PermissionsEditor from "../components/PermissionsEditor"; -import PermissionsApp from "./PermissionsApp"; -import fitViewport from "metabase/hoc/FitViewPort"; - -import { CollectionsApi } from "metabase/services"; -import Collections from "metabase/entities/collections"; - -import { - getCollectionsPermissionsGrid, - getIsDirty, - getDiff, -} from "../selectors"; -import { - updatePermission, - savePermissions, - loadPermissions, -} from "../permissions"; -import { push } from "react-router-redux"; - -const mapStateToProps = (state, props) => { - return { - grid: getCollectionsPermissionsGrid(state, props), - isDirty: getIsDirty(state, props), - diff: getDiff(state, props), - tab: "collections", - }; -}; - -const mapDispatchToProps = { - onUpdatePermission: updatePermission, - onSave: savePermissions, - onCancel: loadPermissions, - onChangeTab: tab => push(`/admin/permissions/${tab}`), -}; - -const Editor = connect( - mapStateToProps, - mapDispatchToProps, -)(PermissionsEditor); - -@connect( - null, - { - loadCollections: Collections.actions.fetchList, - push, - }, -) -@fitViewport -export default class CollectionsPermissionsApp extends Component { - UNSAFE_componentWillMount() { - this.props.loadCollections(); - } - render() { - return ( - - - - ); - } -} diff --git a/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx deleted file mode 100644 index 109e219516618..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/DataPermissionsApp.jsx +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { connect } from "react-redux"; - -import fitViewport from "metabase/hoc/FitViewPort"; - -import PermissionsApp from "./PermissionsApp"; - -import { PermissionsApi } from "metabase/services"; -import { fetchRealDatabases } from "metabase/redux/metadata"; - -@connect( - null, - { fetchRealDatabases }, -) -@fitViewport -export default class DataPermissionsApp extends Component { - UNSAFE_componentWillMount() { - this.props.fetchRealDatabases(true); - } - render() { - return ( - - ); - } -} diff --git a/frontend/src/metabase/admin/permissions/containers/DatabasesPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/DatabasesPermissionsApp.jsx deleted file mode 100644 index f697ab2f7b155..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/DatabasesPermissionsApp.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { connect } from "react-redux"; -import { push } from "react-router-redux"; - -import PermissionsEditor from "../components/PermissionsEditor"; - -import { getDatabasesPermissionsGrid, getIsDirty, getDiff } from "../selectors"; -import { - updatePermission, - savePermissions, - loadPermissions, -} from "../permissions"; - -const mapStateToProps = (state, props) => { - return { - grid: getDatabasesPermissionsGrid(state, props), - isDirty: getIsDirty(state, props), - diff: getDiff(state, props), - tab: "databases", - }; -}; - -const mapDispatchToProps = { - onUpdatePermission: updatePermission, - onSave: savePermissions, - onCancel: loadPermissions, - onChangeTab: tab => push(`/admin/permissions/${tab}`), -}; - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(PermissionsEditor); diff --git a/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx deleted file mode 100644 index 4ed0b01d60e0f..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/PermissionsApp.jsx +++ /dev/null @@ -1,97 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { withRouter } from "react-router"; -import { connect } from "react-redux"; -import { push } from "react-router-redux"; - -import { clearSaveError, initialize } from "../permissions"; -import { getIsDirty, getSaveError } from "../selectors"; -import { t } from "ttag"; -import ConfirmContent from "metabase/components/ConfirmContent"; -import Modal from "metabase/components/Modal"; -import ModalContent from "metabase/components/ModalContent"; -import Button from "metabase/components/Button"; - -const mapStateToProps = (state, props) => ({ - isDirty: getIsDirty(state, props), - saveError: getSaveError(state, props), -}); - -const mapDispatchToProps = { - clearSaveError, - initialize, - push, -}; - -@withRouter -@connect( - mapStateToProps, - mapDispatchToProps, -) -export default class PermissionsApp extends Component { - static propTypes = { - load: PropTypes.func.isRequired, - save: PropTypes.func.isRequired, - }; - - constructor(props, context) { - super(props, context); - this.state = { - nextLocation: false, - confirmed: false, - }; - } - UNSAFE_componentWillMount() { - this.props.initialize(this.props.load, this.props.save); - this.props.router.setRouteLeaveHook(this.props.route, this.routerWillLeave); - } - routerWillLeave = nextLocation => { - if (this.props.isDirty && !this.state.confirmed) { - this.setState({ nextLocation: nextLocation, confirmed: false }); - return false; - } - }; - render() { - const { - children, - fitClassNames, - saveError, - clearSaveError, - push, - } = this.props; - const { nextLocation } = this.state; - - return ( -
- {children} - - -

{saveError}

-
- -
-
-
- - { - this.setState({ nextLocation: null }); - }} - onAction={() => { - this.setState({ nextLocation: null, confirmed: true }, () => { - push(nextLocation.pathname, nextLocation.state); - }); - }} - /> - -
- ); - } -} diff --git a/frontend/src/metabase/admin/permissions/containers/SchemasPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/SchemasPermissionsApp.jsx deleted file mode 100644 index 1116a33b983d1..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/SchemasPermissionsApp.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { connect } from "react-redux"; -import { push } from "react-router-redux"; - -import PermissionsEditor from "../components/PermissionsEditor"; - -import { getSchemasPermissionsGrid, getIsDirty, getDiff } from "../selectors"; -import { - updatePermission, - savePermissions, - loadPermissions, -} from "../permissions"; - -const mapStateToProps = (state, props) => { - return { - grid: getSchemasPermissionsGrid(state, props), - isDirty: getIsDirty(state, props), - diff: getDiff(state, props), - tab: "databases", - }; -}; - -const mapDispatchToProps = { - onUpdatePermission: updatePermission, - onSave: savePermissions, - onCancel: loadPermissions, - onChangeTab: tab => push(`/admin/permissions/${tab}`), -}; - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(PermissionsEditor); diff --git a/frontend/src/metabase/admin/permissions/containers/TablesPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/TablesPermissionsApp.jsx deleted file mode 100644 index 39134527a630c..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/TablesPermissionsApp.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { connect } from "react-redux"; -import { push } from "react-router-redux"; - -import PermissionsEditor from "../components/PermissionsEditor"; - -import { getTablesPermissionsGrid, getIsDirty, getDiff } from "../selectors"; -import { - updatePermission, - savePermissions, - loadPermissions, -} from "../permissions"; - -const mapStateToProps = (state, props) => { - return { - grid: getTablesPermissionsGrid(state, props), - isDirty: getIsDirty(state, props), - diff: getDiff(state, props), - tab: "databases", - }; -}; - -const mapDispatchToProps = { - onUpdatePermission: updatePermission, - onSave: savePermissions, - onCancel: loadPermissions, - onChangeTab: tab => push(`/admin/permissions/${tab}`), -}; - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(PermissionsEditor); diff --git a/frontend/src/metabase/admin/permissions/containers/TogglePropagateAction.jsx b/frontend/src/metabase/admin/permissions/containers/TogglePropagateAction.jsx deleted file mode 100644 index 791a2967ff194..0000000000000 --- a/frontend/src/metabase/admin/permissions/containers/TogglePropagateAction.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; - -import { connect } from "react-redux"; - -import { getPropagatePermissions } from "../selectors"; -import { setPropagatePermissions } from "../permissions"; - -import Toggle from "metabase/components/Toggle"; - -const mapStateToProps = (state, props) => ({ - propagate: getPropagatePermissions(state, props), -}); -const mapDispatchToProps = { - setPropagatePermissions, -}; - -const TogglePropagateAction = connect( - mapStateToProps, - mapDispatchToProps, -)(({ propagate, setPropagatePermissions, message }) => ( -
setPropagatePermissions(!propagate)} - > - {message} - -
-)); - -// eslint-disable-next-line react/display-name -export default props => ; diff --git a/frontend/src/metabase/admin/permissions/hooks/use-leave-confirmation.jsx b/frontend/src/metabase/admin/permissions/hooks/use-leave-confirmation.jsx new file mode 100644 index 0000000000000..9f5717a29af58 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/hooks/use-leave-confirmation.jsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; +import { t } from "ttag"; + +import Modal from "metabase/components/Modal"; +import ConfirmContent from "metabase/components/ConfirmContent"; + +export const useLeaveConfirmation = ({ + router, + route, + onConfirm, + isEnabled, +}) => { + const [isConfirmed, setIsConfirmed] = useState(false); + const [isConfirmationVisible, setIsConfirmationVisible] = useState(false); + const [nextLocation, setNextLocation] = useState(null); + + useEffect(() => { + const removeLeaveHook = router.setRouteLeaveHook(route, location => { + if (isEnabled && !isConfirmed) { + setIsConfirmationVisible(true); + setNextLocation(location); + return false; + } + }); + + return removeLeaveHook; + }, [router, route, isEnabled, isConfirmed]); + + useEffect(() => { + if (isConfirmed && nextLocation) { + onConfirm(nextLocation); + } + }, [isConfirmed, onConfirm, nextLocation]); + + const handleClose = () => { + setIsConfirmationVisible(false); + }; + + const handleConfirm = () => { + setIsConfirmed(true); + }; + + return ( + + + + ); +}; diff --git a/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx new file mode 100644 index 0000000000000..39bd380e0a402 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/pages/CollectionPermissionsPage/CollectionPermissionsPage.jsx @@ -0,0 +1,140 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useCallback } from "react"; +import { push } from "react-router-redux"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import _ from "underscore"; + +import Groups from "metabase/entities/groups"; +import Collections from "metabase/entities/collections"; + +import { + PermissionsEditor, + PermissionsEditorEmptyState, +} from "../../components/PermissionsEditor"; +import { PermissionsPageLayout } from "../../components/PermissionsPageLayout/PermissionsPageLayout"; +import { + initializeCollectionPermissions, + updateCollectionPermission, + saveCollectionPermissions, + loadCollectionPermissions, +} from "../../permissions"; +import { + getCollectionsSidebar, + getCollectionsPermissionEditor, + getCollectionEntity, + getIsDirty, + getDiff, +} from "../../selectors/collection-permissions"; +import { PermissionsSidebar } from "../../components/PermissionsSidebar"; +import { PermissionsEditBar } from "../../components/PermissionsPageLayout/PermissionsEditBar"; +import { useLeaveConfirmation } from "../../hooks/use-leave-confirmation"; + +function CollectionsPermissionsPage({ + sidebar, + permissionEditor, + collection, + + isDirty, + diff, + savePermissions, + loadPermissions, + + updateCollectionPermission, + navigateToItem, + navigateToTab, + initialize, + + router, + route, + navigateToLocation, +}) { + useEffect(() => { + initialize(); + }, [initialize]); + + const beforeLeaveConfirmation = useLeaveConfirmation({ + router, + route, + onConfirm: navigateToLocation, + isEnabled: isDirty, + }); + + const handlePermissionChange = useCallback( + (item, _permission, value, toggleState) => { + updateCollectionPermission({ + groupId: item.id, + collection, + value, + shouldPropagate: toggleState, + }); + }, + [collection, updateCollectionPermission], + ); + + return ( + loadPermissions()} + /> + ) + } + > + + + {!permissionEditor && ( + + )} + + {permissionEditor && ( + + )} + + {beforeLeaveConfirmation} + + ); +} + +const mapDispatchToProps = { + initialize: initializeCollectionPermissions, + loadPermissions: loadCollectionPermissions, + navigateToTab: tab => push(`/admin/permissions/${tab}`), + navigateToItem: ({ id }) => push(`/admin/permissions/collections/${id}`), + updateCollectionPermission, + savePermissions: saveCollectionPermissions, + navigateToLocation: location => push(location.pathname, location.state), +}; + +const mapStateToProps = (state, props) => { + return { + sidebar: getCollectionsSidebar(state, props), + permissionEditor: getCollectionsPermissionEditor(state, props), + isDirty: getIsDirty(state, props), + diff: getDiff(state, props), + collection: getCollectionEntity(state, props), + }; +}; + +export default _.compose( + Collections.loadList({ + query: () => ({ tree: true }), + }), + Groups.loadList(), + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(CollectionsPermissionsPage); diff --git a/frontend/src/metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage.jsx b/frontend/src/metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage.jsx new file mode 100644 index 0000000000000..01c726f8eddd2 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage.jsx @@ -0,0 +1,101 @@ +import React, { useEffect } from "react"; +import PropTypes from "prop-types"; +import { push } from "react-router-redux"; +import _ from "underscore"; +import { connect } from "react-redux"; + +import Databases from "metabase/entities/databases"; +import Groups from "metabase/entities/groups"; + +import { getIsDirty, getDiff } from "../../selectors/data-permissions"; +import { + saveDataPermissions, + loadDataPermissions, + initializeDataPermissions, +} from "../../permissions"; +import { PermissionsEditBar } from "../../components/PermissionsPageLayout/PermissionsEditBar"; +import { PermissionsPageLayout } from "../../components/PermissionsPageLayout/PermissionsPageLayout"; +import { withRouter } from "react-router"; +import { useLeaveConfirmation } from "../../hooks/use-leave-confirmation"; + +const propTypes = { + children: PropTypes.node.isRequired, + isDirty: PropTypes.bool, + diff: PropTypes.object, + savePermissions: PropTypes.func.isRequired, + loadPermissions: PropTypes.func.isRequired, + initialize: PropTypes.func.isRequired, + navigateToTab: PropTypes.func.isRequired, + navigateToLocation: PropTypes.func.isRequired, + router: PropTypes.object, + route: PropTypes.object, +}; + +function DataPermissionsPage({ + children, + isDirty, + diff, + savePermissions, + loadPermissions, + initialize, + navigateToTab, + router, + route, + navigateToLocation, +}) { + useEffect(() => { + initialize(); + }, [initialize]); + + const beforeLeaveConfirmation = useLeaveConfirmation({ + router, + route, + onConfirm: navigateToLocation, + isEnabled: isDirty, + }); + + return ( + + ) + } + > + {children} + {beforeLeaveConfirmation} + + ); +} + +DataPermissionsPage.propTypes = propTypes; + +const mapDispatchToProps = { + loadPermissions: loadDataPermissions, + savePermissions: saveDataPermissions, + initialize: initializeDataPermissions, + navigateToTab: tab => push(`/admin/permissions/${tab}`), + navigateToLocation: location => push(location.pathname, location.state), +}; + +const mapStateToProps = (state, props) => ({ + isDirty: getIsDirty(state, props), + diff: getDiff(state, props), +}); + +export default _.compose( + withRouter, + Databases.loadList({ entityQuery: { include: "tables" } }), + Groups.loadList(), + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(DataPermissionsPage); diff --git a/frontend/src/metabase/admin/permissions/pages/DatabasePermissionsPage/DatabasesPermissionsPage.jsx b/frontend/src/metabase/admin/permissions/pages/DatabasePermissionsPage/DatabasesPermissionsPage.jsx new file mode 100644 index 0000000000000..115a4d5d54e50 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/pages/DatabasePermissionsPage/DatabasesPermissionsPage.jsx @@ -0,0 +1,120 @@ +/* eslint-disable react/prop-types */ +import React, { useCallback } from "react"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import _ from "underscore"; +import { connect } from "react-redux"; + +import { + getGroupsDataPermissionEditor, + getDatabasesSidebar, +} from "../../selectors/data-permissions"; +import { updateDataPermission } from "../../permissions"; + +import { PermissionsSidebar } from "../../components/PermissionsSidebar"; +import { + PermissionsEditor, + PermissionsEditorEmptyState, +} from "../../components/PermissionsEditor"; + +function DatabasesPermissionsPage({ + params, + children, + sidebar, + permissionEditor, + + navigateToItem, + navigateToDatabaseList, + switchView, + updateDataPermission, + dispatch, +}) { + const handleEntityChange = useCallback( + entityType => { + switchView(entityType); + }, + [switchView], + ); + + const handlePermissionChange = useCallback( + async (item, permission, value) => { + await updateDataPermission({ + groupId: item.id, + permission, + value, + entityId: params, + view: "database", + }); + }, + [params, updateDataPermission], + ); + + const handleAction = (action, item) => { + dispatch(action.actionCreator(item.id, params, "database")); + }; + + return ( + + + + {!permissionEditor && ( + + )} + + {permissionEditor && ( + + )} + + {children} + + ); +} + +const BASE_PATH = `/admin/permissions/data/database/`; + +const mapDispatchToProps = { + updateDataPermission, + switchView: entityType => push(`/admin/permissions/data/${entityType}`), + navigateToDatabaseList: () => push(BASE_PATH), + navigateToItem: item => { + switch (item.type) { + case "database": + return push(`${BASE_PATH}${item.id}`); + case "schema": + return push(`${BASE_PATH}${item.databaseId}/schema/${item.name}`); + case "table": + return push( + `${BASE_PATH}${item.databaseId}/schema/${item.schemaName}/table/${item.originalId}`, + ); + } + + return push(BASE_PATH); + }, +}; + +const mapStateToProps = (state, props) => { + return { + sidebar: getDatabasesSidebar(state, props), + permissionEditor: getGroupsDataPermissionEditor(state, props), + }; +}; + +export default _.compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(DatabasesPermissionsPage); diff --git a/frontend/src/metabase/admin/permissions/pages/GroupDataPermissionsPage/GroupsPermissionsPage.jsx b/frontend/src/metabase/admin/permissions/pages/GroupDataPermissionsPage/GroupsPermissionsPage.jsx new file mode 100644 index 0000000000000..f7f5f477520ac --- /dev/null +++ b/frontend/src/metabase/admin/permissions/pages/GroupDataPermissionsPage/GroupsPermissionsPage.jsx @@ -0,0 +1,169 @@ +/* eslint-disable react/prop-types */ +import React, { useCallback } from "react"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import _ from "underscore"; +import { connect } from "react-redux"; + +import { + getDatabasesPermissionEditor, + getGroupsSidebar, +} from "../../selectors/data-permissions"; +import { updateDataPermission } from "../../permissions"; +import { PermissionsSidebar } from "../../components/PermissionsSidebar"; +import { + PermissionsEditor, + PermissionsEditorEmptyState, +} from "../../components/PermissionsEditor"; + +function GroupsPermissionsPage({ + params, + children, + sidebar, + permissionEditor, + + navigateToItem, + switchView, + navigateToTableItem, + updateDataPermission, + dispatch, + navigateToDatabase, +}) { + const handleEntityChange = useCallback( + entityType => { + switchView(entityType); + }, + [switchView], + ); + + const handleSidebarItemSelect = useCallback( + item => { + navigateToItem(item, params); + }, + [navigateToItem, params], + ); + + const handleTableItemSelect = useCallback( + item => { + navigateToTableItem(item, params); + }, + [navigateToTableItem, params], + ); + + const handlePermissionChange = useCallback( + async (item, permission, value) => { + let entityId; + + switch (item.type) { + case "database": + entityId = { databaseId: item.id }; + break; + case "schema": + entityId = { + databaseId: params.databaseId, + schemaName: item.name, + }; + break; + case "table": + entityId = { + databaseId: params.databaseId, + schemaName: params.schemaName, + tableId: item.id, + }; + break; + } + + await updateDataPermission({ + groupId: params.groupId, + permission, + value, + entityId, + view: "group", + }); + }, + [params, updateDataPermission], + ); + + const handleAction = (action, item) => { + dispatch( + action.actionCreator( + params.groupId, + { + databaseId: params.databaseId, + schemaName: params.schemaName, + tableId: item.id, + }, + "group", + ), + ); + }; + + const handleBreadcrumbsItemSelect = item => + navigateToDatabase(params, item.id); + + return ( + + + + {!permissionEditor && ( + + )} + + {permissionEditor && ( + + )} + + {children} + + ); +} + +const BASE_PATH = `/admin/permissions/data/group`; + +const mapDispatchToProps = { + updateDataPermission, + switchView: entityType => push(`/admin/permissions/data/${entityType}/`), + navigateToItem: item => push(`${BASE_PATH}/${item.id}`), + navigateToDatabase: (params, databaseId) => + push(`${BASE_PATH}/${params.groupId}/database/${databaseId}`), + navigateToTableItem: (item, { groupId, databaseId }) => { + if (item.type === "database") { + return item.schemas.length > 1 + ? push(`${BASE_PATH}/${groupId}/database/${item.id}`) + : push( + `${BASE_PATH}/${groupId}/database/${item.id}/schema/${item.schemas[0].name}`, + ); + } else if (item.type === "schema") { + return push( + `${BASE_PATH}/${groupId}/database/${databaseId}/schema/${item.name}`, + ); + } + }, +}; + +const mapStateToProps = (state, props) => { + return { + sidebar: getGroupsSidebar(state, props), + permissionEditor: getDatabasesPermissionEditor(state, props), + }; +}; + +export default _.compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(GroupsPermissionsPage); diff --git a/frontend/src/metabase/admin/permissions/permissions.js b/frontend/src/metabase/admin/permissions/permissions.js index 61dc28e06df80..4062775f7b4d1 100644 --- a/frontend/src/metabase/admin/permissions/permissions.js +++ b/frontend/src/metabase/admin/permissions/permissions.js @@ -4,153 +4,270 @@ import { handleActions, combineReducers, } from "metabase/lib/redux"; - -import MetabaseAnalytics from "metabase/lib/analytics"; -import { t } from "ttag"; -import { PermissionsApi } from "metabase/services"; +import { CollectionsApi, PermissionsApi } from "metabase/services"; import Group from "metabase/entities/groups"; +import MetabaseAnalytics from "metabase/lib/analytics"; +import { + inferAndUpdateEntityPermissions, + updateFieldsPermission, + updateNativePermission, + updateSchemasPermission, + updateTablesPermission, +} from "metabase/lib/permissions"; +import { getMetadata } from "metabase/selectors/metadata"; +import { assocIn } from "icepick"; -const RESET = "metabase/admin/permissions/RESET"; -export const reset = createAction(RESET); - -const INITIALIZE = "metabase/admin/permissions/INITIALIZE"; -export const initialize = createThunkAction( - INITIALIZE, - (load, save) => async (dispatch, getState) => { - dispatch(reset({ load, save })); +const INITIALIZE_DATA_PERMISSIONS = + "metabase/admin/permissions/INITIALIZE_DATA_PERMISSIONS"; +export const initializeDataPermissions = createThunkAction( + INITIALIZE_DATA_PERMISSIONS, + () => async dispatch => { await Promise.all([ - dispatch(loadPermissions()), + dispatch(loadDataPermissions()), dispatch(Group.actions.fetchList()), ]); }, ); -const LOAD_GROUPS = "metabase/admin/permissions/LOAD_GROUPS"; -export const loadGroups = createAction(LOAD_GROUPS, () => - PermissionsApi.groups(), +const LOAD_DATA_PERMISSIONS = + "metabase/admin/permissions/LOAD_DATA_PERMISSIONS"; +export const loadDataPermissions = createThunkAction( + LOAD_DATA_PERMISSIONS, + () => async () => PermissionsApi.graph(), ); -const LOAD_PERMISSIONS = "metabase/admin/permissions/LOAD_PERMISSIONS"; -export const loadPermissions = createThunkAction( - LOAD_PERMISSIONS, - () => async (dispatch, getState) => { - const { load } = getState().admin.permissions; - return load(); +const INITIALIZE_COLLECTION_PERMISSIONS = + "metabase/admin/permissions/INITIALIZE_COLLECTION_PERMISSIONS"; +export const initializeCollectionPermissions = createThunkAction( + INITIALIZE_COLLECTION_PERMISSIONS, + namespace => async dispatch => { + await Promise.all([ + dispatch(loadCollectionPermissions(namespace)), + dispatch(Group.actions.fetchList()), + ]); }, ); -const UPDATE_PERMISSION = "metabase/admin/permissions/UPDATE_PERMISSION"; -export const updatePermission = createThunkAction( - UPDATE_PERMISSION, - ({ groupId, entityId, value, updater, postAction }) => async ( - dispatch, - getState, - ) => { - if (postAction) { - const action = postAction(groupId, entityId, value); - if (action) { - dispatch(action); +const LOAD_COLLECTION_PERMISSIONS = + "metabase/admin/permissions/LOAD_COLLECTION_PERMISSIONS"; +export const loadCollectionPermissions = createThunkAction( + LOAD_COLLECTION_PERMISSIONS, + namespace => async () => { + const params = namespace != null ? { namespace } : {}; + return CollectionsApi.graph(params); + }, +); + +const UPDATE_DATA_PERMISSION = + "metabase/admin/permissions/UPDATE_DATA_PERMISSION"; +export const updateDataPermission = createThunkAction( + UPDATE_DATA_PERMISSION, + ({ groupId, permission, value, entityId, view }) => { + return (dispatch, getState) => { + const metadata = getMetadata(getState()); + if (permission.postActions) { + const params = { ...entityId, groupId }; + const action = permission.postActions?.[value]?.(params, view); + if (action) { + dispatch(action); + } } - } - return updater(groupId, entityId, value); + + return { groupId, permission, value, metadata, entityId }; + }; }, ); -const SAVE_PERMISSIONS = "metabase/admin/permissions/SAVE_PERMISSIONS"; -export const savePermissions = createThunkAction( - SAVE_PERMISSIONS, - () => async (dispatch, getState) => { +const SAVE_DATA_PERMISSIONS = + "metabase/admin/permissions/data/SAVE_DATA_PERMISSIONS"; +export const saveDataPermissions = createThunkAction( + SAVE_DATA_PERMISSIONS, + () => async (_dispatch, getState) => { MetabaseAnalytics.trackEvent("Permissions", "save"); - const { permissions, revision, save } = getState().admin.permissions; - const result = await save({ - revision: revision, - groups: permissions, + const { + dataPermissions, + dataPermissionsRevision, + } = getState().admin.permissions; + const result = await PermissionsApi.updateGraph({ + groups: dataPermissions, + revision: dataPermissionsRevision, }); + return result; }, ); -const SET_PROPAGATE_PERMISSIONS = - "metabase/admin/permissions/SET_PROPAGATE_PERMISSIONS"; -export const setPropagatePermissions = createAction(SET_PROPAGATE_PERMISSIONS); - -const CLEAR_SAVE_ERROR = "metabase/admin/permissions/CLEAR_SAVE_ERROR"; -export const clearSaveError = createAction(CLEAR_SAVE_ERROR); +const UPDATE_COLLECTION_PERMISSION = + "metabase/admin/permissions/UPDATE_COLLECTION_PERMISSION"; +export const updateCollectionPermission = createAction( + UPDATE_COLLECTION_PERMISSION, +); -const save = handleActions( - { - [RESET]: { next: (state, { payload }) => payload.save }, +const SAVE_COLLECTION_PERMISSIONS = + "metabase/admin/permissions/data/SAVE_COLLECTION_PERMISSIONS"; +export const saveCollectionPermissions = createThunkAction( + SAVE_COLLECTION_PERMISSIONS, + namespace => async (_dispatch, getState) => { + MetabaseAnalytics.trackEvent("Permissions", "save"); + const { + collectionPermissions, + collectionPermissionsRevision, + } = getState().admin.permissions; + const result = await CollectionsApi.updateGraph({ + namespace, + revision: collectionPermissionsRevision, + groups: collectionPermissions, + }); + return result; }, - null, ); -const load = handleActions( + +function getDecendentCollections(collection) { + const subCollections = collection.children.filter( + collection => !collection.is_personal, + ); + return subCollections.concat(...subCollections.map(getDecendentCollections)); +} + +const dataPermissions = handleActions( { - [RESET]: { next: (state, { payload }) => payload.load }, + [LOAD_DATA_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, + }, + [SAVE_DATA_PERMISSIONS]: { next: (_state, { payload }) => payload.groups }, + [UPDATE_DATA_PERMISSION]: { + next: (state, { payload }) => { + const { value, groupId, entityId, metadata, permission } = payload; + + if (entityId.tableId != null) { + MetabaseAnalytics.trackEvent("Permissions", "fields", value); + const updatedPermissions = updateFieldsPermission( + state, + groupId, + entityId, + value, + metadata, + ); + return inferAndUpdateEntityPermissions( + updatedPermissions, + groupId, + entityId, + metadata, + ); + } else if (entityId.schemaName != null) { + MetabaseAnalytics.trackEvent("Permissions", "tables", value); + return updateTablesPermission( + state, + groupId, + entityId, + value, + metadata, + ); + } else if (permission.name === "native") { + MetabaseAnalytics.trackEvent("Permissions", "native", value); + return updateNativePermission( + state, + groupId, + entityId, + value, + metadata, + ); + } else { + MetabaseAnalytics.trackEvent("Permissions", "schemas", value); + return updateSchemasPermission( + state, + groupId, + entityId, + value, + metadata, + ); + } + }, + }, }, null, ); -const permissions = handleActions( +const originalDataPermissions = handleActions( { - [RESET]: { next: () => null }, - [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.groups }, - [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.groups }, - [UPDATE_PERMISSION]: { next: (state, { payload }) => payload }, + [LOAD_DATA_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, + }, + [SAVE_DATA_PERMISSIONS]: { next: (_state, { payload }) => payload.groups }, }, null, ); -const originalPermissions = handleActions( +const dataPermissionsRevision = handleActions( { - [RESET]: { next: () => null }, - [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.groups }, - [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.groups }, + [LOAD_DATA_PERMISSIONS]: { + next: (_state, { payload }) => payload.revision, + }, + [SAVE_DATA_PERMISSIONS]: { + next: (_state, { payload }) => payload.revision, + }, }, null, ); -const revision = handleActions( +const collectionPermissions = handleActions( { - [RESET]: { next: () => null }, - [LOAD_PERMISSIONS]: { next: (state, { payload }) => payload.revision }, - [SAVE_PERMISSIONS]: { next: (state, { payload }) => payload.revision }, + [LOAD_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, + }, + [UPDATE_COLLECTION_PERMISSION]: { + next: (state, { payload }) => { + const { groupId, collection, value, shouldPropagate } = payload; + let newPermissions = assocIn(state, [groupId, collection.id], value); + + if (shouldPropagate) { + for (const descendent of getDecendentCollections(collection)) { + newPermissions = assocIn( + newPermissions, + [groupId, descendent.id], + value, + ); + } + } + return newPermissions; + }, + }, + [SAVE_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, + }, }, null, ); -const saveError = handleActions( +const originalCollectionPermissions = handleActions( { - [RESET]: { next: () => null }, - [SAVE_PERMISSIONS]: { - next: state => null, - throw: (state, { payload }) => - (payload && typeof payload.data === "string" - ? payload.data - : payload.data.message) || t`Sorry, an error occurred.`, + [LOAD_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, }, - [LOAD_PERMISSIONS]: { - next: state => null, + [SAVE_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.groups, }, - [CLEAR_SAVE_ERROR]: { next: () => null }, }, null, ); -const propagatePermissions = handleActions( +const collectionPermissionsRevision = handleActions( { - [SET_PROPAGATE_PERMISSIONS]: { next: (state, { payload }) => payload }, + [LOAD_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.revision, + }, + [SAVE_COLLECTION_PERMISSIONS]: { + next: (_state, { payload }) => payload.revision, + }, }, - true, + null, ); export default combineReducers({ - save, - load, - - permissions, - originalPermissions, - saveError, - revision, - - propagatePermissions, + dataPermissions, + originalDataPermissions, + dataPermissionsRevision, + collectionPermissions, + originalCollectionPermissions, + collectionPermissionsRevision, }); diff --git a/frontend/src/metabase/admin/permissions/routes.jsx b/frontend/src/metabase/admin/permissions/routes.jsx index 9c06f6a7626da..bec9326a36d7b 100644 --- a/frontend/src/metabase/admin/permissions/routes.jsx +++ b/frontend/src/metabase/admin/permissions/routes.jsx @@ -1,57 +1,43 @@ -/* eslint-disable react/prop-types */ import React from "react"; import { Route } from "metabase/hoc/Title"; -import { IndexRedirect, IndexRoute } from "react-router"; +import { IndexRedirect } from "react-router"; import { t } from "ttag"; -import DataPermissionsApp from "./containers/DataPermissionsApp"; -import DatabasesPermissionsApp from "./containers/DatabasesPermissionsApp"; -import SchemasPermissionsApp from "./containers/SchemasPermissionsApp"; -import TablesPermissionsApp from "./containers/TablesPermissionsApp"; -import CollectionPermissions from "./containers/CollectionsPermissionsApp"; -import { PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES } from "metabase/plugins"; +import CollectionPermissionsPage from "./pages/CollectionPermissionsPage/CollectionPermissionsPage"; +import DatabasesPermissionsPage from "./pages/DatabasePermissionsPage/DatabasesPermissionsPage"; +import GroupsPermissionsPage from "./pages/GroupDataPermissionsPage/GroupsPermissionsPage"; +import DataPermissionsPage from "./pages/DataPermissionsPage/DataPermissionsPage"; +import { + PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES, + PLUGIN_ADMIN_PERMISSIONS_TABLE_GROUP_ROUTES, +} from "metabase/plugins"; -const getRoutes = store => ( +const getRoutes = () => ( - + - {/* "DATABASES" a.k.a. "data" section */} - - {/* DATABASES */} - + + - {/* SCHEMAS */} - - - {/* TABLES */} - {PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES} + {PLUGIN_ADMIN_PERMISSIONS_TABLE_GROUP_ROUTES} - {/* TABLES NO SCHEMA */} - {/* NOTE: this route is to support null schemas */} ( - - )} + path="group(/:groupId)(/database/:databaseId)(/schema/:schemaName)" + component={GroupsPermissionsPage} > {PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES} - {/* "COLLECTIONS" section */} - - - + ); diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js deleted file mode 100644 index 34ea0a42622dc..0000000000000 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ /dev/null @@ -1,936 +0,0 @@ -import { createSelector } from "reselect"; -import { push } from "react-router-redux"; - -import TogglePropagateAction from "./containers/TogglePropagateAction"; - -import MetabaseAnalytics from "metabase/lib/analytics"; -import { color, alpha } from "metabase/lib/colors"; - -import { - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS, - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS, - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION, -} from "metabase/plugins"; - -import { t } from "ttag"; - -import _ from "underscore"; -import { getIn, assocIn } from "icepick"; - -import { - getNativePermission, - getSchemasPermission, - getTablesPermission, - getFieldsPermission, - updateFieldsPermission, - updateTablesPermission, - updateSchemasPermission, - updateNativePermission, - diffPermissions, - inferAndUpdateEntityPermissions, -} from "metabase/lib/permissions"; -import { - isDefaultGroup, - isAdminGroup, - isMetaBotGroup, - canEditPermissions, -} from "metabase/lib/groups"; - -import Group from "metabase/entities/groups"; - -import { getMetadata } from "metabase/selectors/metadata"; - -import Metadata from "metabase-lib/lib/metadata/Metadata"; -import type { DatabaseId } from "metabase-types/types/Database"; -import type { SchemaName } from "metabase-types/types/Table"; -import type { - Group as GroupType, - GroupsPermissions, -} from "metabase-types/types/Permissions"; - -const getPermissions = state => state.admin.permissions.permissions; -const getOriginalPermissions = state => - state.admin.permissions.originalPermissions; - -const getDatabaseId = (state, props) => - props.params.databaseId ? parseInt(props.params.databaseId) : null; -const getSchemaName = (state, props) => props.params.schemaName; - -// reorder groups to be in this order -const SPECIAL_GROUP_FILTERS = [ - isAdminGroup, - isDefaultGroup, - isMetaBotGroup, -].reverse(); - -function getTooltipForGroup(group) { - if (isAdminGroup(group)) { - return t`Administrators always have the highest level of access to everything in Metabase.`; - } else if (isDefaultGroup(group)) { - return t`Every Metabase user belongs to the All Users group. If you want to limit or restrict a group's access to something, make sure the All Users group has an equal or lower level of access.`; - } else if (isMetaBotGroup(group)) { - return t`MetaBot is Metabase's Slack bot. You can choose what it has access to here.`; - } - return null; -} - -export const getGroups = createSelector( - [Group.selectors.getList], - groups => { - const orderedGroups = groups ? [...groups] : []; - for (const groupFilter of SPECIAL_GROUP_FILTERS) { - const index = _.findIndex(orderedGroups, groupFilter); - if (index >= 0) { - orderedGroups.unshift(...orderedGroups.splice(index, 1)); - } - } - return orderedGroups.map(group => ({ - ...group, - editable: canEditPermissions(group), - tooltip: getTooltipForGroup(group), - })); - }, -); - -export const getIsDirty = createSelector( - getPermissions, - getOriginalPermissions, - (permissions, originalPermissions) => - JSON.stringify(permissions) !== JSON.stringify(originalPermissions), -); - -export const getSaveError = state => state.admin.permissions.saveError; - -// these are all the permission levels ordered by level of access -const PERM_LEVELS = ["write", "read", "all", "controlled", "none"]; -function hasGreaterPermissions(a, b) { - return PERM_LEVELS.indexOf(a) - PERM_LEVELS.indexOf(b) < 0; -} - -function getPermissionWarning( - getter, - entityType, - defaultGroup, - permissions, - groupId, - entityId, - value, -) { - if (!defaultGroup || groupId === defaultGroup.id) { - return null; - } - const perm = value || getter(permissions, groupId, entityId); - const defaultPerm = getter(permissions, defaultGroup.id, entityId); - if (perm === "controlled" && defaultPerm === "controlled") { - return t`The "${defaultGroup.name}" group may have access to a different set of ${entityType} than this group, which may give this group additional access to some ${entityType}.`; - } - if (hasGreaterPermissions(defaultPerm, perm)) { - return t`The "${defaultGroup.name}" group has a higher level of access than this, which will override this setting. You should limit or revoke the "${defaultGroup.name}" group's access to this item.`; - } - return null; -} - -function getPermissionWarningModal( - entityType, - getter, - defaultGroup, - permissions, - groupId, - entityId, - value, -) { - const permissionWarning = getPermissionWarning( - entityType, - getter, - defaultGroup, - permissions, - groupId, - entityId, - value, - ); - if (permissionWarning) { - return { - title: - (value === "controlled" ? t`Limit` : t`Revoke`) + - " " + - t`access even though "${defaultGroup.name}" has greater access?`, - message: permissionWarning, - confirmButtonText: - value === "controlled" ? t`Limit access` : t`Revoke access`, - cancelButtonText: t`Cancel`, - }; - } -} - -function getControlledDatabaseWarningModal(permissions, groupId, entityId) { - if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") { - return { - title: t`Change access to this database to limited?`, - confirmButtonText: t`Change`, - cancelButtonText: t`Cancel`, - }; - } -} - -function getRawQueryWarningModal(permissions, groupId, entityId, value) { - if ( - value === "write" && - getNativePermission(permissions, groupId, entityId) !== "write" && - getSchemasPermission(permissions, groupId, entityId) !== "all" - ) { - return { - title: t`Allow Raw Query Writing?`, - message: t`This will also change this group's data access to Unrestricted for this database.`, - confirmButtonText: t`Allow`, - cancelButtonText: t`Cancel`, - }; - } -} - -// If the user is revoking an access to every single table of a database for a specific user group, -// warn the user that the access to raw queries will be revoked as well. -// This warning will only be shown if the user is editing the permissions of individual tables. -function getRevokingAccessToAllTablesWarningModal( - database, - permissions, - groupId, - entityId, - value, -) { - if ( - value === "none" && - getSchemasPermission(permissions, groupId, entityId) === "controlled" && - getNativePermission(permissions, groupId, entityId) !== "none" - ) { - // allTableEntityIds contains tables from all schemas - const allTableEntityIds = database.tables.map(table => ({ - databaseId: table.db_id, - schemaName: table.schema_name || "", - tableId: table.id, - })); - - // Show the warning only if user tries to revoke access to the very last table of all schemas - const afterChangesNoAccessToAnyTable = _.every( - allTableEntityIds, - id => - getFieldsPermission(permissions, groupId, id) === "none" || - _.isEqual(id, entityId), - ); - if (afterChangesNoAccessToAnyTable) { - return { - title: t`Revoke access to all tables?`, - message: t`This will also revoke this group's access to raw queries for this database.`, - confirmButtonText: t`Revoke access`, - cancelButtonText: t`Cancel`, - }; - } - } -} - -const BG_ALPHA = 0.15; - -const OPTION_GREEN = { - icon: "check", - iconColor: color("success"), - bgColor: alpha(color("success"), BG_ALPHA), -}; -const OPTION_YELLOW = { - icon: "eye", - iconColor: color("warning"), - bgColor: alpha(color("warning"), BG_ALPHA), -}; -const OPTION_RED = { - icon: "close", - iconColor: color("error"), - bgColor: alpha(color("error"), BG_ALPHA), -}; - -const OPTION_ALL = { - ...OPTION_GREEN, - value: "all", - title: t`Grant unrestricted access`, - tooltip: t`Unrestricted access`, -}; - -const OPTION_CONTROLLED = { - ...OPTION_YELLOW, - value: "controlled", - title: t`Limit access`, - tooltip: t`Limited access`, - icon: "permissions_limited", -}; - -const OPTION_NONE = { - ...OPTION_RED, - value: "none", - title: t`Revoke access`, - tooltip: t`No access`, -}; - -const OPTION_NATIVE_WRITE = { - ...OPTION_GREEN, - value: "write", - title: t`Write raw queries`, - tooltip: t`Can write raw queries`, - icon: "sql", -}; - -const OPTION_COLLECTION_WRITE = { - ...OPTION_GREEN, - value: "write", - title: t`Curate collection`, - tooltip: t`Can edit this collection and its contents`, -}; - -const OPTION_COLLECTION_READ = { - ...OPTION_YELLOW, - value: "read", - title: t`View collection`, - tooltip: t`Can view items in this collection`, -}; - -const OPTION_SNIPPET_COLLECTION_WRITE = { - ...OPTION_COLLECTION_WRITE, - title: t`Grant Edit access`, - tooltip: t`Can modify snippets in this folder`, -}; - -const OPTION_SNIPPET_COLLECTION_READ = { - ...OPTION_COLLECTION_READ, - title: t`Grant View access`, - tooltip: t`Can insert and use snippets in this folder, but can't edit the SQL they contain`, -}; - -const OPTION_SNIPPET_COLLECTION_NONE = { - ...OPTION_NONE, - title: t`Revoke access`, - tooltip: t`Can't view or insert snippets in this folder`, -}; - -export const getTablesPermissionsGrid = createSelector( - getMetadata, - getGroups, - getPermissions, - getDatabaseId, - getSchemaName, - ( - metadata: Metadata, - groups: Array, - permissions: GroupsPermissions, - databaseId: DatabaseId, - schemaName: SchemaName, - ) => { - const database = metadata.database(databaseId); - if (!groups || !permissions || !database) { - return null; - } - - const schema = database.schema(schemaName); - const tables = schema && schema.tables; - - if (_.isEmpty(tables)) { - return null; - } - - const defaultGroup = _.find(groups, isDefaultGroup); - - return { - type: "table", - icon: "table", - crumbs: - database.schemaNames().length > 1 - ? [ - [t`Databases`, "/admin/permissions/databases"], - [ - database.name, - "/admin/permissions/databases/" + database.id + "/schemas", - ], - [schemaName], - ] - : [[t`Databases`, "/admin/permissions/databases"], [database.name]], - groups, - permissions: { - fields: { - header: t`Data Access`, - options(groupId, entityId) { - return [ - OPTION_ALL, - ...PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS, - OPTION_NONE, - ]; - }, - actions(groupId, entityId) { - const value = getFieldsPermission(permissions, groupId, entityId); - const getActions = - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS[value] || []; - return getActions.map(getAction => getAction(groupId, entityId)); - }, - postAction(groupId, entityId, value) { - const getPostAction = - PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION[value]; - return getPostAction && getPostAction(groupId, entityId); - }, - getter(groupId, entityId) { - return getFieldsPermission(permissions, groupId, entityId); - }, - updater(groupId, entityId, value) { - MetabaseAnalytics.trackEvent("Permissions", "fields", value); - const updatedPermissions = updateFieldsPermission( - permissions, - groupId, - entityId, - value, - metadata, - ); - return inferAndUpdateEntityPermissions( - updatedPermissions, - groupId, - entityId, - metadata, - ); - }, - confirm(groupId, entityId, value) { - return [ - getPermissionWarningModal( - getFieldsPermission, - "fields", - defaultGroup, - permissions, - groupId, - entityId, - value, - ), - getControlledDatabaseWarningModal(permissions, groupId, entityId), - getRevokingAccessToAllTablesWarningModal( - database, - permissions, - groupId, - entityId, - value, - ), - ]; - }, - warning(groupId, entityId) { - return getPermissionWarning( - getFieldsPermission, - "fields", - defaultGroup, - permissions, - groupId, - entityId, - ); - }, - }, - }, - entities: tables.map(table => ({ - id: { - databaseId: databaseId, - schemaName: schemaName, - tableId: table.id, - }, - name: table.display_name, - subtitle: table.name, - })), - }; - }, -); - -export const getSchemasPermissionsGrid = createSelector( - getMetadata, - getGroups, - getPermissions, - getDatabaseId, - ( - metadata: Metadata, - groups: Array, - permissions: GroupsPermissions, - databaseId: DatabaseId, - ) => { - const database = metadata.database(databaseId); - - if (!groups || !permissions || !database) { - return null; - } - - const schemaNames = database.schemaNames(); - const defaultGroup = _.find(groups, isDefaultGroup); - - return { - type: "schema", - icon: "folder", - crumbs: [[t`Databases`, "/admin/permissions/databases"], [database.name]], - groups, - permissions: { - tables: { - header: t`Data Access`, - options(groupId, entityId) { - return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE]; - }, - getter(groupId, entityId) { - return getTablesPermission(permissions, groupId, entityId); - }, - updater(groupId, entityId, value) { - MetabaseAnalytics.trackEvent("Permissions", "tables", value); - const updatedPermissions = updateTablesPermission( - permissions, - groupId, - entityId, - value, - metadata, - ); - return inferAndUpdateEntityPermissions( - updatedPermissions, - groupId, - entityId, - metadata, - ); - }, - postAction(groupId, { databaseId, schemaName }, value) { - if (value === "controlled") { - return push( - `/admin/permissions/databases/${databaseId}/schemas/${encodeURIComponent( - schemaName, - )}/tables`, - ); - } - }, - confirm(groupId, entityId, value) { - return [ - getPermissionWarningModal( - getTablesPermission, - "tables", - defaultGroup, - permissions, - groupId, - entityId, - value, - ), - getControlledDatabaseWarningModal(permissions, groupId, entityId), - ]; - }, - warning(groupId, entityId) { - return getPermissionWarning( - getTablesPermission, - "tables", - defaultGroup, - permissions, - groupId, - entityId, - ); - }, - }, - }, - entities: schemaNames.map(schemaName => ({ - id: { - databaseId, - schemaName, - }, - name: schemaName, - link: { - name: t`View tables`, - url: `/admin/permissions/databases/${databaseId}/schemas/${encodeURIComponent( - schemaName, - )}/tables`, - }, - })), - }; - }, -); - -export function getDatabaseTablesOrSchemasPath(database) { - const schemas = database ? database.schemaNames() : []; - - return ( - "/admin/permissions/databases/" + - // schema-less db - (schemas.length === 1 && schemas[0] === null - ? `${database.id}/tables` - : // single schema, auto-select it - schemas.length === 1 - ? `${database.id}/schemas/${schemas[0]}/tables` - : // zero or multiple schemas so list them out - `${database.id}/schemas`) - ); -} - -export const getDatabasesPermissionsGrid = createSelector( - getMetadata, - getGroups, - getPermissions, - ( - metadata: Metadata, - groups: Array, - permissions: GroupsPermissions, - ) => { - if (!groups || !permissions || !metadata) { - return null; - } - - const databases = metadata.databasesList({ savedQuestions: false }); - const defaultGroup = _.find(groups, isDefaultGroup); - - return { - type: "database", - icon: "database", - groups, - permissions: { - schemas: { - header: t`Data Access`, - options(groupId, entityId) { - return [OPTION_ALL, OPTION_CONTROLLED, OPTION_NONE]; - }, - getter(groupId, entityId) { - return getSchemasPermission(permissions, groupId, entityId); - }, - updater(groupId, entityId, value) { - MetabaseAnalytics.trackEvent("Permissions", "schemas", value); - return updateSchemasPermission( - permissions, - groupId, - entityId, - value, - metadata, - ); - }, - postAction(groupId, { databaseId }, value) { - if (value === "controlled") { - const database = metadata.database(databaseId); - return push(getDatabaseTablesOrSchemasPath(database)); - } - }, - confirm(groupId, entityId, value) { - return [ - getPermissionWarningModal( - getSchemasPermission, - "schemas", - defaultGroup, - permissions, - groupId, - entityId, - value, - ), - ]; - }, - warning(groupId, entityId) { - return getPermissionWarning( - getSchemasPermission, - "schemas", - defaultGroup, - permissions, - groupId, - entityId, - ); - }, - }, - native: { - header: t`SQL Queries`, - options(groupId, entityId) { - if ( - getSchemasPermission(permissions, groupId, entityId) === "none" - ) { - return [OPTION_NONE]; - } else { - return [OPTION_NATIVE_WRITE, OPTION_NONE]; - } - }, - getter(groupId, entityId) { - return getNativePermission(permissions, groupId, entityId); - }, - updater(groupId, entityId, value) { - MetabaseAnalytics.trackEvent("Permissions", "native", value); - return updateNativePermission( - permissions, - groupId, - entityId, - value, - metadata, - ); - }, - confirm(groupId, entityId, value) { - return [ - getPermissionWarningModal( - getNativePermission, - null, - defaultGroup, - permissions, - groupId, - entityId, - value, - ), - getRawQueryWarningModal(permissions, groupId, entityId, value), - ]; - }, - warning(groupId, entityId) { - return getPermissionWarning( - getNativePermission, - null, - defaultGroup, - permissions, - groupId, - entityId, - ); - }, - }, - }, - entities: databases.map(database => { - const schemas = database.schemaNames(); - return { - id: { - databaseId: database.id, - }, - name: database.name, - link: - schemas.length === 0 || (schemas.length === 1 && schemas[0] == null) - ? { - name: t`View tables`, - url: `/admin/permissions/databases/${database.id}/tables`, - } - : schemas.length === 1 - ? { - name: t`View tables`, - url: `/admin/permissions/databases/${database.id}/schemas/${ - schemas[0] - }/tables`, - } - : { - name: t`View schemas`, - url: `/admin/permissions/databases/${database.id}/schemas`, - }, - }; - }), - }; - }, -); - -import Collections from "metabase/entities/collections"; -import SnippetCollections from "metabase/entities/snippet-collections"; - -const getCollectionId = (state, props) => props && props.collectionId; - -const getSingleCollectionPermissionsMode = (state, props) => - (props && props.singleCollectionMode) || false; - -const permissionsCollectionFilter = collection => !collection.is_personal; - -const getNamespace = (state, props) => props.namespace; - -const getExpandedCollectionsById = (state, props) => - (props.namespace === "snippets" - ? SnippetCollections - : Collections - ).selectors.getExpandedCollectionsById(state, props); - -const getCollections = createSelector( - [ - getExpandedCollectionsById, - getCollectionId, - getSingleCollectionPermissionsMode, - ], - (collectionsById, collectionId, singleMode) => { - if (collectionId && collectionsById[collectionId]) { - if (singleMode) { - // pass the `singleCollectionMode=true` prop when we just want to show permissions for the provided collection, and not it's subcollections - return [collectionsById[collectionId]]; - } else { - return collectionsById[collectionId].children.filter( - permissionsCollectionFilter, - ); - } - // default to root collection - } else if (collectionsById["root"]) { - return [collectionsById["root"]]; - } else { - return null; - } - }, -); -const getCollectionPermission = (permissions, groupId, { collectionId }) => - getIn(permissions, [groupId, collectionId]); - -export const getPropagatePermissions = state => - state.admin.permissions.propagatePermissions; - -export const getCollectionsPermissionsGrid = createSelector( - getCollections, - getGroups, - getPermissions, - getPropagatePermissions, - getNamespace, - ( - collections, - groups: Array, - permissions: GroupsPermissions, - propagatePermissions: boolean, - namespace: string, - ) => { - if (!groups || groups.length === 0 || !permissions || !collections) { - return null; - } - - const crumbs = []; - let parent = collections[0] && collections[0].parent; - if (parent) { - while (parent) { - if (crumbs.length > 0) { - crumbs.unshift([ - parent.name, - `/admin/permissions/collections/${parent.id}`, - ]); - } else { - crumbs.unshift([parent.name]); - } - parent = parent.parent; - } - crumbs.unshift([t`Collections`, "/admin/permissions/collections"]); - } - - const defaultGroup = _.find(groups, isDefaultGroup); - - return { - type: "collection", - icon: "folder", - crumbs, - groups, - permissions: { - access: { - header: t`Collection Access`, - options(groupId, entityId) { - return namespace === "snippets" - ? [ - OPTION_SNIPPET_COLLECTION_WRITE, - OPTION_SNIPPET_COLLECTION_READ, - OPTION_SNIPPET_COLLECTION_NONE, - ] - : [OPTION_COLLECTION_WRITE, OPTION_COLLECTION_READ, OPTION_NONE]; - }, - actions(groupId, { collectionId }) { - const collection = _.findWhere(collections, { - id: collectionId, - }); - if (collection && collection.children.length > 0) { - return [ - () => - TogglePropagateAction({ - message: - namespace === "snippets" - ? t`Also change sub-folders` - : t`Also change sub-collections`, - }), - ]; - } else { - return []; - } - }, - getter(groupId, entityId) { - return getCollectionPermission(permissions, groupId, entityId); - }, - updater(groupId, { collectionId }, value) { - let newPermissions = assocIn( - permissions, - [groupId, collectionId], - value, - ); - if (propagatePermissions) { - const collection = _.findWhere(collections, { - id: collectionId, - }); - for (const descendent of getDecendentCollections(collection)) { - newPermissions = assocIn( - newPermissions, - [groupId, descendent.id], - value, - ); - } - } - return newPermissions; - }, - confirm(groupId, entityId, value) { - return [ - getPermissionWarningModal( - getCollectionPermission, - null, - defaultGroup, - permissions, - groupId, - entityId, - value, - ), - ]; - }, - warning(groupId, entityId) { - const collection = _.findWhere(collections, { - id: entityId.collectionId, - }); - if (!collection) { - return; - } - const collectionPerm = getCollectionPermission( - permissions, - groupId, - entityId, - ); - const descendentCollections = getDecendentCollections(collection); - const descendentPerms = getPermissionsSet( - descendentCollections, - permissions, - groupId, - ); - if ( - collectionPerm === "none" && - (descendentPerms.has("read") || descendentPerms.has("write")) - ) { - return t`This group has permission to view at least one subcollection of this collection.`; - } else if ( - collectionPerm === "read" && - descendentPerms.has("write") - ) { - return t`This group has permission to edit at least one subcollection of this collection.`; - } - }, - }, - }, - entities: collections.map(collection => { - return { - id: { - collectionId: collection.id, - }, - name: collection.name, - link: collection.children && - collection.children.length > 0 && { - name: t`View sub-collections`, - url: `/admin/permissions/collections/${collection.id}`, - }, - }; - }), - }; - }, -); - -function getDecendentCollections(collection) { - const subCollections = collection.children.filter( - permissionsCollectionFilter, - ); - return subCollections.concat(...subCollections.map(getDecendentCollections)); -} - -function getPermissionsSet(collections, permissions, groupId) { - const perms = collections.map(collection => - getCollectionPermission(permissions, groupId, { - collectionId: collection.id, - }), - ); - return new Set(perms); -} - -export const getDiff = createSelector( - getMetadata, - getGroups, - getPermissions, - getOriginalPermissions, - ( - metadata: Metadata, - groups: Array, - permissions: GroupsPermissions, - originalPermissions: GroupsPermissions, - ) => diffPermissions(permissions, originalPermissions, groups, metadata), -); diff --git a/frontend/src/metabase/admin/permissions/selectors/collection-permissions.js b/frontend/src/metabase/admin/permissions/selectors/collection-permissions.js new file mode 100644 index 0000000000000..3db1657865136 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/selectors/collection-permissions.js @@ -0,0 +1,261 @@ +import { createSelector } from "reselect"; +import { t } from "ttag"; +import { getIn } from "icepick"; +import _ from "underscore"; + +import Group from "metabase/entities/groups"; +import { diffPermissions } from "metabase/lib/permissions"; +import Collections, { + getCollectionIcon, + ROOT_COLLECTION, +} from "metabase/entities/collections"; +import SnippetCollections from "metabase/entities/snippet-collections"; +import { nonPersonalOrArchivedCollection } from "metabase/collections/utils"; +import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups"; + +import { COLLECTION_OPTIONS } from "../constants/collections-permissions"; +import { UNABLE_TO_CHANGE_ADMIN_PERMISSIONS } from "../constants/messages"; +import { getPermissionWarningModal } from "./confirmations"; + +export const getIsDirty = createSelector( + state => state.admin.permissions.collectionPermissions, + state => state.admin.permissions.originalCollectionPermissions, + (permissions, originalPermissions) => + JSON.stringify(permissions) !== JSON.stringify(originalPermissions), +); + +export const getDiff = createSelector( + Group.selectors.getList, + state => state.admin.permissions.collectionPermissions, + state => state.admin.permissions.originalCollectionPermissions, + (groups, permissions, originalPermissions) => + diffPermissions(permissions, originalPermissions, groups), +); + +export const getCurrentCollectionId = (_state, props) => + props.params.collectionId === ROOT_COLLECTION.id + ? ROOT_COLLECTION.id + : parseInt(props.params.collectionId); + +const getRootCollectionTreeItem = () => { + const rootCollectionIcon = getCollectionIcon(ROOT_COLLECTION); + return { + ...ROOT_COLLECTION, + icon: rootCollectionIcon.name, + iconColor: rootCollectionIcon.color, + }; +}; + +const getCollectionsTree = (state, _props) => { + const collections = + Collections.selectors.getList(state, { + entityQuery: { tree: true }, + }) || []; + const nonPersonalCollections = collections.filter( + nonPersonalOrArchivedCollection, + ); + + return [ + getRootCollectionTreeItem(), + ...buildCollectionTree(nonPersonalCollections), + ]; +}; + +export function buildCollectionTree(collections) { + if (collections == null) { + return []; + } + return collections.map(collection => { + const icon = getCollectionIcon(collection); + return { + id: collection.id, + name: collection.name, + icon: icon.name, + iconColor: icon.color, + children: buildCollectionTree(collection.children), + }; + }); +} + +export const getCollectionsSidebar = createSelector( + getCollectionsTree, + getCurrentCollectionId, + (collectionsTree, collectionId) => { + return { + selectedId: collectionId, + title: t`Collections`, + entityGroups: [collectionsTree || []], + filterPlaceholder: t`Search for a collection`, + }; + }, +); + +const getCollectionsPermissions = state => + state.admin.permissions.collectionPermissions; + +const findCollection = (collections, collectionId) => { + if (collections.length === 0) { + return null; + } + + const collection = collections.find( + collection => collection.id === collectionId, + ); + + if (collection) { + return collection; + } + + return findCollection( + collections.map(collection => collection.children).flat(), + collectionId, + ); +}; + +const getCollection = (state, props) => { + const collectionId = getCurrentCollectionId(state, props); + const collections = Collections.selectors.getList(state, { + entityQuery: { tree: true }, + }); + + if (collectionId === ROOT_COLLECTION.id) { + return { + ...ROOT_COLLECTION, + children: collections, + }; + } + + return findCollection(collections, collectionId); +}; + +const getFolder = (state, props) => { + const folderId = getCurrentCollectionId(state, props); + const folders = SnippetCollections.selectors.getList(state); + + return folders.find(folder => folder.id === folderId); +}; + +export const getCollectionEntity = (state, props) => { + return props.namespace === "snippets" + ? getFolder(state, props) + : getCollection(state, props); +}; + +const getCollectionPermission = (permissions, groupId, collectionId) => + getIn(permissions, [groupId, collectionId]); + +const getNamespace = (_state, props) => props.namespace; + +const getToggleLabel = namespace => + namespace === "snippets" + ? t`Also change sub-folders` + : t`Also change sub-collections`; + +export const getCollectionsPermissionEditor = createSelector( + getCollectionsPermissions, + getCollectionEntity, + Group.selectors.getList, + getNamespace, + (permissions, collection, groups, namespace) => { + if (!permissions || collection == null) { + return null; + } + + const hasChildren = collection.children?.length > 0; + const toggleLabel = hasChildren ? getToggleLabel(namespace) : null; + const defaultGroup = _.find(groups, isDefaultGroup); + + const entities = groups.map(group => { + const isAdmin = isAdminGroup(group); + + const defaultGroupPermission = getCollectionPermission( + permissions, + defaultGroup.id, + collection.id, + ); + + const confirmations = newValue => [ + getPermissionWarningModal( + newValue, + defaultGroupPermission, + null, + defaultGroup, + group.id, + ), + ]; + + return { + id: group.id, + name: group.name, + permissions: [ + { + toggleLabel, + isDisabled: isAdmin, + disabledTooltip: isAdmin + ? UNABLE_TO_CHANGE_ADMIN_PERMISSIONS + : null, + value: getCollectionPermission( + permissions, + group.id, + collection.id, + ), + warning: getCollectionWarning(group.id, collection, permissions), + confirmations, + options: [ + COLLECTION_OPTIONS.write, + COLLECTION_OPTIONS.read, + COLLECTION_OPTIONS.none, + ], + }, + ], + }; + }); + + return { + title: t`Permissions for ${collection.name}`, + filterPlaceholder: t`Search for a group`, + columns: [`Group name`, t`Collection access`], + entities, + }; + }, +); + +const permissionsCollectionFilter = collection => !collection.is_personal; + +function getDecendentCollections(collection) { + const subCollections = + collection.children?.filter(permissionsCollectionFilter) || []; + return subCollections.concat(...subCollections.map(getDecendentCollections)); +} + +function getPermissionsSet(collections, permissions, groupId) { + const perms = collections.map(collection => + getCollectionPermission(permissions, groupId, collection.id), + ); + return new Set(perms); +} + +function getCollectionWarning(groupId, collection, permissions) { + if (!collection) { + return; + } + const collectionPerm = getCollectionPermission( + permissions, + groupId, + collection.id, + ); + const descendentCollections = getDecendentCollections(collection); + const descendentPerms = getPermissionsSet( + descendentCollections, + permissions, + groupId, + ); + if ( + collectionPerm === "none" && + (descendentPerms.has("read") || descendentPerms.has("write")) + ) { + return t`This group has permission to view at least one subcollection of this collection.`; + } else if (collectionPerm === "read" && descendentPerms.has("write")) { + return t`This group has permission to edit at least one subcollection of this collection.`; + } +} diff --git a/frontend/src/metabase/admin/permissions/selectors/confirmations.js b/frontend/src/metabase/admin/permissions/selectors/confirmations.js new file mode 100644 index 0000000000000..fe23007e668dc --- /dev/null +++ b/frontend/src/metabase/admin/permissions/selectors/confirmations.js @@ -0,0 +1,131 @@ +import { t } from "ttag"; +import _ from "underscore"; + +import { + getFieldsPermission, + getNativePermission, + getSchemasPermission, +} from "metabase/lib/permissions"; + +// these are all the permission levels ordered by level of access +const PERM_LEVELS = ["write", "read", "all", "controlled", "none"]; +function hasGreaterPermissions(a, b) { + return PERM_LEVELS.indexOf(a) - PERM_LEVELS.indexOf(b) < 0; +} + +export function getPermissionWarning( + value, + defaultGroupValue, + entityType, + defaultGroup, + groupId, +) { + if (!defaultGroup || groupId === defaultGroup.id) { + return null; + } + + if (value === "controlled" && defaultGroupValue === "controlled") { + return t`The "${defaultGroup.name}" group may have access to a different set of ${entityType} than this group, which may give this group additional access to some ${entityType}.`; + } + if (hasGreaterPermissions(defaultGroupValue, value)) { + return t`The "${defaultGroup.name}" group has a higher level of access than this, which will override this setting. You should limit or revoke the "${defaultGroup.name}" group's access to this item.`; + } + return null; +} + +export function getPermissionWarningModal( + value, + defaultGroupValue, + entityType, + defaultGroup, + groupId, +) { + const permissionWarning = getPermissionWarning( + value, + defaultGroupValue, + entityType, + defaultGroup, + groupId, + ); + if (permissionWarning) { + return { + title: + (value === "controlled" ? t`Limit` : t`Revoke`) + + " " + + t`access even though "${defaultGroup.name}" has greater access?`, + message: permissionWarning, + confirmButtonText: + value === "controlled" ? t`Limit access` : t`Revoke access`, + cancelButtonText: t`Cancel`, + }; + } +} + +export function getControlledDatabaseWarningModal( + permissions, + groupId, + entityId, +) { + if (getSchemasPermission(permissions, groupId, entityId) !== "controlled") { + return { + title: t`Change access to this database to limited?`, + confirmButtonText: t`Change`, + cancelButtonText: t`Cancel`, + }; + } +} + +export function getRawQueryWarningModal(permissions, groupId, entityId, value) { + if ( + value === "write" && + getNativePermission(permissions, groupId, entityId) !== "write" && + getSchemasPermission(permissions, groupId, entityId) !== "all" + ) { + return { + title: t`Allow Raw Query Writing?`, + message: t`This will also change this group's data access to Unrestricted for this database.`, + confirmButtonText: t`Allow`, + cancelButtonText: t`Cancel`, + }; + } +} + +// If the user is revoking an access to every single table of a database for a specific user group, +// warn the user that the access to raw queries will be revoked as well. +// This warning will only be shown if the user is editing the permissions of individual tables. +export function getRevokingAccessToAllTablesWarningModal( + database, + permissions, + groupId, + entityId, + value, +) { + if ( + value === "none" && + getSchemasPermission(permissions, groupId, entityId) === "controlled" && + getNativePermission(permissions, groupId, entityId) !== "none" + ) { + // allTableEntityIds contains tables from all schemas + const allTableEntityIds = database.tables.map(table => ({ + databaseId: table.db_id, + schemaName: table.schema_name || "", + tableId: table.id, + })); + + // Show the warning only if user tries to revoke access to the very last table of all schemas + const afterChangesNoAccessToAnyTable = _.every( + allTableEntityIds, + id => + getFieldsPermission(permissions, groupId, id) === "none" || + _.isEqual(id, entityId), + ); + if (afterChangesNoAccessToAnyTable) { + return { + title: t`Revoke access to all tables?`, + message: t`This will also revoke this group's access to raw queries for this database.`, + confirmButtonText: t`Revoke access`, + cancelButtonText: t`Cancel`, + }; + } + } +} diff --git a/frontend/src/metabase/admin/permissions/selectors/data-permissions.js b/frontend/src/metabase/admin/permissions/selectors/data-permissions.js new file mode 100644 index 0000000000000..6f10f1bf3fdbb --- /dev/null +++ b/frontend/src/metabase/admin/permissions/selectors/data-permissions.js @@ -0,0 +1,669 @@ +import { createSelector } from "reselect"; +import { msgid, ngettext, t } from "ttag"; +import _ from "underscore"; +import { push } from "react-router-redux"; + +import { getMetadata } from "metabase/selectors/metadata"; + +import Group from "metabase/entities/groups"; +import { + isAdminGroup, + isDefaultGroup, + isMetaBotGroup, +} from "metabase/lib/groups"; +import { DATA_PERMISSION_OPTIONS } from "../constants/data-permissions"; +import { + getFieldsPermission, + getNativePermission, + getSchemasPermission, + getTablesPermission, + diffPermissions, +} from "metabase/lib/permissions"; +import { + DATA_ACCESS_IS_REQUIRED, + UNABLE_TO_CHANGE_ADMIN_PERMISSIONS, +} from "../constants/messages"; +import { + PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS, + PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS, + PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION, +} from "metabase/plugins"; +import { + getPermissionWarning, + getPermissionWarningModal, + getControlledDatabaseWarningModal, + getRawQueryWarningModal, + getRevokingAccessToAllTablesWarningModal, +} from "./confirmations"; + +export const getIsDirty = createSelector( + state => state.admin.permissions.dataPermissions, + state => state.admin.permissions.originalDataPermissions, + (permissions, originalPermissions) => + JSON.stringify(permissions) !== JSON.stringify(originalPermissions), +); + +export const getDiff = createSelector( + getMetadata, + Group.selectors.getList, + state => state.admin.permissions.dataPermissions, + state => state.admin.permissions.originalDataPermissions, + (metadata, groups, permissions, originalPermissions) => + diffPermissions(permissions, originalPermissions, groups, metadata), +); + +const getRouteParams = (_state, props) => { + const { databaseId, schemaName, tableId } = props.params; + return { + databaseId, + schemaName, + tableId, + }; +}; + +const getEntitySwitch = value => ({ + value, + options: [ + { + name: t`Groups`, + value: "group", + }, + { + name: t`Databases`, + value: "database", + }, + ], +}); + +const getSchemaId = name => `schema:${name}`; +const getTableId = id => `table:${id}`; + +export const getDatabasesSidebar = createSelector( + getMetadata, + getRouteParams, + (metadata, params) => { + const { databaseId, schemaName, tableId } = params; + + if (databaseId == null) { + const entities = Object.values(metadata.databases).map(database => ({ + ...database, + icon: "database", + type: "database", + })); + + return { + entityGroups: [entities], + entitySwitch: getEntitySwitch("database"), + filterPlaceholder: t`Search for a database`, + }; + } + + const database = metadata.databases[databaseId]; + + let selectedId = null; + + if (tableId != null) { + selectedId = getTableId(tableId); + } else if (schemaName != null) { + selectedId = getSchemaId(schemaName); + } + + let entities = database.schemas.map(schema => { + return { + id: getSchemaId(schema.name), + name: schema.name, + databaseId: databaseId, + type: "schema", + children: schema.tables.map(table => ({ + id: getTableId(table.id), + originalId: table.id, + name: table.name, + schemaName: schema.name, + databaseId: databaseId, + type: "table", + })), + }; + }); + + const shouldIncludeSchemas = database.schemas.length > 1; + if (!shouldIncludeSchemas) { + entities = entities[0].children; + } + + return { + selectedId, + title: database.name, + description: t`Select a table to set more specific permissions`, + entityGroups: [entities], + filterPlaceholder: t`Search for a table`, + }; + }, +); + +const getBreadcrumbs = (params, metadata) => { + const { databaseId, schemaName, tableId } = params; + + if (databaseId == null) { + return null; + } + + const database = metadata.database(databaseId); + + const databaseItem = { + text: database.name, + id: databaseId, + type: "database", + }; + + if (schemaName == null) { + return [databaseItem]; + } + + const schema = metadata.schema(`${databaseId}:${schemaName}`); + const schemaItem = { + id: schema.id, + text: schema.name, + name: schema.name, + databaseId, + type: "schema", + }; + + const hasMultipleSchemas = database.schemas.length > 1; + + if (tableId == null) { + return [databaseItem, hasMultipleSchemas && schemaItem].filter(Boolean); + } + + const table = metadata.table(tableId); + const tableItem = { + text: table.name, + type: "table", + schemaName: schema.name, + databaseId, + originalId: table.id, + }; + + return [databaseItem, hasMultipleSchemas && schemaItem, tableItem].filter( + Boolean, + ); +}; + +export const getGroupsWithoutMetabot = createSelector( + [Group.selectors.getList], + groups => groups.filter(group => !isMetaBotGroup(group)), +); + +const getDataPermissions = state => state.admin.permissions.dataPermissions; + +const buildFieldsPermissions = ( + entityId, + groupId, + isAdmin, + permissions, + defaultGroup, + database, +) => { + const value = getFieldsPermission(permissions, groupId, entityId); + const defaultGroupValue = getFieldsPermission( + permissions, + defaultGroup.id, + entityId, + ); + + const warning = getPermissionWarning( + value, + defaultGroupValue, + "fields", + defaultGroup, + groupId, + ); + + const confirmations = newValue => [ + getPermissionWarningModal( + newValue, + defaultGroupValue, + "fields", + defaultGroup, + groupId, + ), + getControlledDatabaseWarningModal(permissions, groupId, entityId), + getRevokingAccessToAllTablesWarningModal( + database, + permissions, + groupId, + entityId, + newValue, + ), + ]; + + return [ + { + name: "access", + isDisabled: isAdmin, + disabledTooltip: isAdmin ? UNABLE_TO_CHANGE_ADMIN_PERMISSIONS : null, + value, + warning, + options: [ + DATA_PERMISSION_OPTIONS.all, + ...PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS, + DATA_PERMISSION_OPTIONS.none, + ], + actions: PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS, + postActions: PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_POST_ACTION, + confirmations, + }, + ]; +}; + +const buildTablesPermissions = ( + entityId, + groupId, + isAdmin, + permissions, + defaultGroup, +) => { + const value = getTablesPermission(permissions, groupId, entityId); + const defaultGroupValue = getTablesPermission( + permissions, + defaultGroup.id, + entityId, + ); + + const warning = getPermissionWarning( + value, + defaultGroupValue, + "tables", + defaultGroup, + groupId, + ); + + const confirmations = newValue => [ + getPermissionWarningModal( + newValue, + defaultGroupValue, + "tables", + defaultGroup, + groupId, + ), + getControlledDatabaseWarningModal(permissions, groupId, entityId), + ]; + + return [ + { + name: "access", + isDisabled: isAdmin, + disabledTooltip: isAdmin ? UNABLE_TO_CHANGE_ADMIN_PERMISSIONS : null, + value, + warning, + confirmations, + postActions: { + controlled: () => + push( + `/admin/permissions/data/group/${groupId}/database/${entityId.databaseId}/schema/${entityId.schemaName}`, + ), + }, + options: [ + DATA_PERMISSION_OPTIONS.all, + DATA_PERMISSION_OPTIONS.controlled, + DATA_PERMISSION_OPTIONS.none, + ], + }, + ]; +}; + +const buildDatabasePermissions = ( + entityId, + groupId, + isAdmin, + permissions, + defaultGroup, + database, +) => { + const accessPermissionValue = getSchemasPermission( + permissions, + groupId, + entityId, + ); + const defaultGroupAccessPermissionValue = getTablesPermission( + permissions, + defaultGroup.id, + entityId, + ); + const accessPermissionWarning = getPermissionWarning( + accessPermissionValue, + defaultGroupAccessPermissionValue, + "schemas", + defaultGroup, + groupId, + ); + + const accessPermissionConfirmations = newValue => [ + getPermissionWarningModal( + newValue, + defaultGroupAccessPermissionValue, + "schemas", + defaultGroup, + groupId, + ), + ]; + + const nativePermissionValue = getNativePermission( + permissions, + groupId, + entityId, + ); + + const defaultGroupNativePermissionValue = getNativePermission( + permissions, + defaultGroup.id, + entityId, + ); + const nativePermissionWarning = getPermissionWarning( + nativePermissionValue, + defaultGroupNativePermissionValue, + null, + defaultGroup, + groupId, + ); + + const nativePermissionConfirmations = newValue => [ + getPermissionWarningModal( + newValue, + defaultGroupNativePermissionValue, + null, + defaultGroup, + groupId, + ), + getRawQueryWarningModal(permissions, groupId, entityId, newValue), + ]; + + let controlledPostActionUrl = `/admin/permissions/data/group/${groupId}/database/${entityId.databaseId}`; + const hasSingleSchema = database.schemas.length === 1; + if (hasSingleSchema) { + controlledPostActionUrl += `/schema/${database.schemas[0].name}`; + } + + return [ + { + name: "access", + isDisabled: isAdmin, + disabledTooltip: isAdmin ? UNABLE_TO_CHANGE_ADMIN_PERMISSIONS : null, + value: accessPermissionValue, + warning: accessPermissionWarning, + confirmations: accessPermissionConfirmations, + options: [ + DATA_PERMISSION_OPTIONS.all, + DATA_PERMISSION_OPTIONS.controlled, + DATA_PERMISSION_OPTIONS.none, + ], + postActions: { + controlled: () => push(controlledPostActionUrl), + }, + }, + { + name: "native", + isDisabled: isAdmin || accessPermissionValue === "none", + disabledTooltip: isAdmin + ? UNABLE_TO_CHANGE_ADMIN_PERMISSIONS + : DATA_ACCESS_IS_REQUIRED, + value: nativePermissionValue, + warning: nativePermissionWarning, + confirmations: nativePermissionConfirmations, + options: [DATA_PERMISSION_OPTIONS.write, DATA_PERMISSION_OPTIONS.none], + }, + ]; +}; + +export const getGroupsDataPermissionEditor = createSelector( + getMetadata, + getRouteParams, + getDataPermissions, + getGroupsWithoutMetabot, + (metadata, params, permissions, groups) => { + const { databaseId, schemaName, tableId } = params; + + if (!permissions || databaseId == null) { + return null; + } + + const defaultGroup = _.find(groups, isDefaultGroup); + + const isDatabaseLevelPermission = tableId == null && schemaName == null; + const columns = [ + t`Group name`, + t`Data access`, + isDatabaseLevelPermission ? t`SQL queries` : null, + ].filter(Boolean); + + const entities = groups.map(group => { + const isAdmin = isAdminGroup(group); + let groupPermissions; + + if (tableId != null) { + groupPermissions = buildFieldsPermissions( + { + databaseId, + schemaName, + tableId, + }, + group.id, + isAdmin, + permissions, + defaultGroup, + metadata.database(databaseId), + ); + } else if (schemaName != null) { + groupPermissions = buildTablesPermissions( + { + databaseId, + schemaName, + }, + group.id, + isAdmin, + permissions, + defaultGroup, + ); + } else if (databaseId != null) { + groupPermissions = buildDatabasePermissions( + { + databaseId, + }, + group.id, + isAdmin, + permissions, + defaultGroup, + metadata.database(databaseId), + ); + } + + return { + id: group.id, + name: group.name, + permissions: groupPermissions, + }; + }); + + return { + title: t`Permissions for`, + filterPlaceholder: t`Search groups`, + breadcrumbs: getBreadcrumbs(params, metadata), + columns, + entities, + }; + }, +); + +const getGroupRouteParams = (_state, props) => { + const { groupId, databaseId, schemaName } = props.params; + return { + groupId, + databaseId, + schemaName, + }; +}; + +const isPinnedGroup = group => + isAdminGroup(group) || isDefaultGroup(group) || isMetaBotGroup(group); + +export const getGroupsSidebar = createSelector( + Group.selectors.getList, + getGroupRouteParams, + (groups, params) => { + const { groupId } = params; + + const [pinnedGroups, unpinnedGroups] = _.partition(groups, isPinnedGroup); + + const pinnedGroupItems = pinnedGroups.map(group => ({ + ...group, + icon: "bolt", + })); + + const unpinnedGroupItems = unpinnedGroups.map(group => ({ + ...group, + icon: "group", + })); + + return { + selectedId: parseInt(groupId), + entityGroups: [pinnedGroupItems, unpinnedGroupItems], + entitySwitch: getEntitySwitch("group"), + filterPlaceholder: t`Search for a group`, + }; + }, +); + +const getEditorEntityName = ({ databaseId, schemaName }) => { + if (schemaName != null) { + return t`Table name`; + } else if (databaseId) { + return t`Schema name`; + } else { + return t`Database name`; + } +}; + +const getFilterPlaceholder = ({ databaseId, schemaName }) => { + if (schemaName != null) { + return t`Search tables`; + } else if (databaseId) { + return t`Search schemas`; + } else { + return t`Search databases`; + } +}; + +export const getGroup = (state, props) => + Group.selectors.getObject(state, { entityId: props.params.groupId }); + +export const getDatabasesPermissionEditor = createSelector( + getMetadata, + getGroupRouteParams, + getDataPermissions, + getGroup, + getGroupsWithoutMetabot, + (metadata, params, permissions, group, groups) => { + const { groupId, databaseId, schemaName } = params; + + if (!permissions || groupId == null) { + return null; + } + + const isAdmin = isAdminGroup(group); + + let entities = []; + + const isDatabaseLevelPermission = schemaName == null && databaseId == null; + const columns = [ + getEditorEntityName(params), + t`Data access`, + isDatabaseLevelPermission ? t`SQL queries` : null, + ].filter(Boolean); + + const defaultGroup = _.find(groups, isDefaultGroup); + + if (schemaName != null) { + entities = metadata + .schema(`${databaseId}:${schemaName}`) + .tables.map(table => { + return { + id: table.id, + name: table.name, + type: "table", + permissions: buildFieldsPermissions( + { + databaseId, + schemaName, + tableId: table.id, + }, + groupId, + isAdmin, + permissions, + defaultGroup, + metadata.database(databaseId), + ), + }; + }); + } else if (databaseId != null) { + entities = metadata.database(databaseId).schemas.map(schema => { + return { + id: schema.id, + name: schema.name, + type: "schema", + permissions: buildTablesPermissions( + { + databaseId, + schemaName: schema.name, + }, + groupId, + isAdmin, + permissions, + defaultGroup, + ), + }; + }); + } else if (groupId != null) { + entities = metadata + .databasesList({ savedQuestions: false }) + .map(database => { + return { + id: database.id, + name: database.name, + type: "database", + schemas: database.schemas, + permissions: buildDatabasePermissions( + { + databaseId: database.id, + }, + groupId, + isAdmin, + permissions, + defaultGroup, + database, + ), + }; + }); + } + + const breadcrumbs = getBreadcrumbs(params, metadata); + let title = t`Permissions for the ${group.name} group`; + if (breadcrumbs?.length > 0) { + title += " - "; + } + + return { + title, + breadcrumbs, + description: + group != null + ? ngettext( + msgid`${group.member_count} person`, + `${group.member_count} people`, + group.member_count, + ) + : null, + filterPlaceholder: getFilterPlaceholder(params), + columns, + entities, + }; + }, +); diff --git a/frontend/src/metabase/components/Radio.jsx b/frontend/src/metabase/components/Radio.jsx index 2a0dcff09beff..e5dc2e036f3c3 100644 --- a/frontend/src/metabase/components/Radio.jsx +++ b/frontend/src/metabase/components/Radio.jsx @@ -44,6 +44,7 @@ const propTypes = { // Modes variant: PropTypes.oneOf(["bubble", "normal", "underlined"]), vertical: PropTypes.bool, + colorScheme: PropTypes.oneOf(["admin", "default"]), }; const defaultNameGetter = option => option.name; @@ -75,6 +76,7 @@ function Radio({ yspace, py, showButtons = vertical && variant !== "bubble", + colorScheme = "default", ...props }) { const id = useMemo(() => _.uniqueId("radio-"), []); @@ -102,6 +104,7 @@ function Radio({ - !props.showButtons && !props.selected ? color("brand") : null}; + !props.showButtons && !props.selected + ? COLOR_SCHEMES[props.colorScheme].main + : null}; } `; @@ -64,11 +75,15 @@ export const BubbleList = styled(BaseList)``; export const BubbleItem = styled(BaseItem)` font-weight: 700; border-radius: 99px; - color: ${props => (props.selected ? color("white") : color("brand"))}; + color: ${props => + props.selected ? color("white") : COLOR_SCHEMES[props.colorScheme].main}; background-color: ${props => - props.selected ? color("brand") : lighten("brand")}; + props.selected + ? COLOR_SCHEMES[props.colorScheme].main + : lighten(COLOR_SCHEMES[props.colorScheme].main)}; :hover { - background-color: ${props => !props.selected && lighten("brand", 0.38)}; + background-color: ${props => + !props.selected && lighten(COLOR_SCHEMES[props.colorScheme].main, 0.38)}; transition: background 300ms linear; } `; @@ -85,7 +100,8 @@ export const NormalList = styled(BaseList).attrs({ })``; export const NormalItem = styled(BaseItem)` - color: ${props => (props.selected ? color("brand") : null)}; + color: ${props => + props.selected ? COLOR_SCHEMES[props.colorScheme].main : null}; `; // UNDERLINE @@ -93,7 +109,8 @@ export const UnderlinedList = styled(NormalList)``; export const UnderlinedItem = styled(NormalItem)` border-bottom: 3px solid transparent; - border-color: ${props => (props.selected ? color("brand") : null)}; + border-color: ${props => + props.selected ? COLOR_SCHEMES[props.colorScheme].main : null}; `; UnderlinedItem.defaultProps = { diff --git a/frontend/src/metabase/components/TextInput.jsx b/frontend/src/metabase/components/TextInput.jsx index 25b50dc64181e..08cefd60dc02d 100644 --- a/frontend/src/metabase/components/TextInput.jsx +++ b/frontend/src/metabase/components/TextInput.jsx @@ -1,19 +1,42 @@ import React, { forwardRef } from "react"; import PropTypes from "prop-types"; -import styled, { css } from "styled-components"; -import { color } from "metabase/lib/colors"; import { t } from "ttag"; import Icon from "metabase/components/Icon"; +import { + TextInputRoot, + ClearButton, + IconWrapper, + Input, +} from "./TextInput.styled"; + +TextInput.propTypes = { + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + value: PropTypes.string, + type: PropTypes.string, + autoFocus: PropTypes.bool, + className: PropTypes.string, + hasClearButton: PropTypes.bool, + icon: PropTypes.node, + padding: PropTypes.oneOf(["sm", "md"]), + borderRadius: PropTypes.oneOf(["sm", "md"]), + colorScheme: PropTypes.oneOf(["default", "admin"]), +}; + function TextInput({ - value, + value = "", className, - placeholder, + placeholder = t`Find...`, onChange, - hasClearButton, + hasClearButton = false, icon, - type, + type = "text", + colorScheme = "default", + autoFocus = false, + padding = "md", + borderRadius = "md", ...rest }) { const handleClearClick = () => { @@ -26,12 +49,16 @@ function TextInput({ {icon && {icon}} onChange(e.target.value)} + padding={padding} + borderRadius={borderRadius} {...rest} /> @@ -44,96 +71,6 @@ function TextInput({ ); } -TextInput.propTypes = { - onChange: PropTypes.func.isRequired, - placeholder: PropTypes.string, - value: PropTypes.string, - type: PropTypes.string, - autoFocus: PropTypes.bool, - className: PropTypes.string, - hasClearButton: PropTypes.bool, - icon: PropTypes.node, - padding: PropTypes.oneOf(["sm", "md"]), - borderRadius: PropTypes.oneOf(["sm", "md"]), -}; - -TextInput.defaultProps = { - placeholder: t`Find...`, - value: "", - type: "text", - autoFocus: false, - hasClearButton: false, - padding: "md", - borderRadius: "md", -}; - -const PADDING = { - sm: "0.5rem", - md: "0.75rem", -}; - -const BORDER_RADIUS = { - sm: "4px", - md: "8px", -}; - -const Input = styled.input` - border: 1px solid ${color("border")}; - outline: none; - width: 100%; - font-size: 1.12em; - font-weight: 700; - color: ${color("text-dark")}; - min-width: 200px; - - ${({ borderRadius, padding }) => css` - border-radius: ${BORDER_RADIUS[borderRadius]}; - padding: ${PADDING[padding]}; - `} - - ${props => - props.hasClearButton - ? css` - padding-right: 26px; - ` - : null} - - ${props => - props.hasIcon - ? css` - padding-left: 36px; - ` - : null} - - &:focus { - border-color: ${color("brand")}; - } -`; - -const TextInputRoot = styled.div` - position: relative; - display: flex; - align-items: center; -`; - -const ClearButton = styled.button` - display: flex; - position: absolute; - right: 12px; - color: ${color("bg-dark")}; - cursor: pointer; - - &:hover { - color: ${color("text-dark")}; - } -`; - -const IconWrapper = styled.span` - position: absolute; - padding-left: 0.75rem; - color: ${color("text-light")}; -`; - export default forwardRef(function TextInputWithForwardedRef(props, ref) { return ; }); diff --git a/frontend/src/metabase/components/TextInput.styled.jsx b/frontend/src/metabase/components/TextInput.styled.jsx new file mode 100644 index 0000000000000..5e61eabd163d3 --- /dev/null +++ b/frontend/src/metabase/components/TextInput.styled.jsx @@ -0,0 +1,74 @@ +import styled, { css } from "styled-components"; +import { color } from "metabase/lib/colors"; + +const PADDING = { + sm: "0.5rem", + md: "0.75rem", +}; + +const BORDER_RADIUS = { + sm: "4px", + md: "8px", +}; + +const COLOR_BY_VARIANT = { + default: color("brand"), + admin: color("accent7"), +}; + +export const Input = styled.input` + border: 1px solid ${color("border")}; + outline: none; + width: 100%; + font-size: 1.12em; + font-weight: 400; + color: ${color("text-dark")}; + min-width: 200px; + + ${({ borderRadius, padding }) => css` + border-radius: ${BORDER_RADIUS[borderRadius]}; + padding: ${PADDING[padding]}; + `} + + ${props => + props.hasClearButton + ? css` + padding-right: 26px; + ` + : null} + + ${props => + props.hasIcon + ? css` + padding-left: 36px; + ` + : null} + + &:focus { + border-color: ${props => COLOR_BY_VARIANT[props.colorScheme]}; + } +`; + +export const TextInputRoot = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +export const ClearButton = styled.button` + display: flex; + position: absolute; + right: 12px; + color: ${color("bg-dark")}; + cursor: pointer; + + &:hover { + color: ${color("text-dark")}; + } +`; + +export const IconWrapper = styled.span` + position: absolute; + padding-left: 0.75rem; + color: ${color("text-light")}; +`; diff --git a/frontend/src/metabase/components/tree/Tree.jsx b/frontend/src/metabase/components/tree/Tree.jsx index c63f62d0961c3..86932e7cb4861 100644 --- a/frontend/src/metabase/components/tree/Tree.jsx +++ b/frontend/src/metabase/components/tree/Tree.jsx @@ -8,8 +8,9 @@ const propTypes = { TreeNodeComponent: PropTypes.object, data: PropTypes.array.isRequired, onSelect: PropTypes.func.isRequired, - variant: PropTypes.oneOf(["default", "admin"]), + colorScheme: PropTypes.oneOf(["default", "admin"]), selectedId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + emptyState: PropTypes.node, }; export function Tree({ @@ -17,7 +18,8 @@ export function Tree({ data, onSelect, selectedId, - variant = "default", + colorScheme = "default", + emptyState = null, }) { const [expandedIds, setExpandedIds] = useState( new Set(selectedId != null ? getInitialExpandedIds(selectedId, data) : []), @@ -34,9 +36,13 @@ export function Tree({ [expandedIds], ); + if (data.length === 0) { + return {emptyState}; + } + return ( {icon && ( - + )} diff --git a/frontend/src/metabase/components/tree/TreeNode.styled.jsx b/frontend/src/metabase/components/tree/TreeNode.styled.jsx index 42ba2ee8d1a22..bae134580923d 100644 --- a/frontend/src/metabase/components/tree/TreeNode.styled.jsx +++ b/frontend/src/metabase/components/tree/TreeNode.styled.jsx @@ -2,26 +2,24 @@ import styled, { css } from "styled-components"; import colors, { lighten } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; -// NOTE: whitelabeling/theming mutates colors object so we need to make it lazy -const TEXT_COLOR_BY_VARIANT = { - default: () => colors["brand"], - admin: () => colors["text-medium"], -}; - -const BACKGROUND_COLOR_BY_VARIANT = { - default: () => colors["brand"], - admin: () => colors["accent7"], +const COLOR_SCHEMES = { + admin: { + text: colors["text-medium"], + background: colors["accent7"], + }, + default: { + text: colors["brand"], + background: colors["brand"], + }, }; export const TreeNodeRoot = styled.li` display: flex; align-items: center; color: ${props => - props.isSelected - ? colors["white"] - : TEXT_COLOR_BY_VARIANT[props.variant]()}; + props.isSelected ? colors["white"] : COLOR_SCHEMES[props.colorScheme].text}; background-color: ${props => - props.isSelected ? BACKGROUND_COLOR_BY_VARIANT[props.variant]() : "unset"}; + props.isSelected ? COLOR_SCHEMES[props.colorScheme].background : "unset"}; padding-left: ${props => props.depth + 0.5}rem; padding-right: 0.5rem; cursor: pointer; @@ -30,8 +28,8 @@ export const TreeNodeRoot = styled.li` &:hover { background-color: ${props => props.isSelected - ? BACKGROUND_COLOR_BY_VARIANT[props.variant]() - : lighten(BACKGROUND_COLOR_BY_VARIANT[props.variant](), 0.6)}; + ? COLOR_SCHEMES[props.colorScheme].background + : lighten(COLOR_SCHEMES[props.colorScheme].background, 0.6)}; } `; @@ -73,7 +71,5 @@ export const RightArrowContainer = styled.div` display: flex; align-items: center; color: ${props => - props.isSelected - ? colors["white"] - : TEXT_COLOR_BY_VARIANT[props.variant]()}; + props.isSelected ? colors["white"] : COLOR_SCHEMES[props.colorScheme].text}; `; diff --git a/frontend/src/metabase/components/tree/TreeNodeList.jsx b/frontend/src/metabase/components/tree/TreeNodeList.jsx index 52db5a4c826f9..ff24072049219 100644 --- a/frontend/src/metabase/components/tree/TreeNodeList.jsx +++ b/frontend/src/metabase/components/tree/TreeNodeList.jsx @@ -10,7 +10,7 @@ const propTypes = { expandedIds: PropTypes.instanceOf(Set), selectedId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), depth: PropTypes.number.isRequired, - variant: PropTypes.string, + colorScheme: PropTypes.string, }; export function TreeNodeList({ @@ -21,7 +21,7 @@ export function TreeNodeList({ expandedIds, selectedId, depth, - variant, + colorScheme, }) { const selectedRef = useScrollOnMount(); @@ -37,7 +37,7 @@ export function TreeNodeList({ {isExpanded && ( { + const fullPathSegments = location.pathname.split("/"); + const routeSegments = route.path.split("/"); + + fullPathSegments.splice(-routeSegments.length); + + return fullPathSegments.join("/"); +}; + const ModalWithRoute = (ComposedModal, modalProps = {}) => connect( null, @@ -15,14 +24,10 @@ const ModalWithRoute = (ComposedModal, modalProps = {}) => ComposedModal.name}]`; onClose = () => { - const { - location: { pathname }, - } = this.props; - const urlWithoutLastSegment = pathname.substring( - 0, - pathname.lastIndexOf("/"), - ); - this.props.onChangeLocation(urlWithoutLastSegment); + const { location, route } = this.props; + + const parentPath = getParentPath(route, location); + this.props.onChangeLocation(parentPath); }; render() { diff --git a/frontend/src/metabase/plugins/index.js b/frontend/src/metabase/plugins/index.js index 5cc947132c2e3..d3823c2fd06cd 100644 --- a/frontend/src/metabase/plugins/index.js +++ b/frontend/src/metabase/plugins/index.js @@ -22,6 +22,7 @@ export const PLUGIN_ADMIN_SETTINGS_UPDATES = []; // admin permissions grid export const PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES = []; +export const PLUGIN_ADMIN_PERMISSIONS_TABLE_GROUP_ROUTES = []; export const PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_OPTIONS = []; export const PLUGIN_ADMIN_PERMISSIONS_TABLE_FIELDS_ACTIONS = { controlled: [], diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 709f44b8097ce..1271127121a28 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -37,7 +37,7 @@ import QueryBuilder from "metabase/query_builder/containers/QueryBuilder"; import CollectionEdit from "metabase/collections/containers/CollectionEdit"; import CollectionCreate from "metabase/collections/containers/CollectionCreate"; import ArchiveCollectionModal from "metabase/components/ArchiveCollectionModal"; -import CollectionPermissionsModal from "metabase/admin/permissions/containers/CollectionPermissionsModal"; +import CollectionPermissionsModal from "metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal"; import UserCollectionList from "metabase/containers/UserCollectionList"; import PulseEditApp from "metabase/pulse/containers/PulseEditApp"; diff --git a/frontend/test/metabase/admin/permissions/selectors.unit.spec.fixtures.js b/frontend/test/metabase/admin/permissions/selectors.unit.spec.fixtures.js deleted file mode 100644 index 927ea770d711b..0000000000000 --- a/frontend/test/metabase/admin/permissions/selectors.unit.spec.fixtures.js +++ /dev/null @@ -1,127 +0,0 @@ -// Database 2 contains an imaginary multi-schema database (like Redshift for instance) -// Database 3 contains an imaginary database which doesn't have any schemas (like MySQL) -// (A single-schema database was originally Database 1 but it got removed as testing against it felt redundant) - -export const normalizedMetadata = { - metrics: {}, - segments: {}, - databases: { - "2": { - name: "Imaginary Multi-Schema Dataset", - tables: [ - // In schema_1 - 5, - 6, - // In schema_2 - 7, - 8, - 9, - ], - id: 2, - }, - "3": { - name: "Imaginary Schemaless Dataset", - tables: [10, 11, 12, 13], - id: 3, - }, - }, - schemas: { - "2:schema_1": { - id: "2:schema_1", - name: "schema_1", - database: 2, - }, - "2:schema_2": { - id: "2:schema_2", - name: "schema_2", - database: 2, - }, - "3:": { - id: "3:", - name: null, - database: 3, - }, - }, - tables: { - "5": { - schema: "2:schema_1", - schema_name: "schema_1", - name: "Avian Singles Messages", - id: 5, - db_id: 2, - }, - "6": { - schema: "2:schema_1", - schema_name: "schema_1", - name: "Avian Singles Users", - id: 6, - db_id: 2, - }, - "7": { - schema: "2:schema_2", - schema_name: "schema_2", - name: "Tupac Sightings Sightings", - id: 7, - db_id: 2, - }, - "8": { - schema: "2:schema_2", - schema_name: "schema_2", - name: "Tupac Sightings Categories", - id: 8, - db_id: 2, - }, - "9": { - schema: "2:schema_2", - schema_name: "schema_2", - name: "Tupac Sightings Cities", - id: 9, - db_id: 2, - }, - "10": { - schema: "3:", - schema_name: null, - name: "Badminton Men's Double Results", - id: 10, - db_id: 3, - }, - "11": { - schema: "3:", - schema_name: null, - name: "Badminton Mixed Double Results", - id: 11, - db_id: 3, - }, - "12": { - schema: "3:", - schema_name: null, - name: "Badminton Women's Singles Results", - id: 12, - db_id: 3, - }, - "13": { - schema: "3:", - schema_name: null, - name: "Badminton Mixed Singles Results", - id: 13, - db_id: 3, - }, - }, - fields: { - /* stripped out */ - }, - revisions: {}, - databasesList: [2, 3], - - groups: { - "1": { - id: 1, - name: "Group starting with full access", - }, - "2": { - id: 2, - name: "Group starting with no access at all", - }, - }, - groups_list: [1, 2], -}; diff --git a/frontend/test/metabase/admin/permissions/selectors.unit.spec.js b/frontend/test/metabase/admin/permissions/selectors.unit.spec.js deleted file mode 100644 index 45e8c07960f54..0000000000000 --- a/frontend/test/metabase/admin/permissions/selectors.unit.spec.js +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Tests granting and revoking permissions against three kinds of datasets: - * - dataset with tables in a single PUBLIC schema (for instance H2 or PostgreSQL if no custom schemas created) - * - dataset with no schemas (for instance MySQL) - * - dataset with multiple schemas (for instance Redshift) - */ - -import { setIn } from "icepick"; - -jest.mock("metabase/lib/analytics"); -jest.mock("metabase/containers/CollectionSelect"); - -import { normalizedMetadata } from "./selectors.unit.spec.fixtures"; -import { - getTablesPermissionsGrid, - getSchemasPermissionsGrid, - getDatabasesPermissionsGrid, - getDatabaseTablesOrSchemasPath, -} from "metabase/admin/permissions/selectors"; - -/******** INITIAL TEST STATE ********/ - -const initialPermissions = { - 1: { - // Sample dataset - 1: { - native: "write", - schemas: "all", - }, - // Imaginary multi-schema - 2: { - native: "write", - schemas: "all", - }, - // Imaginary schemaless - 3: { - native: "write", - schemas: "all", - }, - }, - 2: { - // Sample dataset - 1: { - native: "none", - schemas: "none", - }, - // Imaginary multi-schema - 2: { - native: "none", - schemas: "none", - }, - // Imaginary schemaless - 3: { - native: "none", - schemas: "none", - }, - }, -}; - -/******** MANAGING THE CURRENT (SIMULATED) STATE TREE ********/ - -const initialState = { - admin: { - permissions: { - permissions: initialPermissions, - originalPermissions: initialPermissions, - }, - }, - entities: normalizedMetadata, -}; - -let state = initialState; -const resetState = () => { - state = initialState; -}; -const getPermissionsTree = () => state.admin.permissions.permissions; -const getPermissionsForDb = ({ entityId, groupId }) => - getPermissionsTree()[groupId][entityId.databaseId]; - -const updatePermissionsInState = permissions => { - state = setIn(state, ["admin", "permissions", "permissions"], permissions); -}; - -const getProps = ({ databaseId, schemaName }) => ({ - params: { - databaseId, - schemaName, - }, -}); - -/******** HIGH-LEVEL METHODS FOR UPDATING PERMISSIONS ********/ - -const changePermissionsForEntityInGrid = ({ - grid, - category, - entityId, - groupId, - permission, -}) => { - const newPermissions = grid.permissions[category].updater( - groupId, - entityId, - permission, - ); - updatePermissionsInState(newPermissions); - return newPermissions; -}; - -const changeDbNativePermissionsForEntity = ({ - entityId, - groupId, - permission, -}) => { - const grid = getDatabasesPermissionsGrid(state, getProps(entityId)); - return changePermissionsForEntityInGrid({ - grid, - category: "native", - entityId, - groupId, - permission, - }); -}; - -const changeDbDataPermissionsForEntity = ({ - entityId, - groupId, - permission, -}) => { - const grid = getDatabasesPermissionsGrid(state, getProps(entityId)); - return changePermissionsForEntityInGrid({ - grid, - category: "schemas", - entityId, - groupId, - permission, - }); -}; - -const changeSchemaPermissionsForEntity = ({ - entityId, - groupId, - permission, -}) => { - const grid = getSchemasPermissionsGrid(state, getProps(entityId)); - return changePermissionsForEntityInGrid({ - grid, - category: "tables", - entityId, - groupId, - permission, - }); -}; - -const changeTablePermissionsForEntity = ({ entityId, groupId, permission }) => { - const grid = getTablesPermissionsGrid(state, getProps(entityId)); - return changePermissionsForEntityInGrid({ - grid, - category: "fields", - entityId, - groupId, - permission, - }); -}; - -const getMethodsForDbAndSchema = entityId => ({ - changeDbNativePermissions: ({ groupId, permission }) => - changeDbNativePermissionsForEntity({ entityId, groupId, permission }), - changeDbDataPermissions: ({ groupId, permission }) => - changeDbDataPermissionsForEntity({ entityId, groupId, permission }), - changeSchemaPermissions: ({ groupId, permission }) => - changeSchemaPermissionsForEntity({ entityId, groupId, permission }), - changeTablePermissions: ({ tableId, groupId, permission }) => - changeTablePermissionsForEntity({ - entityId: { ...entityId, tableId }, - groupId, - permission, - }), - getPermissions: ({ groupId }) => getPermissionsForDb({ entityId, groupId }), -}); - -/******** ACTUAL TESTS ********/ - -describe("permissions selectors", () => { - beforeEach(resetState); - - describe("for a schemaless dataset", () => { - // Schema "name" (better description would be a "permission path identifier") is simply an empty string - // for databases where the metadata value for table schema is `null` - const schemalessDataset = getMethodsForDbAndSchema({ - databaseId: 3, - schemaName: null, - }); - - it("should restrict access correctly on table level", () => { - // Revoking access to one table should downgrade the native permissions to "read" - schemalessDataset.changeTablePermissions({ - tableId: 10, - groupId: 1, - permission: "none", - }); - expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: { - "": { - "10": "none", - "11": "all", - "12": "all", - "13": "all", - }, - }, - }); - - // Revoking access to the rest of tables one-by-one... - schemalessDataset.changeTablePermissions({ - tableId: 11, - groupId: 1, - permission: "none", - }); - schemalessDataset.changeTablePermissions({ - tableId: 12, - groupId: 1, - permission: "none", - }); - schemalessDataset.changeTablePermissions({ - tableId: 13, - groupId: 1, - permission: "none", - }); - - expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - // ...should revoke all permissions for that database - native: "none", - schemas: "none", - }); - }); - - it("should restrict access correctly on db level", () => { - // Should not let change the native permission to none - schemalessDataset.changeDbNativePermissions({ - groupId: 1, - permission: "none", - }); - expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "all", - }); - - resetState(); // ad-hoc state reset for the next test - // Revoking the data access to the database at once should revoke all permissions for that database - schemalessDataset.changeDbDataPermissions({ - groupId: 1, - permission: "none", - }); - expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "none", - }); - }); - - it("should grant more access correctly on table level", () => { - // Simply grant an access to a single table - schemalessDataset.changeTablePermissions({ - tableId: 12, - groupId: 2, - permission: "all", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - "": { - "10": "none", - "11": "none", - "12": "all", - "13": "none", - }, - }, - }); - - // Grant the access to rest of tables - schemalessDataset.changeTablePermissions({ - tableId: 10, - groupId: 2, - permission: "all", - }); - schemalessDataset.changeTablePermissions({ - tableId: 11, - groupId: 2, - permission: "all", - }); - schemalessDataset.changeTablePermissions({ - tableId: 13, - groupId: 2, - permission: "all", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: "all", - }); - - // Should pass changes to native permissions through - schemalessDataset.changeDbNativePermissions({ - groupId: 2, - permission: "write", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "write", - schemas: "all", - }); - }); - - it("should grant more access correctly on db level", () => { - // Setting limited access should produce a permission tree where each schema has "none" access - // (this is a strange, rather no-op edge case but the UI currently enables this) - schemalessDataset.changeDbDataPermissions({ - groupId: 2, - permission: "controlled", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - "": "none", - }, - }); - - // Granting native access should also grant a full write access - schemalessDataset.changeDbNativePermissions({ - groupId: 2, - permission: "write", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "write", - schemas: "all", - }); - - resetState(); // ad-hoc reset (normally run before tests) - // test that setting full access works too - schemalessDataset.changeDbDataPermissions({ - groupId: 2, - permission: "all", - }); - expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: "all", - }); - }); - }); - - describe("for a dataset with multiple schemas", () => { - const schema1 = getMethodsForDbAndSchema({ - databaseId: 2, - schemaName: "schema_1", - }); - const schema2 = getMethodsForDbAndSchema({ - databaseId: 2, - schemaName: "schema_2", - }); - - it("should restrict access correctly on table level", () => { - // Revoking access to one table should downgrade the native permissions to "none" - schema1.changeTablePermissions({ - tableId: 5, - groupId: 1, - permission: "none", - }); - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: { - schema_1: { - "5": "none", - "6": "all", - }, - schema_2: "all", - }, - }); - - // State where both schemas have mixed permissions - schema2.changeTablePermissions({ - tableId: 8, - groupId: 1, - permission: "none", - }); - schema2.changeTablePermissions({ - tableId: 9, - groupId: 1, - permission: "none", - }); - expect(schema2.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: { - schema_1: { - "5": "none", - "6": "all", - }, - schema_2: { - "7": "all", - "8": "none", - "9": "none", - }, - }, - }); - - // Completely revoke access to the first schema with table-level changes - schema1.changeTablePermissions({ - tableId: 6, - groupId: 1, - permission: "none", - }); - - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: { - schema_1: "none", - schema_2: { - "7": "all", - "8": "none", - "9": "none", - }, - }, - }); - - // Revoking all permissions of the other schema should revoke all db permissions too - schema2.changeTablePermissions({ - tableId: 7, - groupId: 1, - permission: "none", - }); - expect(schema2.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "none", - }); - }); - - it("should restrict access correctly on schema level", () => { - // Revoking access to one schema - schema2.changeSchemaPermissions({ groupId: 1, permission: "none" }); - expect(schema2.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: { - schema_1: "all", - schema_2: "none", - }, - }); - - // Revoking access to other too - schema1.changeSchemaPermissions({ groupId: 1, permission: "none" }); - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "none", - }); - }); - - it("should restrict access correctly on db level", () => { - // Should let change the native permission to none - schema1.changeDbNativePermissions({ groupId: 1, permission: "none" }); - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "all", - }); - - resetState(); // ad-hoc state reset for the next test - // Revoking the data access to the database at once should revoke all permissions for that database - schema1.changeDbDataPermissions({ groupId: 1, permission: "none" }); - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "none", - schemas: "none", - }); - }); - - it("should grant more access correctly on table level", () => { - // Simply grant an access to a single table - schema2.changeTablePermissions({ - tableId: 7, - groupId: 2, - permission: "all", - }); - expect(schema2.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - schema_1: "none", - schema_2: { - "7": "all", - "8": "none", - "9": "none", - }, - }, - }); - - // State where both schemas have mixed permissions - schema1.changeTablePermissions({ - tableId: 5, - groupId: 2, - permission: "all", - }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - schema_1: { - "5": "all", - "6": "none", - }, - schema_2: { - "7": "all", - "8": "none", - "9": "none", - }, - }, - }); - - // Grant full access to the second schema - schema2.changeTablePermissions({ - tableId: 8, - groupId: 2, - permission: "all", - }); - schema2.changeTablePermissions({ - tableId: 9, - groupId: 2, - permission: "all", - }); - expect(schema2.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - schema_1: { - "5": "all", - "6": "none", - }, - schema_2: "all", - }, - }); - - // Grant the access to whole db (no native yet) - schema1.changeTablePermissions({ - tableId: 5, - groupId: 2, - permission: "all", - }); - schema1.changeTablePermissions({ - tableId: 6, - groupId: 2, - permission: "all", - }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: "all", - }); - - // Should pass changes to native permissions through - schema1.changeDbNativePermissions({ groupId: 2, permission: "write" }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "write", - schemas: "all", - }); - }); - - it("should grant more access correctly on schema level", () => { - // Granting full access to one schema - schema1.changeSchemaPermissions({ groupId: 2, permission: "all" }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - schema_1: "all", - schema_2: "none", - }, - }); - - // Granting access to the other as well - schema2.changeSchemaPermissions({ groupId: 2, permission: "all" }); - expect(schema2.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: "all", - }); - }); - - it("should grant more access correctly on db level", () => { - // Setting limited access should produce a permission tree where each schema has "none" access - // (this is a strange, rather no-op edge case but the UI currently enables this) - schema1.changeDbDataPermissions({ groupId: 2, permission: "controlled" }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: { - schema_1: "none", - schema_2: "none", - }, - }); - - // Granting native access should also grant a full write access - schema1.changeDbNativePermissions({ groupId: 2, permission: "write" }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "write", - schemas: "all", - }); - - resetState(); // ad-hoc reset (normally run before tests) - // test that setting full access works too - schema1.changeDbDataPermissions({ groupId: 2, permission: "all" }); - expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "none", - schemas: "all", - }); - }); - }); -}); - -describe("getDatabaseTablesOrSchemasPath", () => { - it("should return path for schema-less db", () => { - const database = { id: 1, schemaNames: () => [null] }; - expect(getDatabaseTablesOrSchemasPath(database)).toBe( - "/admin/permissions/databases/1/tables", - ); - }); - - it("should return path for db with no schemas", () => { - const database = { id: 1, schemaNames: () => [] }; - expect(getDatabaseTablesOrSchemasPath(database)).toBe( - "/admin/permissions/databases/1/schemas", - ); - }); - - it("should return path for db with a single schema", () => { - const database = { id: 1, schemaNames: () => ["foo"] }; - expect(getDatabaseTablesOrSchemasPath(database)).toBe( - "/admin/permissions/databases/1/schemas/foo/tables", - ); - }); - - it("should return path for db with multiple schemas", () => { - const database = { id: 1, schemaNames: () => ["foo", "bar"] }; - expect(getDatabaseTablesOrSchemasPath(database)).toBe( - "/admin/permissions/databases/1/schemas", - ); - }); -}); diff --git a/frontend/test/metabase/scenarios/admin/permissions/sandboxes.cy.spec.js b/frontend/test/metabase/scenarios/admin/permissions/sandboxes.cy.spec.js index 2d7844b736d8c..5c9b8622a1ccf 100644 --- a/frontend/test/metabase/scenarios/admin/permissions/sandboxes.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/permissions/sandboxes.cy.spec.js @@ -740,17 +740,12 @@ describeWithToken("formatting > sandboxes", () => { native: { query: "SELECT CAST(ID AS VARCHAR) AS ID FROM ORDERS;" }, }); - cy.visit("/admin/permissions/databases/1/schemas"); - cy.findByText("View tables").click(); - // | | All users | collection | - // |--------------- |:---------:|:----------:| - // | Orders | X (0) | X (1) | - + cy.visit("/admin/permissions/data/database/1/schema/PUBLIC/table/2"); cy.wait("@tablePermissions"); cy.icon("close") .eq(1) // No better way of doing this, undfortunately (see table above) .click(); - cy.findByText("Grant sandboxed access").click(); + cy.findByText("Sandboxed").click(); cy.button("Change").click(); cy.findByText( "Use a saved question to create a custom view for this table", diff --git a/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js b/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js index fb019f2478c37..b9cf756556257 100644 --- a/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js +++ b/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js @@ -196,14 +196,15 @@ describeWithToken("scenarios > question > snippets", () => { // Update permissions for "All users" modal().within(() => { - cy.findAllByRole("grid") - .last() + cy.findByTestId("permission-table") .find(".Icon-close") .first() .click(); }); - cy.findByText("Grant View access").click(); + cy.findAllByRole("option") + .contains("View") + .click(); cy.button("Save").click(); cy.wait("@updatePermissions"); diff --git a/frontend/test/metabase/scenarios/question/view.cy.spec.js b/frontend/test/metabase/scenarios/question/view.cy.spec.js index a419669b2858f..e813007078ab5 100644 --- a/frontend/test/metabase/scenarios/question/view.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/view.cy.spec.js @@ -90,11 +90,13 @@ describe("scenarios > question > view", () => { describe("apply filters without data permissions", () => { beforeEach(() => { // All users upgraded to collection view access - cy.visit("/admin/permissions/collections"); + cy.visit("/admin/permissions/collections/root"); cy.icon("close") .first() .click(); - cy.findByText("View collection").click(); + cy.findAllByRole("option") + .contains("View") + .click(); cy.findByText("Save Changes").click(); cy.findByText("Yes").click();