@commercetools-frontend/permissions
Version:
React components to declaratively handle MC permissions
407 lines (375 loc) • 22.5 kB
JavaScript
import { useEffect, Children, createElement } from 'react';
import isNil from 'lodash/isNil';
import invariant from 'tiny-invariant';
import _Object$keys from '@babel/runtime-corejs3/core-js-stable/object/keys';
import _Object$getOwnPropertySymbols from '@babel/runtime-corejs3/core-js-stable/object/get-own-property-symbols';
import _filterInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/filter';
import _Object$getOwnPropertyDescriptor from '@babel/runtime-corejs3/core-js-stable/object/get-own-property-descriptor';
import _forEachInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/for-each';
import _Object$getOwnPropertyDescriptors from '@babel/runtime-corejs3/core-js-stable/object/get-own-property-descriptors';
import _Object$defineProperties from '@babel/runtime-corejs3/core-js-stable/object/define-properties';
import _Object$defineProperty from '@babel/runtime-corejs3/core-js-stable/object/define-property';
import _defineProperty from '@babel/runtime-corejs3/helpers/esm/defineProperty';
import _objectWithoutProperties from '@babel/runtime-corejs3/helpers/esm/objectWithoutProperties';
import warning from 'tiny-warning';
import { useApplicationContext } from '@commercetools-frontend/application-shell-connectors';
import { reportErrorToSentry } from '@commercetools-frontend/sentry';
import _slicedToArray from '@babel/runtime-corejs3/helpers/esm/slicedToArray';
import _startsWithInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/starts-with';
import _includesInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/includes';
import _everyInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/every';
import _someInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/some';
import _valuesInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/values';
import _findInstanceProperty from '@babel/runtime-corejs3/core-js-stable/instance/find';
import _Object$entries from '@babel/runtime-corejs3/core-js-stable/object/entries';
import upperFirst from 'lodash/upperFirst';
import { jsx, Fragment } from '@emotion/react/jsx-runtime';
// NOTE: This string will be replaced on build time with the package version.
var version = "25.0.0";
// Permissions
// Action rights
// Data fences
// Potentially a union type.
// Build the permission key from the definition to match it to the format coming from the API.
const toCanCase = permissionName => "can".concat(permissionName);
const getIsViewPermission = demandedPermission => _startsWithInstanceProperty(demandedPermission).call(demandedPermission, 'View');
const toManageCase = permissionName => permissionName.replace('View', 'Manage');
// Check that the user permissions match EXACTLY the required permission.
// The shapes of the arguments are:
// - demandedPermission:
// 'ViewProducts'
// - actualPermissions:
// { canViewProducts: true, canManageOrders: false }
const hasExactPermission = (demandedPermission, actualPermissions) => actualPermissions[toCanCase(demandedPermission)];
const getInferredManagePermission = (demandedPermission, actualPermissions) => actualPermissions[toCanCase(toManageCase(demandedPermission))];
// Check that the user permissions match the required MANAGE permission.
// The shapes of the arguments are:
// - demandedPermission:
// 'ViewProducts'
// - actualPermissions:
// { canViewProducts: true, canManageOrders: false }
const doesManagePermissionInferViewPermission = (demandedPermission, actualPermissions) => {
const isDemandedPermissionViewPermission = getIsViewPermission(demandedPermission);
const isViewPermissionInferredByManagePermission = Boolean(getInferredManagePermission(demandedPermission, actualPermissions));
return isDemandedPermissionViewPermission && isViewPermissionInferredByManagePermission;
};
// Check if the demanded permissions contain any unnecessary
// implied permissions.
// E.g. ViewCustomerGroup is implied by ManageCustomerGroup.
const getImpliedPermissions = permissions => {
const viewPermissions = _filterInstanceProperty(permissions).call(permissions, permission => _startsWithInstanceProperty(permission).call(permission, 'View'));
const impliedPermissions = _filterInstanceProperty(viewPermissions).call(viewPermissions, viewPermission => _includesInstanceProperty(permissions).call(permissions, toManageCase(viewPermission)));
return impliedPermissions;
};
// Check the user permissions using one of the defined matchers.
// The shapes of the arguments are:
// - demandedPermission:
// 'ViewProducts'
// - actualPermissions:
// { canViewProducts: true, canManageOrders: false }
// NOTE: in case the `actualPermissions` are not defined, fall back to an empty object.
// This might be the case when the permissions for a user/project could not be loaded
// (e.g. project not found).
const hasPermission = (demandedPermission, actualPermissions) =>
// First checking the existence of the exact permission
hasExactPermission(demandedPermission, actualPermissions || {}) ||
// Then checking if a view permission is inferred as a manage permission
doesManagePermissionInferViewPermission(demandedPermission, actualPermissions || {});
// Check the user action rights using one of the defined matchers.
// The shapes of the arguments are:
// - demandedActionRight:
// '{ group: 'products', name: 'editPrices' }'
// - actualActionRights:
// { orders: { canEditPrices: false }, products: { canEditPrices: true } }
const hasActionRight = (demandedActionRight, actualActionRights) => {
const actionRightGroup = actualActionRights && actualActionRights[demandedActionRight.group];
return Boolean(actionRightGroup && actionRightGroup[toCanCase(demandedActionRight.name)]);
};
// Check that the user permissions match EVERY one of the required permissions.
// The shapes of the arguments are:
// - demandedPermissions:
// ['ViewProducts', 'ManageOrders']
// - actualPermissions:
// { canViewProducts: true, canManageOrders: false }
const hasEveryPermissions = (demandedPermissions, actualPermissions) => _everyInstanceProperty(demandedPermissions).call(demandedPermissions, permission => hasPermission(permission, actualPermissions));
// Check that the user action rights match EVERY one of the required action rights.
// The shapes of the arguments are:
// - demandedActionRights:
// [
// { group: 'products', name: 'editPrices' },
// { group: 'products', name: 'publishProducts' },
// ]
// - actualActionRights:
// { products: { canEditPrices: true, canPublishProducts: true } }
const hasEveryActionRight = (demandedActionRights, actualActionRights) => _everyInstanceProperty(demandedActionRights).call(demandedActionRights, actionRight => hasActionRight(actionRight, actualActionRights));
// Check that the user permissions match SOME one of the required permissions.
// The shapes of the arguments are:
// - demandedPermissions:
// ['ViewProducts', 'ManageOrders']
// - actualPermissions:
// { canViewProducts: true, canManageOrders: false }
const hasSomePermissions = (demandedPermissions, actualPermissions) => _someInstanceProperty(demandedPermissions).call(demandedPermissions, permission => hasPermission(permission, actualPermissions));
const getHasDemandedDataFence = options => {
if (!options.selectDataFenceData) return false;
const hasDemandedPermission = hasPermission(options.demandedDataFence.name, {
[options.actualDataFence.name]: true
});
if (!hasDemandedPermission) return false;
const selectedDataFenceData = options.selectDataFenceData({
type: options.demandedDataFence.type,
group: options.demandedDataFence.group,
name: options.demandedDataFence.name,
actualDataFenceValues: _valuesInstanceProperty(options.actualDataFence.dataFenceValue)
});
if (!selectedDataFenceData) {
reportErrorToSentry(new Error("missing mapper for type \"".concat(options.demandedDataFence.type, "\"")), {
extra: options.demandedDataFence.type
});
return false;
}
// it is enough to only have a subset of demanded dataFence data belonging to actual dataFence values
return _someInstanceProperty(selectedDataFenceData).call(selectedDataFenceData, value => {
var _context;
return _includesInstanceProperty(_context = _valuesInstanceProperty(options.actualDataFence.dataFenceValue)).call(_context, value);
});
};
const getDataFenceByTypeAndGroup = (actualDataFences, demandedDataFenceType, demandedDataFenceGroup) => {
if (!actualDataFences) return null;
if (demandedDataFenceType in actualDataFences) {
switch (demandedDataFenceType) {
case 'store':
{
const actualDataFence = actualDataFences[demandedDataFenceType];
if (actualDataFence && demandedDataFenceGroup in actualDataFence) {
return actualDataFence[demandedDataFenceGroup];
}
break;
}
}
}
return null;
};
const getIsPermissionOverwritingDataFence = (actualPermissions, demandedDataFence) => {
if (!actualPermissions) return false;
/**
* NOTE:
* A data fence relates, exists in relation to a general permission.
* This relation is constructed by the group of the data fence.
*
* Given the user already has manage access on the data fence's group
* Then the data fence is overruled by it.
* Given the user _does not_ have manage access on the data fence's group
* Then the data fence itself takes precedence
*/
const demandedPermissionByDataFence = "Manage".concat(upperFirst(demandedDataFence.group));
if (hasExactPermission(demandedPermissionByDataFence, actualPermissions)) return true;
return false;
};
const hasSomeDataFence = options => {
var _context2;
// Datafences applied should be combined with an OR, that is why we use
// `some` and not `every`
return _someInstanceProperty(_context2 = options.demandedDataFences).call(_context2, demandedDataFence => {
var _context3;
// Given that dataFence structure on `applicationContext`, we get the value by a path
// e.g given dataFence with { store: { orders: { canManageOrders: { values: } } } }
// we read the dataFence by the [type] and [group]
// dataFence[type][group] = dataFence.store.group
// with value = there is a dataFence to apply, overrules `hasDemandedProjectPermissions`
// without value = there is no dataFence to apply, overruled by `hasDemandedProjectPermissions`
const actualDataFenceByTypeAndGroup = getDataFenceByTypeAndGroup(options.actualDataFences, demandedDataFence.type, demandedDataFence.group);
if (!actualDataFenceByTypeAndGroup) return false;
// we try to find if the demanded dataFence is available inside the actual datafences
const specificActualDataFence = _findInstanceProperty(_context3 = _Object$entries(actualDataFenceByTypeAndGroup)).call(_context3, _ref => {
let _ref2 = _slicedToArray(_ref, 2),
dataFenceName = _ref2[0],
dataFenceValue = _ref2[1];
// Either the manage permission matches or the
// a demanded view permission is implied by a manage permission.
const doesDataFenceMatchByName = dataFenceName === toCanCase(demandedDataFence.name) || dataFenceName === toCanCase(toManageCase(demandedDataFence.name));
const doesDataFenceHaveValue = Boolean(dataFenceValue);
return doesDataFenceMatchByName && doesDataFenceHaveValue;
});
if (!specificActualDataFence) return false;
const _specificActualDataFe = _slicedToArray(specificActualDataFence, 2),
dataFenceName = _specificActualDataFe[0],
dataFenceValue = _specificActualDataFe[1];
const actualDataFence = {
name: dataFenceName,
// we do the type casting because at this point we are sure that
// specificActualDataFence.dataFenceValue is not null
dataFenceValue: dataFenceValue
};
const isPermissionOverwritingDataFence = getIsPermissionOverwritingDataFence(options.actualPermissions, demandedDataFence);
const hasDemandedDataFence = getHasDemandedDataFence({
actualDataFence,
demandedDataFence,
selectDataFenceData: options.selectDataFenceData
});
return !isPermissionOverwritingDataFence && hasDemandedDataFence;
});
};
// Permissions
// Action rights
// Data fences
// Log a warning only once, and not on each render.
const useWarning = (condition, message) => {
useEffect(() => {
process.env.NODE_ENV !== "production" ? warning(condition, message) : void 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
const useIsAuthorized = _ref => {
let demandedPermissions = _ref.demandedPermissions,
demandedActionRights = _ref.demandedActionRights,
demandedDataFences = _ref.demandedDataFences,
selectDataFenceData = _ref.selectDataFenceData,
_ref$shouldMatchSomeP = _ref.shouldMatchSomePermissions,
shouldMatchSomePermissions = _ref$shouldMatchSomeP === void 0 ? false : _ref$shouldMatchSomeP,
projectPermissions = _ref.projectPermissions;
const impliedPermissions = getImpliedPermissions(demandedPermissions);
useWarning(!demandedActionRights || demandedActionRights.length === 1, "@commercetools-frontend/permissions: It is recommended to pass a single demanded action right while using the hook, HoC or component multiple times.");
useWarning(!demandedPermissions || demandedPermissions.length === 1, "@commercetools-frontend/permissions: It is recommended to pass a single demanded permission while using the hook, HoC or component multiple times.");
useWarning(shouldMatchSomePermissions === false, "@commercetools-frontend/permissions: It is recommended not to use 'shouldMatchSomePermissions' but instead use the hook, HoC or component multiple times.");
useWarning(!impliedPermissions || impliedPermissions.length === 0, "@commercetools-frontend/permissions: Demanded permissions contain implied permissions. These are implied: ".concat(impliedPermissions.join(', '), "."));
const actualPermissions = useApplicationContext(applicationContext => {
var _projectPermissions$p;
return (_projectPermissions$p = projectPermissions === null || projectPermissions === void 0 ? void 0 : projectPermissions.permissions) !== null && _projectPermissions$p !== void 0 ? _projectPermissions$p : applicationContext.permissions;
});
const actualActionRights = useApplicationContext(applicationContext => {
var _projectPermissions$a;
return (_projectPermissions$a = projectPermissions === null || projectPermissions === void 0 ? void 0 : projectPermissions.actionRights) !== null && _projectPermissions$a !== void 0 ? _projectPermissions$a : applicationContext.actionRights;
});
const actualDataFences = useApplicationContext(applicationContext => {
var _projectPermissions$d;
return (_projectPermissions$d = projectPermissions === null || projectPermissions === void 0 ? void 0 : projectPermissions.dataFences) !== null && _projectPermissions$d !== void 0 ? _projectPermissions$d : applicationContext.dataFences;
});
// if the user has no permissions and no dataFences assigned to them, they are not authorized
if (!actualPermissions && !actualDataFences) return false;
let hasDemandedDataFences = false;
if (demandedDataFences && demandedDataFences.length > 0) {
if (!selectDataFenceData) {
reportErrorToSentry(new Error("@commercetools-frontend/permissions/Authorized: Missing data fences selector \"selectDataFenceData\"."));
}
hasDemandedDataFences = hasSomeDataFence({
actualPermissions,
demandedDataFences,
actualDataFences,
selectDataFenceData
});
}
const hasDemandedPermissions = shouldMatchSomePermissions ? hasSomePermissions(demandedPermissions, actualPermissions) : hasEveryPermissions(demandedPermissions, actualPermissions);
const hasDemandedActionRights = hasEveryActionRight(demandedActionRights || [], actualActionRights);
return hasDemandedDataFences || hasDemandedPermissions && hasDemandedActionRights;
};
// From https://github.com/acdlite/recompose/blob/master/src/packages/recompose/getDisplayName.js
const getDisplayName = Component => {
if (typeof Component === 'string') {
return Component;
}
if (!Component) {
return undefined;
}
return Component.displayName || Component.name || 'Component';
};
const _excluded = ["shouldMatchSomePermissions"];
function ownKeys$1(e, r) { var t = _Object$keys(e); if (_Object$getOwnPropertySymbols) { var o = _Object$getOwnPropertySymbols(e); r && (o = _filterInstanceProperty(o).call(o, function (r) { return _Object$getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread$1(e) { for (var r = 1; r < arguments.length; r++) { var _context, _context2; var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? _forEachInstanceProperty(_context = ownKeys$1(Object(t), !0)).call(_context, function (r) { _defineProperty(e, r, t[r]); }) : _Object$getOwnPropertyDescriptors ? _Object$defineProperties(e, _Object$getOwnPropertyDescriptors(t)) : _forEachInstanceProperty(_context2 = ownKeys$1(Object(t))).call(_context2, function (r) { _Object$defineProperty(e, r, _Object$getOwnPropertyDescriptor(t, r)); }); } return e; }
const Authorized = _ref => {
let _ref$shouldMatchSomeP = _ref.shouldMatchSomePermissions,
shouldMatchSomePermissions = _ref$shouldMatchSomeP === void 0 ? false : _ref$shouldMatchSomeP,
props = _objectWithoutProperties(_ref, _excluded);
const isAuthorized = useIsAuthorized({
demandedPermissions: props.demandedPermissions,
demandedActionRights: props.demandedActionRights,
demandedDataFences: props.demandedDataFences,
selectDataFenceData: props.selectDataFenceData,
shouldMatchSomePermissions: shouldMatchSomePermissions,
projectPermissions: props.projectPermissions
});
return jsx(Fragment, {
children: props.render(isAuthorized)
});
};
Authorized.displayName = 'Authorized';
const injectAuthorized = function (demandedPermissions) {
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
let propName = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'isAuthorized';
return Component => {
const WrappedComponent = props => jsx(Authorized, {
shouldMatchSomePermissions: options.shouldMatchSomePermissions,
demandedPermissions: demandedPermissions,
demandedActionRights: options.actionRights,
demandedDataFences: options.dataFences,
selectDataFenceData: options.getSelectDataFenceData && options.getSelectDataFenceData(props),
render: isAuthorized =>
// @ts-ignore: relates to https://github.com/emotion-js/emotion/issues/3245
jsx(Component, _objectSpread$1(_objectSpread$1({}, props), {}, {
[propName]: isAuthorized
}))
});
WrappedComponent.displayName = "withUserPermissions(".concat(getDisplayName(Component), ")");
return WrappedComponent;
};
};
const getHasChildren = children => Children.count(children) > 0;
// Permissions
// Action rights
// Data fences
const RestrictedByPermissions = props => {
!!(typeof props.children === 'function' && !isNil(props.unauthorizedComponent)) ? process.env.NODE_ENV !== "production" ? invariant(false, '@commercetools-frontend/permissions/RestrictedByPermissions: You provided both `children` and `unauthorizedComponent`. Please provide only one of them.') : invariant(false) : void 0;
return jsx(Authorized, {
shouldMatchSomePermissions: props.shouldMatchSomePermissions,
demandedPermissions: props.permissions,
demandedActionRights: props.actionRights,
demandedDataFences: props.dataFences,
selectDataFenceData: props.selectDataFenceData,
projectPermissions: props.projectPermissions,
render: isAuthorized => {
if (typeof props.children === 'function') return props.children({
isAuthorized
});
if (typeof props.render === 'function') return props.render({
isAuthorized
});
if (isAuthorized) {
if (props.children && getHasChildren(props.children)) return Children.only(props.children);
} else if (!isAuthorized) {
if (props.unauthorizedComponent) {
return /*#__PURE__*/createElement(props.unauthorizedComponent);
}
}
return null;
}
});
};
RestrictedByPermissions.displayName = 'RestrictedByPermissions';
function ownKeys(e, r) { var t = _Object$keys(e); if (_Object$getOwnPropertySymbols) { var o = _Object$getOwnPropertySymbols(e); r && (o = _filterInstanceProperty(o).call(o, function (r) { return _Object$getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var _context, _context2; var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? _forEachInstanceProperty(_context = ownKeys(Object(t), !0)).call(_context, function (r) { _defineProperty(e, r, t[r]); }) : _Object$getOwnPropertyDescriptors ? _Object$defineProperties(e, _Object$getOwnPropertyDescriptors(t)) : _forEachInstanceProperty(_context2 = ownKeys(Object(t))).call(_context2, function (r) { _Object$defineProperty(e, r, _Object$getOwnPropertyDescriptor(t, r)); }); } return e; }
const branchOnPermissions = function (demandedPermissions, FallbackComponent) {
let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {
shouldMatchSomePermissions: false
};
return Component => {
const WrappedComponent = props => jsx(Authorized, {
shouldMatchSomePermissions: options.shouldMatchSomePermissions,
demandedPermissions: demandedPermissions,
demandedActionRights: options.actionRights,
demandedDataFences: options.dataFences,
selectDataFenceData: options.getSelectDataFenceData && options.getSelectDataFenceData(props),
render: isAuthorized => {
if (isAuthorized) {
// @ts-ignore: relates to https://github.com/emotion-js/emotion/issues/3245
return jsx(Component, _objectSpread({}, props));
}
if (FallbackComponent) {
return jsx(FallbackComponent, {});
}
return jsx(Fragment, {});
}
});
WrappedComponent.displayName = "branchOnPermissions(".concat(getDisplayName(Component), ")");
return WrappedComponent;
};
};
export { Authorized, RestrictedByPermissions, branchOnPermissions, hasEveryPermissions, hasSomePermissions, injectAuthorized, useIsAuthorized, version };