/** * 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'); import {makeT} from 'app/client/lib/localization'; // One of the strings 'read', 'update', etc. export type PermissionKey = keyof PartialPermissionSet; const t = makeT('PermissionsWidget'); /** * Renders a box for each of availableBits, and a dropdown with a description and some shortcuts. */ export function permissionsWidget( availableBits: PermissionKey[], pset: Observable, 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)), t("Allow All"), dom.cls('disabled', options.disabled) ), cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), t("Deny All"), dom.cls('disabled', options.disabled) ), cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), t("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 equivalent 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; `);