gristlabs_grist-core/app/client/aclui/PermissionsWidget.ts
Paul Fitzpatrick a1a84d99c0 (core) alert user if they try to use rec in a column rule controlling read permission
Summary:
This particular combination of features is not built out - data will be
censored but changes to data will not.  So the user will now get an error
if they try to do it.  Existing rules of this kind will continue to
operate as before, and can be set via the api.

Test Plan: added test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2751
2021-03-10 11:57:09 -05:00

180 lines
6.0 KiB
TypeScript

/**
* Implements a widget showing 3-state boxes for permissions
* (for Allow / Deny / Pass-Through).
*/
import {colors, testId} from 'app/client/ui2018/cssVars';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem} from 'app/client/ui2018/menus';
import {PartialPermissionSet, PartialPermissionValue} from 'app/common/ACLPermissions';
import {ALL_PERMISSION_PROPS, emptyPermissionSet} from 'app/common/ACLPermissions';
import {capitalize} from 'app/common/gutil';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
// One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PartialPermissionSet;
/**
* Renders a box for each of availableBits, and a dropdown with a description and some shortcuts.
*/
export function permissionsWidget(
availableBits: PermissionKey[],
pset: Observable<PartialPermissionSet>,
options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void},
...args: DomElementArg[]
) {
// These are the permission sets available to set via the dropdown.
const empty: PartialPermissionSet = emptyPermissionSet();
const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow');
const denyAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'deny');
const readOnly: PartialPermissionSet = makePermissionSet(availableBits, (b) => b === 'read' ? 'allow' : 'deny');
const setPermissions = (p: PartialPermissionSet) => {
options.sanityCheck?.(p);
pset.set(p);
};
return cssPermissions(
dom.forEach(availableBits, (bit) => {
return cssBit(
bit.slice(0, 1).toUpperCase(), // Show the first letter of the property (e.g. "R" for "read")
cssBit.cls((use) => '-' + use(pset)[bit]), // -allow, -deny class suffixes.
dom.attr('title', (use) => capitalize(`${use(pset)[bit]} ${bit}`.trim())), // Explanation on hover
dom.cls('disabled', options.disabled),
// Cycle the bit's value on click, unless disabled.
(options.disabled ? null :
dom.on('click', () => setPermissions({...pset.get(), [bit]: next(pset.get()[bit])}))
)
);
}),
cssIconButton(icon('Dropdown'), testId('permissions-dropdown'), menu(() => {
// Show a disabled "Custom" menu item if the permission set isn't a recognized one, for
// information purposes.
const isCustom = [allowAll, denyAll, readOnly, empty].every(ps => !isEqual(ps, pset.get()));
return [
(isCustom ?
cssMenuItem(() => null, dom.cls('disabled'), menuIcon('Tick'),
cssMenuItemContent(
'Custom',
cssMenuItemDetails(dom.text((use) => psetDescription(use(pset))))
),
) :
null
),
// If the set matches any recognized pattern, mark that item with a tick (checkmark).
cssMenuItem(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), 'Allow All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), 'Deny All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), 'Read Only',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(empty),
// For the empty permission set, it seems clearer to describe it as "No Effect", but to
// all it "Clear" when offering to the user as the action.
isEqual(pset.get(), empty) ? [tick(true), 'No Effect'] : [tick(false), 'Clear'],
dom.cls('disabled', options.disabled),
),
];
})),
...args
);
}
function next(pvalue: PartialPermissionValue): PartialPermissionValue {
switch (pvalue) {
case 'allow': return '';
case 'deny': return 'allow';
}
return 'deny';
}
// Helper to build up permission sets.
function makePermissionSet(bits: PermissionKey[], makeValue: (bit: PermissionKey) => PartialPermissionValue) {
const pset = emptyPermissionSet();
for (const bit of bits) {
pset[bit] = makeValue(bit);
}
return pset;
}
// Helper for a tick (checkmark) icon, replacing it with an equialent space when not shown.
function tick(show: boolean) {
return show ? menuIcon('Tick') : cssMenuIconSpace();
}
// Human-readable summary of the permission set. E.g. "Allow Read. Deny Update, Create.".
function psetDescription(permissionSet: PartialPermissionSet): string {
const allow: string[] = [];
const deny: string[] = [];
for (const prop of ALL_PERMISSION_PROPS) {
const value = permissionSet[prop];
if (value === "allow") {
allow.push(capitalize(prop));
} else if (value === "deny") {
deny.push(capitalize(prop));
}
}
const parts: string[] = [];
if (allow.length) { parts.push(`Allow ${allow.join(", ")}.`); }
if (deny.length) { parts.push(`Deny ${deny.join(", ")}.`); }
return parts.join(' ');
}
const cssPermissions = styled('div', `
display: flex;
gap: 4px;
`);
const cssBit = styled('div', `
flex: none;
height: 24px;
width: 24px;
border-radius: 2px;
font-size: 13px;
font-weight: 500;
border: 1px dashed ${colors.darkGrey};
color: ${colors.darkGrey};
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&-allow {
background-color: ${colors.lightGreen};
border: 1px solid ${colors.lightGreen};
color: white;
}
&-deny {
background-image: linear-gradient(-45deg, ${colors.error} 14px, white 15px 16px, ${colors.error} 16px);
border: 1px solid ${colors.error};
color: white;
}
&.disabled {
opacity: 0.5;
}
`);
const cssMenuIconSpace = styled('div', `
width: 24px;
`);
// Don't make disabled item too hard to see here.
const cssMenuItem = styled(menuItem, `
align-items: start;
&.disabled {
opacity: unset;
}
`);
const cssMenuItemContent = styled('div', `
display: flex;
flex-direction: column;
`);
const cssMenuItemDetails = styled('div', `
font-size: 12px;
`);