2020-12-21 22:14:40 +00:00
|
|
|
/**
|
|
|
|
* 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>,
|
2021-03-10 14:08:46 +00:00
|
|
|
options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void},
|
2020-12-21 22:14:40 +00:00
|
|
|
...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');
|
2021-03-10 14:08:46 +00:00
|
|
|
const setPermissions = (p: PartialPermissionSet) => {
|
|
|
|
options.sanityCheck?.(p);
|
|
|
|
pset.set(p);
|
|
|
|
};
|
2020-12-21 22:14:40 +00:00
|
|
|
|
|
|
|
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 :
|
2021-03-10 14:08:46 +00:00
|
|
|
dom.on('click', () => setPermissions({...pset.get(), [bit]: next(pset.get()[bit])}))
|
2020-12-21 22:14:40 +00:00
|
|
|
)
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
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).
|
2021-03-10 14:08:46 +00:00
|
|
|
cssMenuItem(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), 'Allow All',
|
2020-12-21 22:14:40 +00:00
|
|
|
dom.cls('disabled', options.disabled)
|
|
|
|
),
|
2021-03-10 14:08:46 +00:00
|
|
|
cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), 'Deny All',
|
2020-12-21 22:14:40 +00:00
|
|
|
dom.cls('disabled', options.disabled)
|
|
|
|
),
|
2021-03-10 14:08:46 +00:00
|
|
|
cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), 'Read Only',
|
2020-12-21 22:14:40 +00:00
|
|
|
dom.cls('disabled', options.disabled)
|
|
|
|
),
|
2021-03-10 14:08:46 +00:00
|
|
|
cssMenuItem(() => setPermissions(empty),
|
2020-12-21 22:14:40 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2022-02-19 09:46:49 +00:00
|
|
|
// Helper for a tick (checkmark) icon, replacing it with an equivalent space when not shown.
|
2020-12-21 22:14:40 +00:00
|
|
|
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;
|
|
|
|
`);
|