You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/ui/AccessRules.ts

192 lines
6.4 KiB

/**
* UI for managing granular ACLs.
*/
import {GristDoc} from 'app/client/components/GristDoc';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {primaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem, select} from 'app/client/ui2018/menus';
import {arrayRepeat, setDifference} from 'app/common/gutil';
import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
interface AclState {
ownerOnlyTableIds: Set<string>;
ownerOnlyStructure: boolean;
}
function buildAclState(gristDoc: GristDoc): AclState {
const ownerOnlyTableIds = new Set<string>();
let ownerOnlyStructure = false;
const tableData = gristDoc.docModel.aclResources.tableData;
for (const res of tableData.getRecords()) {
const code = String(res.colIds);
if (res.tableId && code === '~o') {
ownerOnlyTableIds.add(String(res.tableId));
}
if (!res.tableId && code === '~o structure') {
ownerOnlyStructure = true;
}
}
return {ownerOnlyTableIds, ownerOnlyStructure};
}
export class AccessRules extends Disposable {
public isAnythingChanged: Computed<boolean>;
// NOTE: For the time being, rules correspond one to one with resources.
private _initialState: AclState = buildAclState(this._gristDoc);
private _allTableIds: ObsArray<string> = createObsArray(this, this._gristDoc.docModel.allTableIds);
private _ownerOnlyTableIds = this.autoDispose(obsArray([...this._initialState.ownerOnlyTableIds]));
private _ownerOnlyStructure = Observable.create<boolean>(this, this._initialState.ownerOnlyStructure);
private _currentState = Computed.create<AclState>(this, (use) => ({
ownerOnlyTableIds: new Set(use(this._ownerOnlyTableIds)),
ownerOnlyStructure: use(this._ownerOnlyStructure),
}));
constructor(private _gristDoc: GristDoc) {
super();
this.isAnythingChanged = Computed.create(this, (use) =>
!isEqual(use(this._currentState), this._initialState));
}
public async save(): Promise<void> {
if (!this.isAnythingChanged.get()) { return; }
// If anything has changed, we re-fetch the state from the current docModel (it may have been
// changed by other users), and apply changes, if any, relative to that.
const latestState = buildAclState(this._gristDoc);
const currentState = this._currentState.get();
const tableData = this._gristDoc.docModel.aclResources.tableData;
await tableData.docData.bundleActions('Update Access Rules', async () => {
// If ownerOnlyStructure flag changed, add or remove the relevant resource record.
if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) {
if (currentState.ownerOnlyStructure) {
await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds: "~o structure"}]);
} else {
const rowId = tableData.findMatchingRowId({tableId: '', colIds: '~o structure'});
if (rowId) {
await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]);
}
}
}
// Handle tables added to ownerOnlyTableIds.
const tablesAdded = setDifference(currentState.ownerOnlyTableIds, latestState.ownerOnlyTableIds);
if (tablesAdded.size) {
await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), {
tableId: [...tablesAdded],
colIds: arrayRepeat(tablesAdded.size, "~o"),
}]);
}
// Handle table removed from ownerOnlyTaleIds.
const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds);
if (tablesRemoved.size) {
const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r);
await tableData.sendTableAction(['BulkRemoveRecord', rowIds]);
}
});
}
public buildDom() {
return [
cssAddTableRow(
primaryButton(icon('Plus'), 'Add Table Rules',
menu(() => [
dom.forEach(this._allTableIds, (tableId) =>
// Add the table on a timeout, to avoid disabling the clicked menu item
// synchronously, which prevents the menu from closing on click.
menuItem(() => setTimeout(() => this._ownerOnlyTableIds.push(tableId), 0),
tableId,
dom.cls('disabled', (use) => use(this._ownerOnlyTableIds).includes(tableId)),
)
),
]),
),
),
shadowScroll(
dom.forEach(this._ownerOnlyTableIds, (tableId) => {
return cssTableRule(
cssTableHeader(
dom('div', 'Rules for ', dom('b', dom.text(tableId))),
cssRemove(icon('Remove'),
dom.on('click', () =>
this._ownerOnlyTableIds.splice(this._ownerOnlyTableIds.get().indexOf(tableId), 1))
),
),
cssTableBody(
cssPermissions('All Access'),
cssPrincipals('Owners'),
),
);
}),
cssTableRule(
cssTableHeader('Default Rule'),
cssTableBody(
cssPermissions('Schema Edit'),
cssPrincipals(
select(this._ownerOnlyStructure, [
{label: 'Owners Only', value: true},
{label: 'Owners & Editors', value: false}
]),
)
),
),
),
];
}
}
const cssAddTableRow = styled('div', `
margin: 0 64px 16px 64px;
display: flex;
justify-content: flex-end;
`);
const cssTableRule = styled('div', `
margin: 16px 64px;
border: 1px solid ${colors.darkGrey};
border-radius: 4px;
padding: 8px 16px 16px 16px;
`);
const cssTableHeader = styled('div', `
display: flex;
align-items: center;
margin-bottom: 8px;
`);
const cssTableBody = styled('div', `
display: flex;
align-items: center;
`);
const cssPermissions = styled('div', `
flex: 1;
white-space: nowrap;
color: ${colors.lightGreen};
`);
const cssPrincipals = styled('div', `
flex: 1;
color: ${colors.lightGreen};
`);
const cssRemove = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.darkGrey};
--icon-color: ${colors.slate};
}
`);