Skip to content
Snippets Groups Projects
Unverified Commit 859ca2df authored by Nick Fitzpatrick's avatar Nick Fitzpatrick Committed by GitHub
Browse files

36797 partial permission graph (#37058)

* building out partial graphs

* Set up change deteected modal, graph loading states

* e2e tests. Load admin permissions

* reset permissions from memory on cancel

* fixing types and tests
parent 34ebffa9
No related branches found
No related tags found
No related merge requests found
Showing
with 306 additions and 53 deletions
......@@ -491,6 +491,30 @@ describe("scenarios > admin > permissions", { tags: "@OSS" }, () => {
["Reviews", "Unrestricted", "Yes"],
]);
});
it("should show a modal when a revision changes while an admin is editing", () => {
cy.intercept("/api/permissions/graph/group/1").as("graph");
cy.visit("/admin/permissions");
selectSidebarItem("collection");
modifyPermission(
"Sample Database",
DATA_ACCESS_PERMISSION_INDEX,
"Unrestricted",
);
cy.get("@graph").then(data => {
cy.request("PUT", "/api/permissions/graph", {
groups: {},
revision: data.response.body.revision,
}).then(() => {
selectSidebarItem("data");
modal().findByText("Someone just changed permissions");
});
});
});
});
context("database focused view", () => {
......@@ -597,6 +621,30 @@ describe("scenarios > admin > permissions", { tags: "@OSS" }, () => {
["readonly", "Unrestricted", "Yes"],
]);
});
it("should show a modal when a revision changes while an admin is editing", () => {
cy.intercept("/api/permissions/graph/group/1").as("graph");
cy.visit("/admin/permissions/");
selectSidebarItem("collection");
modifyPermission(
"Sample Database",
DATA_ACCESS_PERMISSION_INDEX,
"Unrestricted",
);
cy.get("@graph").then(data => {
cy.request("PUT", "/api/permissions/graph", {
groups: {},
revision: data.response.body.revision,
}).then(() => {
cy.get("label").contains("Databases").click();
selectSidebarItem("Sample Database");
modal().findByText("Someone just changed permissions");
});
});
});
});
});
});
......
......@@ -29,6 +29,10 @@ export interface AdminState {
originalCollectionPermissions: CollectionPermissions;
saveError?: string;
isHelpReferenceOpen: boolean;
hasRevisionChanged: {
revision: number | null;
hasChanged: boolean;
};
};
settings: {
settings: SettingDefinition[];
......
......@@ -26,6 +26,10 @@ export const createMockPermissionsState = (
collectionPermissions: {},
originalCollectionPermissions: {},
isHelpReferenceOpen: false,
hasRevisionChanged: {
revision: null,
hasChanged: false,
},
...opts,
};
};
......@@ -10,6 +10,13 @@ import { LeaveConfirmationModal } from "metabase/components/LeaveConfirmationMod
import Modal from "metabase/components/Modal";
import ModalContent from "metabase/components/ModalContent";
import {
Modal as NewModal,
Text,
Button as NewButton,
Group,
} from "metabase/ui";
import type { PermissionsGraph } from "metabase-types/api";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
......@@ -28,6 +35,7 @@ import {
toggleHelpReference,
} from "../../permissions";
import { ToolbarButton } from "../ToolbarButton";
import { showRevisionChangedModal } from "../../selectors/data-permissions/revision";
import { PermissionsTabs } from "./PermissionsTabs";
import { PermissionsEditBar } from "./PermissionsEditBar";
......@@ -70,7 +78,7 @@ function PermissionsPageLayout({
helpContent,
}: PermissionsPageLayoutProps) {
const saveError = useSelector(state => state.admin.permissions.saveError);
const showRefreshModal = useSelector(showRevisionChangedModal);
const isHelpReferenceOpen = useSelector(getIsHelpReferenceOpen);
const dispatch = useDispatch();
......@@ -132,6 +140,24 @@ function PermissionsPageLayout({
{helpContent}
</PermissionPageSidebar>
)}
<NewModal
title="Someone just changed permissions"
opened={showRefreshModal}
size="lg"
padding="2.5rem"
withCloseButton={false}
onClose={() => true}
>
<Text mb="1rem">
To edit permissions, you need to start from the latest version. Please
refresh the page.
</Text>
<Group position="right">
<NewButton onClick={() => location.reload()} variant="filled">
Refresh the page
</NewButton>
</Group>
</NewModal>
</PermissionPageRoot>
);
}
......
import type { ReactNode } from "react";
import { useEffect, useCallback } from "react";
import { useAsync } from "react-use";
import _ from "underscore";
import type { Route } from "react-router";
......@@ -7,14 +8,18 @@ import Tables from "metabase/entities/tables";
import Groups from "metabase/entities/groups";
import Databases from "metabase/entities/databases";
import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups";
import { PermissionsApi } from "metabase/services";
import { Loader, Center } from "metabase/ui";
import type { DatabaseId, Group } from "metabase-types/api";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type Database from "metabase-lib/metadata/Database";
import { getIsDirty, getDiff } from "../../selectors/data-permissions/diff";
import {
saveDataPermissions,
loadDataPermissions,
initializeDataPermissions,
restoreLoadedPermissions,
LOAD_DATA_PERMISSIONS_FOR_GROUP,
} from "../../permissions";
import PermissionsPageLayout from "../../components/PermissionsPageLayout/PermissionsPageLayout";
import { DataPermissionsHelp } from "../../components/DataPermissionsHelp";
......@@ -46,12 +51,25 @@ function DataPermissionsPage({
const dispatch = useDispatch();
const loadPermissions = () => dispatch(loadDataPermissions());
const resetPermissions = () => dispatch(restoreLoadedPermissions());
const savePermissions = () => dispatch(saveDataPermissions());
const initialize = useCallback(
() => dispatch(initializeDataPermissions()),
[dispatch],
);
const { loading: isLoadingAllUsers } = useAsync(async () => {
const allUsers = groups.find(isDefaultGroup);
const result = await PermissionsApi.graphForGroup({
groupId: allUsers?.id,
});
await dispatch({ type: LOAD_DATA_PERMISSIONS_FOR_GROUP, payload: result });
}, []);
const { loading: isLoadingAdminstrators } = useAsync(async () => {
const admins = groups.find(isAdminGroup);
const result = await PermissionsApi.graphForGroup({
groupId: admins?.id,
});
await dispatch({ type: LOAD_DATA_PERMISSIONS_FOR_GROUP, payload: result });
}, []);
const fetchTables = useCallback(
(dbId: DatabaseId) =>
dispatch(
......@@ -64,10 +82,6 @@ function DataPermissionsPage({
[dispatch],
);
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
if (params.databaseId == null) {
return;
......@@ -75,10 +89,18 @@ function DataPermissionsPage({
fetchTables(params.databaseId);
}, [params.databaseId, fetchTables]);
if (isLoadingAllUsers || isLoadingAdminstrators) {
return (
<Center h="100%">
<Loader size="lg" />
</Center>
);
}
return (
<PermissionsPageLayout
tab="data"
onLoad={loadPermissions}
onLoad={resetPermissions}
onSave={savePermissions}
diff={diff}
isDirty={isDirty}
......
import { Fragment, useCallback } from "react";
import { useAsync } from "react-use";
import PropTypes from "prop-types";
import { bindActionCreators } from "@reduxjs/toolkit";
import { push } from "react-router-redux";
......@@ -6,19 +7,25 @@ import { t } from "ttag";
import _ from "underscore";
import { connect } from "react-redux";
import { Loader, Center } from "metabase/ui";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { PermissionsApi } from "metabase/services";
import {
getGroupsDataPermissionEditor,
getDataFocusSidebar,
getIsLoadingDatabaseTables,
getLoadingDatabaseTablesError,
} from "../../selectors/data-permissions";
import { updateDataPermission } from "../../permissions";
import {
updateDataPermission,
LOAD_DATA_PERMISSIONS_FOR_DB,
} from "../../permissions";
import { PermissionsSidebar } from "../../components/PermissionsSidebar";
import {
PermissionsEditor,
PermissionsEditorEmptyState,
permissionEditorPropTypes,
} from "../../components/PermissionsEditor";
import {
DATABASES_BASE_PATH,
......@@ -42,7 +49,6 @@ const mapDispatchToProps = dispatch => ({
const mapStateToProps = (state, props) => {
return {
sidebar: getDataFocusSidebar(state, props),
permissionEditor: getGroupsDataPermissionEditor(state, props),
isSidebarLoading: getIsLoadingDatabaseTables(state, props),
sidebarError: getLoadingDatabaseTablesError(state, props),
};
......@@ -56,29 +62,42 @@ const propTypes = {
}),
children: PropTypes.node,
sidebar: PropTypes.object,
permissionEditor: PropTypes.shape(permissionEditorPropTypes),
navigateToItem: PropTypes.func.isRequired,
switchView: PropTypes.func.isRequired,
updateDataPermission: PropTypes.func.isRequired,
navigateToDatabaseList: PropTypes.func.isRequired,
isSidebarLoading: PropTypes.bool,
sidebarError: PropTypes.string,
dispatch: PropTypes.func.isRequired,
};
function DatabasesPermissionsPage({
sidebar,
permissionEditor,
params,
children,
navigateToItem,
navigateToDatabaseList,
switchView,
updateDataPermission,
dispatch,
isSidebarLoading,
sidebarError,
}) {
const dispatch = useDispatch();
const permissionEditor = useSelector(state =>
getGroupsDataPermissionEditor(state, { params }),
);
const { loading: isLoading } = useAsync(async () => {
if (params.databaseId) {
const response = await PermissionsApi.graphForDB({
databaseId: params.databaseId,
});
await dispatch({
type: LOAD_DATA_PERMISSIONS_FOR_DB,
payload: response,
});
}
}, [params.databaseId]);
const handleEntityChange = useCallback(
entityType => {
switchView(entityType);
......@@ -115,15 +134,19 @@ function DatabasesPermissionsPage({
onBack={params.databaseId == null ? null : navigateToDatabaseList}
onEntityChange={handleEntityChange}
/>
{!permissionEditor && (
{isLoading && (
<Center style={{ flexGrow: 1 }}>
<Loader size="lg" />
</Center>
)}
{!permissionEditor && !isLoading && (
<PermissionsEditorEmptyState
icon="database"
message={t`Select a database to see group permissions`}
/>
)}
{permissionEditor && (
{permissionEditor && !isLoading && (
<PermissionsEditor
{...permissionEditor}
onBreadcrumbsItemSelect={handleBreadcrumbsItemSelect}
......
import { Fragment, useCallback } from "react";
import { useAsync } from "react-use";
import PropTypes from "prop-types";
import { bindActionCreators } from "@reduxjs/toolkit";
import { push } from "react-router-redux";
import { t } from "ttag";
import _ from "underscore";
import { connect } from "react-redux";
import { useSelector, useDispatch } from "metabase/lib/redux";
import { Loader, Center } from "metabase/ui";
import { PermissionsApi } from "metabase/services";
import {
getDatabasesPermissionEditor,
getIsLoadingDatabaseTables,
getLoadingDatabaseTablesError,
getGroupsSidebar,
} from "../../selectors/data-permissions";
import { updateDataPermission } from "../../permissions";
import {
updateDataPermission,
LOAD_DATA_PERMISSIONS_FOR_GROUP,
} from "../../permissions";
import { PermissionsSidebar } from "../../components/PermissionsSidebar";
import {
PermissionsEditor,
PermissionsEditorEmptyState,
permissionEditorPropTypes,
} from "../../components/PermissionsEditor";
import {
getGroupFocusPermissionsUrl,
......@@ -42,7 +49,6 @@ const mapDispatchToProps = dispatch => ({
const mapStateToProps = (state, props) => {
return {
sidebar: getGroupsSidebar(state, props),
permissionEditor: getDatabasesPermissionEditor(state, props),
isEditorLoading: getIsLoadingDatabaseTables(state, props),
editorError: getLoadingDatabaseTablesError(state, props),
};
......@@ -56,7 +62,6 @@ const propTypes = {
}),
children: PropTypes.node,
sidebar: PropTypes.object,
permissionEditor: PropTypes.shape(permissionEditorPropTypes),
navigateToItem: PropTypes.func.isRequired,
switchView: PropTypes.func.isRequired,
navigateToTableItem: PropTypes.func.isRequired,
......@@ -70,15 +75,31 @@ function GroupsPermissionsPage({
sidebar,
params,
children,
permissionEditor,
navigateToItem,
switchView,
navigateToTableItem,
updateDataPermission,
isEditorLoading,
editorError,
dispatch,
}) {
const dispatch = useDispatch();
const { loading: isLoading } = useAsync(async () => {
if (params.groupId) {
const response = await PermissionsApi.graphForGroup({
groupId: params.groupId,
});
await dispatch({
type: LOAD_DATA_PERMISSIONS_FOR_GROUP,
payload: response,
});
}
}, [params.groupId]);
const permissionEditor = useSelector(state =>
getDatabasesPermissionEditor(state, { params }),
);
const handleEntityChange = useCallback(
entityType => {
switchView(entityType);
......@@ -128,14 +149,20 @@ function GroupsPermissionsPage({
onEntityChange={handleEntityChange}
/>
{showEmptyState && (
{isLoading && (
<Center style={{ flexGrow: 1 }}>
<Loader size="lg" />
</Center>
)}
{showEmptyState && !isLoading && (
<PermissionsEditorEmptyState
icon="group"
message={t`Select a group to see its data permissions`}
/>
)}
{!showEmptyState && (
{!showEmptyState && !isLoading && (
<PermissionsEditor
{...permissionEditor}
isLoading={isEditorLoading}
......
import { t } from "ttag";
import { push } from "react-router-redux";
import { assocIn } from "icepick";
import { assocIn, merge } from "icepick";
import {
PLUGIN_DATA_PERMISSIONS,
......@@ -48,6 +48,25 @@ export const loadDataPermissions = createThunkAction(
() => async () => PermissionsApi.graph(),
);
export const RESTORE_LOADED_PERMISSIONS =
"metabase/admin/permissions/RESTORE_LOADED_PERMISSIONS";
export const restoreLoadedPermissions = createThunkAction(
RESTORE_LOADED_PERMISSIONS,
() => async (dispatch, getState) => {
const state = getState();
const groups = state.admin.permissions.originalDataPermissions;
const revision = state.admin.permissions.dataPermissionsRevision;
dispatch({ type: LOAD_DATA_PERMISSIONS, payload: { groups, revision } });
},
);
export const LOAD_DATA_PERMISSIONS_FOR_GROUP =
"metabase/admin/permissions/LOAD_DATA_PERMISSIONS_FOR_GROUP";
export const LOAD_DATA_PERMISSIONS_FOR_DB =
"metabase/admin/permissions/LOAD_DATA_PERMISSIONS_FOR_GROUP";
const INITIALIZE_COLLECTION_PERMISSIONS =
"metabase/admin/permissions/INITIALIZE_COLLECTION_PERMISSIONS";
export const initializeCollectionPermissions = createThunkAction(
......@@ -230,6 +249,12 @@ const dataPermissions = handleActions(
[LOAD_DATA_PERMISSIONS]: {
next: (_state, { payload }) => payload.groups,
},
[LOAD_DATA_PERMISSIONS_FOR_GROUP]: {
next: (state, { payload }) => merge(payload.groups, state),
},
[LOAD_DATA_PERMISSIONS_FOR_DB]: {
next: (state, { payload }) => merge(payload.groups, state),
},
[SAVE_DATA_PERMISSIONS]: { next: (_state, { payload }) => payload.groups },
[UPDATE_DATA_PERMISSION]: {
next: (state, { payload }) => {
......@@ -317,6 +342,12 @@ const originalDataPermissions = handleActions(
[LOAD_DATA_PERMISSIONS]: {
next: (_state, { payload }) => payload.groups,
},
[LOAD_DATA_PERMISSIONS_FOR_GROUP]: {
next: (state, { payload }) => merge(payload.groups, state),
},
[LOAD_DATA_PERMISSIONS_FOR_DB]: {
next: (state, { payload }) => merge(payload.groups, state),
},
[SAVE_DATA_PERMISSIONS]: {
next: (_state, { payload }) => payload.groups,
},
......@@ -329,6 +360,12 @@ const dataPermissionsRevision = handleActions(
[LOAD_DATA_PERMISSIONS]: {
next: (_state, { payload }) => payload.revision,
},
[LOAD_DATA_PERMISSIONS_FOR_GROUP]: {
next: (state, { payload }) => payload.revision,
},
[LOAD_DATA_PERMISSIONS_FOR_DB]: {
next: (state, { payload }) => payload.revision,
},
[SAVE_DATA_PERMISSIONS]: {
next: (_state, { payload }) => payload.revision,
},
......@@ -402,6 +439,40 @@ export const isHelpReferenceOpen = handleActions(
false,
);
const checkRevisionChanged = (state, { payload }) => {
if (!state.revision) {
return {
revision: payload.revision,
hasChanged: false,
};
} else if (state.revision === payload.revision && !state.hasChanged) {
return state;
} else {
return {
revision: payload.revision,
hasChanged: true,
};
}
};
const hasRevisionChanged = handleActions(
{
[LOAD_DATA_PERMISSIONS]: {
next: checkRevisionChanged,
},
[LOAD_DATA_PERMISSIONS_FOR_GROUP]: {
next: checkRevisionChanged,
},
[LOAD_DATA_PERMISSIONS_FOR_DB]: {
next: checkRevisionChanged,
},
},
{
revision: null,
hasChanged: false,
},
);
export default combineReducers({
saveError,
dataPermissions,
......@@ -411,4 +482,5 @@ export default combineReducers({
originalCollectionPermissions,
collectionPermissionsRevision,
isHelpReferenceOpen,
hasRevisionChanged,
});
import { createSelector } from "@reduxjs/toolkit";
import type { State } from "metabase-types/store";
import { getIsDirty } from "./diff";
export const showRevisionChangedModal = createSelector(
[
getIsDirty,
(state: State) => state.admin.permissions.hasRevisionChanged.hasChanged,
],
(isDirty, hasRevisionChanged) => isDirty && hasRevisionChanged,
);
......@@ -8,11 +8,10 @@ import {
} from "__support__/ui";
import DataPermissionsPage from "metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage";
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import { createMockPermissionsGraph } from "metabase-types/api/mocks/permissions";
import { createMockGroup } from "metabase-types/api/mocks/group";
import {
setupDatabasesEndpoints,
setupPermissionsGraphEndpoint,
setupPermissionsGraphEndpoints,
setupGroupsEndpoint,
} from "__support__/server-mocks";
import DatabasesPermissionsPage from "metabase/admin/permissions/pages/DatabasePermissionsPage/DatabasesPermissionsPage";
......@@ -23,16 +22,15 @@ import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/hooks/use-before-unload"
const TEST_DATABASE = createSampleDatabase();
const TEST_GROUPS = [createMockGroup()];
const TEST_PERMISSIONS_GRAPH = createMockPermissionsGraph({
groups: TEST_GROUPS,
databases: [TEST_DATABASE],
});
// Order is important here for test to pass, since admin options aren't editable
const TEST_GROUPS = [
createMockGroup({ name: "All Users" }),
createMockGroup({ id: 2, name: "Administrators" }),
];
const setup = async () => {
setupDatabasesEndpoints([TEST_DATABASE]);
setupPermissionsGraphEndpoint(TEST_PERMISSIONS_GRAPH);
setupPermissionsGraphEndpoints(TEST_GROUPS, [TEST_DATABASE]);
setupGroupsEndpoint(TEST_GROUPS);
fetchMock.get(
......
......@@ -8,11 +8,10 @@ import {
} from "__support__/ui";
import DataPermissionsPage from "metabase/admin/permissions/pages/DataPermissionsPage/DataPermissionsPage";
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import { createMockPermissionsGraph } from "metabase-types/api/mocks/permissions";
import { createMockGroup } from "metabase-types/api/mocks/group";
import {
setupDatabasesEndpoints,
setupPermissionsGraphEndpoint,
setupPermissionsGraphEndpoints,
setupGroupsEndpoint,
} from "__support__/server-mocks";
import { PLUGIN_ADMIN_PERMISSIONS_TABLE_ROUTES } from "metabase/plugins";
......@@ -28,14 +27,9 @@ const TEST_GROUPS = [
createMockGroup({ name: "All Users" }),
];
const TEST_PERMISSIONS_GRAPH = createMockPermissionsGraph({
groups: TEST_GROUPS,
databases: [TEST_DATABASE],
});
const setup = async () => {
setupDatabasesEndpoints([TEST_DATABASE]);
setupPermissionsGraphEndpoint(TEST_PERMISSIONS_GRAPH);
setupPermissionsGraphEndpoints(TEST_GROUPS, [TEST_DATABASE]);
setupGroupsEndpoint(TEST_GROUPS);
fetchMock.get(
......
......@@ -452,6 +452,8 @@ export const PermissionsApi = {
groups: GET("/api/permissions/group"),
groupDetails: GET("/api/permissions/group/:id"),
graph: GET("/api/permissions/graph"),
graphForGroup: GET("/api/permissions/graph/group/:groupId"),
graphForDB: GET("/api/permissions/graph/db/:databaseId"),
updateGraph: PUT("/api/permissions/graph"),
createGroup: POST("/api/permissions/group"),
memberships: GET("/api/permissions/membership"),
......
import fetchMock from "fetch-mock";
import type {
CollectionPermissionsGraph,
PermissionsGraph,
Group,
Database,
} from "metabase-types/api";
export const setupPermissionsGraphEndpoint = (
permissionsGraph: PermissionsGraph,
import { createMockPermissionsGraph } from "metabase-types/api/mocks/permissions";
export const setupPermissionsGraphEndpoints = (
groups: Omit<Group, "members">[],
databases: Database[],
) => {
fetchMock.get("path:/api/permissions/graph", permissionsGraph);
fetchMock.get(
"path:/api/permissions/graph",
createMockPermissionsGraph({ groups, databases }),
);
groups.forEach(group => {
fetchMock.get(
`path:/api/permissions/graph/group/${group.id}`,
createMockPermissionsGraph({ groups: [group], databases }),
);
});
databases.forEach(database => {
fetchMock.get(
`path:/api/permissions/graph/db/${database.id}`,
createMockPermissionsGraph({ groups, databases: [database] }),
);
});
};
export const setupCollectionPermissionsGraphEndpoint = (
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment