mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Implement much of the general AccessRules UI.
Summary: - Factored out ACLRuleCollection into its own file, and use for building UI. - Moved AccessRules out of UserManager to a page linked from left panel. - Changed default RulePart to be the last part of a rule for simpler code. - Implemented much of the UI for adding/deleting rules. - For now, editing the ACLFormula and Permissions is done using text inputs. - Implemented saving rules by syncing a bundle of them. - Fixed DocData to clean up action bundle in case of an early error. Test Plan: WIP planning to add some new browser tests for the UI Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2678
This commit is contained in:
@@ -3,197 +3,852 @@
|
||||
*/
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
||||
import {reportError, UserError} from 'app/client/models/errors';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menu, menuItem, select} from 'app/client/ui2018/menus';
|
||||
import {readAclRules} from 'app/common/GranularAccessClause';
|
||||
import {setDifference} from 'app/common/gutil';
|
||||
import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs';
|
||||
import {menu, menuItemAsync} from 'app/client/ui2018/menus';
|
||||
import {emptyPermissionSet, parsePermissions} from 'app/common/ACLPermissions';
|
||||
import {PartialPermissionSet, permissionSetToText} from 'app/common/ACLPermissions';
|
||||
import {ACLRuleCollection} from 'app/common/ACLRuleCollection';
|
||||
import {BulkColValues, RowRecord, UserAction} from 'app/common/DocActions';
|
||||
import {RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
|
||||
import {isObject} from 'app/common/gutil';
|
||||
import {SchemaTypes} from 'app/common/schema';
|
||||
import {BaseObservable, Computed, Disposable, dom, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
|
||||
interface AclState {
|
||||
ownerOnlyTableIds: Set<string>;
|
||||
ownerOnlyStructure: boolean;
|
||||
}
|
||||
// tslint:disable:max-classes-per-file no-console
|
||||
|
||||
const MATCH_NON_OWNER = 'user.Access != "owners"';
|
||||
// Types for the rows in the ACL tables we use.
|
||||
type ResourceRec = SchemaTypes["_grist_ACLResources"] & {id?: number};
|
||||
type RuleRec = Partial<SchemaTypes["_grist_ACLRules"]> & {id?: number, resourceRec?: ResourceRec};
|
||||
|
||||
function buildAclState(gristDoc: GristDoc): AclState {
|
||||
const {ruleSets} = readAclRules(gristDoc.docData, {log: console});
|
||||
console.log("FOUND RULE SETS", ruleSets);
|
||||
|
||||
const ownerOnlyTableIds = new Set<string>();
|
||||
let ownerOnlyStructure = false;
|
||||
for (const ruleSet of ruleSets) {
|
||||
if (ruleSet.tableId === '*' && ruleSet.colIds === '*') {
|
||||
if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === '-S')) {
|
||||
ownerOnlyStructure = true;
|
||||
}
|
||||
} else if (ruleSet.tableId !== '*' && ruleSet.colIds === '*') {
|
||||
if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === 'none')) {
|
||||
ownerOnlyTableIds.add(ruleSet.tableId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {ownerOnlyTableIds, ownerOnlyStructure};
|
||||
}
|
||||
type UseCB = <T>(obs: BaseObservable<T>) => T;
|
||||
|
||||
/**
|
||||
* Top-most container managing state and dom-building for the ACL rule UI.
|
||||
*/
|
||||
export class AccessRules extends Disposable {
|
||||
// Whether anything has changed, i.e. whether to show a "Save" button.
|
||||
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);
|
||||
// Parsed rules obtained from DocData during last call to update(). Used for isAnythingChanged.
|
||||
private _ruleCollection = new ACLRuleCollection();
|
||||
|
||||
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),
|
||||
}));
|
||||
// Array of all per-table rules.
|
||||
private _tableRules = this.autoDispose(obsArray<TableRules>());
|
||||
|
||||
// The default rule set for the document (for "*:*").
|
||||
private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
|
||||
|
||||
// Array of all tableIds in the document (for adding new per-table rules).
|
||||
private _allTableIds = createObsArray(this, this._gristDoc.docModel.allTableIds);
|
||||
|
||||
// Array of all UserAttribute rules.
|
||||
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
|
||||
|
||||
constructor(private _gristDoc: GristDoc) {
|
||||
super();
|
||||
this.isAnythingChanged = Computed.create(this, (use) =>
|
||||
!isEqual(use(this._currentState), this._initialState));
|
||||
this.isAnythingChanged = Computed.create(this, (use) => {
|
||||
const defRuleSet = use(this._docDefaultRuleSet);
|
||||
const tableRules = use(this._tableRules);
|
||||
const userAttr = use(this._userAttrRules);
|
||||
return (defRuleSet && use(defRuleSet.isChanged)) ||
|
||||
// If any table was changed or added, some t.isChanged will be set. If there were only
|
||||
// removals, then tableRules.length will have changed.
|
||||
tableRules.length !== this._ruleCollection.getAllTableIds().length ||
|
||||
tableRules.some(t => use(t.isChanged)) ||
|
||||
userAttr.length !== this._ruleCollection.getUserAttributeRules().size ||
|
||||
userAttr.some(u => use(u.isChanged));
|
||||
});
|
||||
this.update().catch(reportError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace internal state from the rules in DocData.
|
||||
*/
|
||||
public async update() {
|
||||
const rules = this._ruleCollection;
|
||||
await rules.update(this._gristDoc.docData, {log: console});
|
||||
this._tableRules.set(
|
||||
rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules,
|
||||
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
|
||||
);
|
||||
DefaultObsRuleSet.create(this._docDefaultRuleSet, null, undefined, rules.getDocDefaultRuleSet());
|
||||
this._userAttrRules.set(
|
||||
Array.from(rules.getUserAttributeRules().values(), userAttr =>
|
||||
ObsUserAttributeRule.create(this._userAttrRules, this, userAttr))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the internal state into records and sync them to the document.
|
||||
*/
|
||||
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();
|
||||
|
||||
// Note that if anything has changed, we apply changes relative to the current state of the
|
||||
// ACL tables (they may have changed by other users). So our changes will win.
|
||||
// TODO: There is a race condition if two people save different rules at the same time, since
|
||||
// it's a two-step operation (syncing resources and rules).
|
||||
|
||||
const docData = this._gristDoc.docData;
|
||||
const resourcesTable = docData.getTable('_grist_ACLResources')!;
|
||||
const rulesTable = docData.getTable('_grist_ACLRules')!;
|
||||
await this._gristDoc.docData.bundleActions('Update Access Rules', async () => {
|
||||
// If ownerOnlyStructure flag changed, add or remove the relevant resource record.
|
||||
const defaultResource = resourcesTable.findMatchingRowId({tableId: '*', colIds: '*'}) ||
|
||||
await resourcesTable.sendTableAction(['AddRecord', null, {tableId: '*', colIds: '*'}]);
|
||||
const ruleObj = {resource: defaultResource, aclFormula: MATCH_NON_OWNER, permissionsText: '-S'};
|
||||
const ruleRowId = rulesTable.findMatchingRowId(ruleObj);
|
||||
if (currentState.ownerOnlyStructure && !ruleRowId) {
|
||||
await rulesTable.sendTableAction(['AddRecord', null, ruleObj]);
|
||||
} else if (!currentState.ownerOnlyStructure && ruleRowId) {
|
||||
await rulesTable.sendTableAction(['RemoveRecord', ruleRowId]);
|
||||
|
||||
await docData.bundleActions(null, async () => {
|
||||
|
||||
// Add/remove resources to have just the ones we need.
|
||||
const newResources: RowRecord[] = flatten(
|
||||
[{tableId: '*', colIds: '*'}], ...this._tableRules.get().map(t => t.getResources()))
|
||||
.map(r => ({id: -1, ...r}));
|
||||
const newResourceMap = await syncRecords(resourcesTable, newResources, serializeResource);
|
||||
|
||||
// For syncing rules, we'll go by rowId that we store with each RulePart and with the RuleSet.
|
||||
// New rules will get temporary negative rowIds.
|
||||
let nextId: number = -1;
|
||||
const newRules: RowRecord[] = [];
|
||||
for (const rule of this.getRules()) {
|
||||
// We use id of 0 internally to mark built-in rules. Skip those.
|
||||
if (rule.id === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up the rowId for the resource.
|
||||
const resourceKey = serializeResource(rule.resourceRec as RowRecord);
|
||||
const resourceRowId = newResourceMap.get(resourceKey);
|
||||
if (!resourceRowId) {
|
||||
throw new Error(`Resource missing in resource map: ${resourceKey}`);
|
||||
}
|
||||
newRules.push({
|
||||
id: rule.id || (nextId--),
|
||||
resource: resourceRowId,
|
||||
aclFormula: rule.aclFormula!,
|
||||
permissionsText: rule.permissionsText!,
|
||||
rulePos: rule.rulePos || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle tables added to ownerOnlyTableIds.
|
||||
const tablesAdded = setDifference(currentState.ownerOnlyTableIds, latestState.ownerOnlyTableIds);
|
||||
for (const tableId of tablesAdded) {
|
||||
const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'}) ||
|
||||
await resourcesTable.sendTableAction(['AddRecord', null, {tableId, colIds: '*'}]);
|
||||
await rulesTable.sendTableAction(
|
||||
['AddRecord', null, {resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'}]);
|
||||
// UserAttribute rules are listed in the same rulesTable.
|
||||
const defaultResourceRowId = newResourceMap.get(serializeResource({id: -1, tableId: '*', colIds: '*'}));
|
||||
if (!defaultResourceRowId) {
|
||||
throw new Error('Default resource missing in resource map');
|
||||
}
|
||||
// Handle table removed from ownerOnlyTableIds.
|
||||
const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds);
|
||||
for (const tableId of tablesRemoved) {
|
||||
const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'});
|
||||
if (resource) {
|
||||
const rowId = rulesTable.findMatchingRowId({resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'});
|
||||
if (rowId) {
|
||||
await rulesTable.sendTableAction(['RemoveRecord', rowId]);
|
||||
for (const userAttr of this._userAttrRules.get()) {
|
||||
const rule = userAttr.getRule();
|
||||
newRules.push({
|
||||
id: rule.id || (nextId--),
|
||||
resource: defaultResourceRowId,
|
||||
rulePos: rule.rulePos || null,
|
||||
userAttributes: rule.userAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
// We need to fill in rulePos values. We'll add them in the order the rules are listed (since
|
||||
// this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible.
|
||||
let lastGoodRulePos = 0;
|
||||
let lastGoodIndex = -1;
|
||||
for (let i = 0; i < newRules.length; i++) {
|
||||
const pos = newRules[i].rulePos as number;
|
||||
if (pos && pos > lastGoodRulePos) {
|
||||
const step = (pos - lastGoodRulePos) / (i - lastGoodIndex);
|
||||
for (let k = lastGoodIndex + 1; k < i; k++) {
|
||||
newRules[k].rulePos = step * (k - lastGoodIndex);
|
||||
}
|
||||
lastGoodRulePos = pos;
|
||||
lastGoodIndex = i;
|
||||
}
|
||||
}
|
||||
// Fill in the rulePos values for the remaining rules.
|
||||
for (let k = lastGoodIndex + 1; k < newRules.length; k++) {
|
||||
newRules[k].rulePos = ++lastGoodRulePos;
|
||||
}
|
||||
// Finally we can sync the records.
|
||||
await syncRecords(rulesTable, newRules);
|
||||
});
|
||||
|
||||
// Re-populate the state from DocData once the records are synced.
|
||||
await this.update();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return [
|
||||
cssAddTableRow(
|
||||
primaryButton(icon('Plus'), 'Add Table Rules',
|
||||
bigBasicButton('Saved', {disabled: true}, dom.hide(this.isAnythingChanged)),
|
||||
bigPrimaryButton('Save', dom.show(this.isAnythingChanged),
|
||||
dom.on('click', () => this.save()),
|
||||
testId('rules-save'),
|
||||
),
|
||||
bigBasicButton('Revert', dom.show(this.isAnythingChanged),
|
||||
dom.on('click', () => this.update()),
|
||||
testId('rules-revert'),
|
||||
),
|
||||
|
||||
bigBasicButton('Add Table Rules', {style: 'margin-left: auto'},
|
||||
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),
|
||||
menuItemAsync(() => this._addTableRules(tableId),
|
||||
tableId,
|
||||
dom.cls('disabled', (use) => use(this._ownerOnlyTableIds).includes(tableId)),
|
||||
dom.cls('disabled', (use) => use(this._tableRules).some(t => t.tableId === tableId)),
|
||||
)
|
||||
),
|
||||
]),
|
||||
),
|
||||
bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())),
|
||||
),
|
||||
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))
|
||||
),
|
||||
),
|
||||
dom.maybe(use => use(this._userAttrRules).length, () =>
|
||||
cssTableRule(
|
||||
cssTableHeader('User Attributes'),
|
||||
cssTableBody(
|
||||
cssPermissions('All Access'),
|
||||
cssPrincipals('Owners'),
|
||||
dom.forEach(this._userAttrRules, (userAttr) => userAttr.buildDom()),
|
||||
),
|
||||
);
|
||||
}),
|
||||
cssTableRule(
|
||||
cssTableHeader('Default Rule'),
|
||||
cssTableBody(
|
||||
cssPermissions('Schema Edit'),
|
||||
cssPrincipals(
|
||||
select(this._ownerOnlyStructure, [
|
||||
{label: 'Owners Only', value: true},
|
||||
{label: 'Owners & Editors', value: false}
|
||||
]),
|
||||
)
|
||||
),
|
||||
),
|
||||
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
|
||||
cssTableRule(
|
||||
cssTableHeader('Default Rules'),
|
||||
cssTableBody(
|
||||
dom.maybe(this._docDefaultRuleSet, ruleSet => ruleSet.buildDom()),
|
||||
)
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all rule records, for saving.
|
||||
*/
|
||||
public getRules(): RuleRec[] {
|
||||
return flatten(
|
||||
...this._tableRules.get().map(t => t.getRules()),
|
||||
this._docDefaultRuleSet.get()?.getRules('*') || []
|
||||
);
|
||||
}
|
||||
|
||||
public removeTableRules(tableRules: TableRules) {
|
||||
removeItem(this._tableRules, tableRules);
|
||||
}
|
||||
|
||||
public removeUserAttributes(userAttr: ObsUserAttributeRule) {
|
||||
removeItem(this._userAttrRules, userAttr);
|
||||
}
|
||||
|
||||
private _addTableRules(tableId: string) {
|
||||
if (this._tableRules.get().some(t => t.tableId === tableId)) {
|
||||
throw new Error(`Trying to add TableRules for existing table ${tableId}`);
|
||||
}
|
||||
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
|
||||
this._tableRules.push(TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet));
|
||||
}
|
||||
|
||||
private _addUserAttributes() {
|
||||
this._userAttrRules.push(ObsUserAttributeRule.create(this._userAttrRules, this));
|
||||
}
|
||||
}
|
||||
|
||||
// Represents all rules for a table.
|
||||
class TableRules extends Disposable {
|
||||
// Whether any table rules changed. Always true if this._colRuleSets is undefined.
|
||||
public isChanged: Computed<boolean>;
|
||||
|
||||
// The column-specific rule sets.
|
||||
private _columnRuleSets = this.autoDispose(obsArray<ColumnObsRuleSet>());
|
||||
|
||||
// Whether there are any column-specific rule sets.
|
||||
private _haveColumnRules = Computed.create(this, this._columnRuleSets, (use, cols) => cols.length > 0);
|
||||
|
||||
// The default rule set (for columns '*'), if one is set.
|
||||
private _defaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
|
||||
|
||||
constructor(public readonly tableId: string, private _accessRules: AccessRules,
|
||||
private _colRuleSets?: RuleSet[], private _defRuleSet?: RuleSet) {
|
||||
super();
|
||||
this._columnRuleSets.set(this._colRuleSets?.map(rs =>
|
||||
ColumnObsRuleSet.create(this._columnRuleSets, this, rs, rs.colIds === '*' ? [] : rs.colIds)) || []);
|
||||
|
||||
if (!this._colRuleSets) {
|
||||
// Must be a newly-created TableRules object. Just create a default RuleSet (for tableId:*)
|
||||
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules);
|
||||
} else if (this._defRuleSet) {
|
||||
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules, this._defRuleSet);
|
||||
}
|
||||
|
||||
this.isChanged = Computed.create(this, (use) => {
|
||||
if (!this._colRuleSets) { return true; } // This TableRules object must be newly-added
|
||||
const columnRuleSets = use(this._columnRuleSets);
|
||||
const d = use(this._defaultRuleSet);
|
||||
return (
|
||||
Boolean(d) !== Boolean(this._defRuleSet) || // Default rule set got added or removed
|
||||
(d && use(d.isChanged)) || // Or changed
|
||||
columnRuleSets.length < this._colRuleSets.length || // There was a removal
|
||||
columnRuleSets.some(rs => use(rs.isChanged)) // There was an addition or a change.
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return cssTableRule(
|
||||
cssTableHeader(
|
||||
dom('span', 'Rules for table ', cssTableName(this.tableId)),
|
||||
cssIconButton(icon('Dots'), {style: 'margin-left: auto'},
|
||||
menu(() => [
|
||||
menuItemAsync(() => this._addColumnRuleSet(), 'Add Column Rule'),
|
||||
menuItemAsync(() => this._addDefaultRuleSet(), 'Add Default Rule',
|
||||
dom.cls('disabled', use => Boolean(use(this._defaultRuleSet)))),
|
||||
menuItemAsync(() => this._accessRules.removeTableRules(this), 'Delete Table Rules'),
|
||||
]),
|
||||
testId('rule-table-menu-btn'),
|
||||
),
|
||||
testId('rule-table-header'),
|
||||
),
|
||||
cssTableBody(
|
||||
dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildDom()),
|
||||
dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildDom()),
|
||||
),
|
||||
testId('rule-table'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the resources (tableId:colIds entities), for saving, checking along the way that they
|
||||
* are valid.
|
||||
*/
|
||||
public getResources(): ResourceRec[] {
|
||||
// Check that the colIds are valid.
|
||||
const seen = new Set<string>();
|
||||
for (const ruleSet of this._columnRuleSets.get()) {
|
||||
const colIds = ruleSet.getColIdList();
|
||||
if (colIds.length === 0) {
|
||||
throw new UserError(`No columns listed in a column rule for table ${this.tableId}`);
|
||||
}
|
||||
for (const colId of colIds) {
|
||||
if (seen.has(colId)) {
|
||||
throw new UserError(`Column ${colId} appears in multiple rules for table ${this.tableId}`);
|
||||
}
|
||||
seen.add(colId);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...this._columnRuleSets.get().map(rs => ({tableId: this.tableId, colIds: rs.getColIds()})),
|
||||
{tableId: this.tableId, colIds: '*'},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rules for this table, for saving.
|
||||
*/
|
||||
public getRules(): RuleRec[] {
|
||||
return flatten(
|
||||
...this._columnRuleSets.get().map(rs => rs.getRules(this.tableId)),
|
||||
this._defaultRuleSet.get()?.getRules(this.tableId) || [],
|
||||
);
|
||||
}
|
||||
|
||||
public removeRuleSet(ruleSet: ObsRuleSet) {
|
||||
if (ruleSet === this._defaultRuleSet.get()) {
|
||||
this._defaultRuleSet.set(null);
|
||||
} else {
|
||||
removeItem(this._columnRuleSets, ruleSet);
|
||||
}
|
||||
if (!this._defaultRuleSet.get() && this._columnRuleSets.get().length === 0) {
|
||||
this._accessRules.removeTableRules(this);
|
||||
}
|
||||
}
|
||||
|
||||
private _addColumnRuleSet() {
|
||||
this._columnRuleSets.push(ColumnObsRuleSet.create(this._columnRuleSets, this, undefined, []));
|
||||
}
|
||||
|
||||
private _addDefaultRuleSet() {
|
||||
if (!this._defaultRuleSet.get()) {
|
||||
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Represents one RuleSet, for a combination of columns in one table, or the default RuleSet for
|
||||
// all remaining columns in a table.
|
||||
abstract class ObsRuleSet extends Disposable {
|
||||
// Whether rules changed. Always true if this._ruleSet is undefined.
|
||||
public isChanged: Computed<boolean>;
|
||||
|
||||
// Whether the rule set includes any conditions besides the default rule.
|
||||
public haveConditions: Computed<boolean>;
|
||||
|
||||
// List of individual rule parts for this entity. The default permissions may be included as the
|
||||
// last rule part, with an empty aclFormula.
|
||||
private _body = this.autoDispose(obsArray<ObsRulePart>());
|
||||
|
||||
// ruleSet is omitted for a new ObsRuleSet added by the user.
|
||||
constructor(private _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
|
||||
super();
|
||||
if (this._ruleSet) {
|
||||
this._body.set(this._ruleSet.body.map(part => ObsRulePart.create(this._body, this, part)));
|
||||
} else {
|
||||
// If creating a new RuleSet, start with just a default permission part.
|
||||
this._body.set([ObsRulePart.create(this._body, this, undefined, true)]);
|
||||
}
|
||||
|
||||
this.isChanged = Computed.create(this, this._body, (use, body) => {
|
||||
// If anything was changed or added, some part.isChanged will be set. If there were only
|
||||
// removals, then body.length will have changed.
|
||||
return (body.length !== (this._ruleSet?.body?.length || 0) ||
|
||||
body.some(part => use(part.isChanged)));
|
||||
});
|
||||
|
||||
this.haveConditions = Computed.create(this, this._body, (use, body) => body.some(p => !p.isDefault));
|
||||
}
|
||||
|
||||
public getRules(tableId: string): RuleRec[] {
|
||||
// Return every part in the body, tacking on resourceRec to each rule.
|
||||
return this._body.get().map(part => ({
|
||||
...part.getRulePart(),
|
||||
resourceRec: {tableId, colIds: this.getColIds()}
|
||||
}));
|
||||
}
|
||||
|
||||
public getColIds(): string {
|
||||
return '*';
|
||||
}
|
||||
|
||||
public abstract buildDom(): Element;
|
||||
|
||||
public removeRulePart(rulePart: ObsRulePart) {
|
||||
removeItem(this._body, rulePart);
|
||||
if (this._body.get().length === 0) {
|
||||
this._tableRules?.removeRuleSet(this);
|
||||
}
|
||||
}
|
||||
|
||||
public addRulePart(beforeRule: ObsRulePart) {
|
||||
const i = this._body.get().indexOf(beforeRule);
|
||||
this._body.splice(i, 0, ObsRulePart.create(this._body, this, undefined, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first built-in rule. It's the only one of the built-in rules to get a "+" next to
|
||||
* it, since we don't allow inserting new rules in-between built-in rules.
|
||||
*/
|
||||
public getFirstBuiltIn(): ObsRulePart|undefined {
|
||||
return this._body.get().find(p => p.isBuiltIn());
|
||||
}
|
||||
|
||||
/**
|
||||
* When an empty-conditition RulePart is the only part of a RuleSet, we can say it applies to
|
||||
* "Everyone".
|
||||
*/
|
||||
public isSoleCondition(use: UseCB, part: ObsRulePart): boolean {
|
||||
const body = use(this._body);
|
||||
return body.length === 1 && body[0] === part;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an empty-conditition RulePart is last in a RuleSet, we say it applies to "Everyone Else".
|
||||
*/
|
||||
public isLastCondition(use: UseCB, part: ObsRulePart): boolean {
|
||||
const body = use(this._body);
|
||||
return body[body.length - 1] === part;
|
||||
}
|
||||
|
||||
protected buildRuleBody() {
|
||||
return cssRuleSetBody(
|
||||
dom.forEach(this._body, part => part.buildDom()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ColumnObsRuleSet extends ObsRuleSet {
|
||||
private _colIds = Observable.create<string[]>(this, this._initialColIds);
|
||||
private _colIdStr = Computed.create(this, (use) => use(this._colIds).join(", "));
|
||||
|
||||
constructor(tableRules: TableRules, ruleSet: RuleSet|undefined, private _initialColIds: string[]) {
|
||||
super(tableRules, ruleSet);
|
||||
const baseIsChanged = this.isChanged;
|
||||
this.isChanged = Computed.create(this, (use) =>
|
||||
!isEqual(use(this._colIds), this._initialColIds) || use(baseIsChanged));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const saveColIds = async (colIdStr: string) => {
|
||||
this._colIds.set(colIdStr.split(',').map(val => val.trim()).filter(Boolean));
|
||||
};
|
||||
return cssRuleSet(
|
||||
cssResource('Columns', textInput(this._colIdStr, saveColIds),
|
||||
testId('rule-resource')
|
||||
),
|
||||
this.buildRuleBody(),
|
||||
testId('rule-set'),
|
||||
);
|
||||
}
|
||||
|
||||
public getColIdList(): string[] {
|
||||
return this._colIds.get();
|
||||
}
|
||||
|
||||
public getColIds(): string {
|
||||
return this._colIds.get().join(",");
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultObsRuleSet extends ObsRuleSet {
|
||||
constructor(tableRules: TableRules|null, private _haveColumnRules?: Observable<boolean>, ruleSet?: RuleSet) {
|
||||
super(tableRules, ruleSet);
|
||||
}
|
||||
public buildDom() {
|
||||
return cssRuleSet(
|
||||
cssResource(dom.text(use => this._haveColumnRules && use(this._haveColumnRules) ?
|
||||
'Remaining Columns' : 'All Columns'),
|
||||
testId('rule-resource')
|
||||
),
|
||||
this.buildRuleBody(),
|
||||
testId('rule-set'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ObsUserAttributeRule extends Disposable {
|
||||
public isChanged: Computed<boolean>;
|
||||
|
||||
private _name = Observable.create<string>(this, this._userAttr?.name || '');
|
||||
private _tableId = Observable.create<string>(this, this._userAttr?.tableId || '');
|
||||
private _lookupColId = Observable.create<string>(this, this._userAttr?.lookupColId || '');
|
||||
private _charId = Observable.create<string>(this, this._userAttr?.charId || '');
|
||||
|
||||
constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule) {
|
||||
super();
|
||||
this.isChanged = Computed.create(this, use => (
|
||||
use(this._name) !== this._userAttr?.name ||
|
||||
use(this._tableId) !== this._userAttr?.tableId ||
|
||||
use(this._lookupColId) !== this._userAttr?.lookupColId ||
|
||||
use(this._charId) !== this._userAttr?.charId
|
||||
));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return cssUserAttribute(
|
||||
cssConditionInput(this._name, async (val) => this._name.set(val),
|
||||
{placeholder: 'New attribute name'}),
|
||||
cssConditionInput(this._tableId, async (val) => this._tableId.set(val),
|
||||
{placeholder: 'Table ID'}),
|
||||
cssConditionInput(this._lookupColId, async (val) => this._lookupColId.set(val),
|
||||
{placeholder: 'Column to look up'}),
|
||||
cssConditionInput(this._charId, async (val) => this._charId.set(val),
|
||||
{placeholder: 'User attribute to look up'}),
|
||||
cssIconButton(icon('Remove'), {style: 'margin-left: 4px'},
|
||||
dom.on('click', () => this._accessRules.removeUserAttributes(this)))
|
||||
);
|
||||
}
|
||||
|
||||
public getRule() {
|
||||
const spec = {
|
||||
name: this._name.get(),
|
||||
tableId: this._tableId.get(),
|
||||
lookupColId: this._lookupColId.get(),
|
||||
charId: this._charId.get(),
|
||||
};
|
||||
for (const [prop, value] of Object.entries(spec)) {
|
||||
if (!value) {
|
||||
throw new UserError(`Invalid user attribute rule: ${prop} must be set`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: this._userAttr?.origRecord?.id,
|
||||
rulePos: this._userAttr?.origRecord?.rulePos as number|undefined,
|
||||
userAttributes: JSON.stringify(spec),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Represents one line of a RuleSet, a combination of an aclFormula and permissions to apply to
|
||||
// requests that match it.
|
||||
class ObsRulePart extends Disposable {
|
||||
// Whether rules changed. Always true if this._rulePart is undefined.
|
||||
public isChanged: Computed<boolean>;
|
||||
|
||||
// Formula to show in the "advanced" UI.
|
||||
private _aclFormula = Observable.create<string>(this, this._rulePart?.aclFormula || "");
|
||||
|
||||
// The permission bits.
|
||||
private _permissions = Observable.create<PartialPermissionSet>(
|
||||
this, this._rulePart?.permissions || emptyPermissionSet());
|
||||
|
||||
private _permissionsText = Computed.create(this, this._permissions, (use, p) => permissionSetToText(p));
|
||||
|
||||
// rulePart is omitted for a new ObsRulePart added by the user.
|
||||
constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart,
|
||||
public readonly isDefault: boolean = (_rulePart?.aclFormula === '')) {
|
||||
super();
|
||||
this.isChanged = Computed.create(this, (use) => {
|
||||
return (use(this._aclFormula) !== this._rulePart?.aclFormula ||
|
||||
!isEqual(use(this._permissions), this._rulePart?.permissions));
|
||||
});
|
||||
}
|
||||
|
||||
public getRulePart(): RuleRec {
|
||||
// Use id of 0 to distinguish built-in rules from newly added rule, which will have id of undefined.
|
||||
const id = this.isBuiltIn() ? 0 : this._rulePart?.origRecord?.id;
|
||||
return {
|
||||
id,
|
||||
aclFormula: this._aclFormula.get(),
|
||||
permissionsText: permissionSetToText(this._permissions.get()),
|
||||
rulePos: this._rulePart?.origRecord?.rulePos as number|undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return cssRulePart(
|
||||
(this._isNonFirstBuiltIn() ?
|
||||
cssIconSpace({style: 'margin-right: 4px'}) :
|
||||
cssIconButton(icon('Plus'), {style: 'margin-right: 4px'},
|
||||
dom.on('click', () => this._ruleSet.addRulePart(this)),
|
||||
testId('rule-add'),
|
||||
)
|
||||
),
|
||||
cssConditionInput(
|
||||
this._aclFormula, async (text) => this._aclFormula.set(text),
|
||||
dom.prop('disabled', this.isBuiltIn()),
|
||||
dom.prop('placeholder', (use) => {
|
||||
return (
|
||||
this._ruleSet.isSoleCondition(use, this) ? 'Everyone' :
|
||||
this._ruleSet.isLastCondition(use, this) ? 'Everyone Else' :
|
||||
'Enter Condition'
|
||||
);
|
||||
}),
|
||||
testId('rule-acl-formula'),
|
||||
),
|
||||
cssPermissionsInput(
|
||||
this._permissionsText, async (p) => this._permissions.set(parsePermissions(p)),
|
||||
dom.prop('disabled', this.isBuiltIn()),
|
||||
testId('rule-permissions')
|
||||
),
|
||||
(this.isBuiltIn() ?
|
||||
cssIconSpace({style: 'margin-left: 4px'}) :
|
||||
cssIconButton(icon('Remove'), {style: 'margin-left: 4px'},
|
||||
dom.on('click', () => this._ruleSet.removeRulePart(this)),
|
||||
testId('rule-remove'),
|
||||
)
|
||||
),
|
||||
testId('rule-part'),
|
||||
);
|
||||
}
|
||||
|
||||
public isBuiltIn(): boolean {
|
||||
return this._rulePart ? !this._rulePart.origRecord?.id : false;
|
||||
}
|
||||
|
||||
private _isNonFirstBuiltIn(): boolean {
|
||||
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Produce and apply UserActions to create/update/remove records, to replace data in tableData
|
||||
* with newRecords. Records are matched on uniqueId(record), which defaults to returning record.id
|
||||
* (unique negative IDs may be used for new records). The returned Map maps uniqueId(record) to
|
||||
* rowId for all existing and newly added records.
|
||||
*
|
||||
* TODO This is a general-purpose function, and should live in a separate module.
|
||||
*/
|
||||
async function syncRecords(tableData: TableData, newRecords: RowRecord[],
|
||||
uniqueId: (r: RowRecord) => string = (r => String(r.id))
|
||||
): Promise<Map<string, number>> {
|
||||
const oldRecords = tableData.getRecords();
|
||||
const oldRecordMap = new Map<string, RowRecord>(oldRecords.map(r => [uniqueId(r), r]));
|
||||
const newRecordMap = new Map<string, RowRecord>(newRecords.map(r => [uniqueId(r), r]));
|
||||
|
||||
const removedRecords: RowRecord[] = oldRecords.filter(r => !newRecordMap.has(uniqueId(r)));
|
||||
const addedRecords: RowRecord[] = newRecords.filter(r => !oldRecordMap.has(uniqueId(r)));
|
||||
// Array of [before, after] pairs for changed records.
|
||||
const updatedRecords: Array<[RowRecord, RowRecord]> = oldRecords.map((r): ([RowRecord, RowRecord]|null) => {
|
||||
const newRec = newRecordMap.get(uniqueId(r));
|
||||
const updated = newRec && {...r, ...newRec, id: r.id};
|
||||
return updated && !isEqual(updated, r) ? [r, updated] : null;
|
||||
}).filter(isObject);
|
||||
|
||||
console.log("syncRecords: removing [%s], adding [%s], updating [%s]",
|
||||
removedRecords.map(uniqueId).join(", "),
|
||||
addedRecords.map(uniqueId).join(", "),
|
||||
updatedRecords.map(([r]) => uniqueId(r)).join(", "));
|
||||
|
||||
const userActions: UserAction[] = [];
|
||||
if (removedRecords.length > 0) {
|
||||
userActions.push(['BulkRemoveRecord', removedRecords.map(r => r.id)]);
|
||||
}
|
||||
if (updatedRecords.length > 0) {
|
||||
userActions.push(['BulkUpdateRecord', updatedRecords.map(([r]) => r.id), getColChanges(updatedRecords)]);
|
||||
}
|
||||
let addActionIndex: number = -1;
|
||||
if (addedRecords.length > 0) {
|
||||
addActionIndex = userActions.length;
|
||||
userActions.push(['BulkAddRecord', addedRecords.map(r => null), getColValues(addedRecords)]);
|
||||
}
|
||||
|
||||
const rowIdMap = new Map<string, number>();
|
||||
oldRecords.forEach((r) => rowIdMap.set(uniqueId(r), r.id));
|
||||
|
||||
if (userActions.length > 0) {
|
||||
const results = await tableData.sendTableActions(userActions);
|
||||
const newRowIds = results[addActionIndex];
|
||||
addedRecords.forEach((r, i) => rowIdMap.set(uniqueId(r), newRowIds[i]));
|
||||
}
|
||||
return rowIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of rows into an object with columns of values, used for
|
||||
* BulkAddRecord/BulkUpdateRecord actions.
|
||||
*/
|
||||
function getColValues(records: RowRecord[]): BulkColValues {
|
||||
const colIdSet = new Set<string>();
|
||||
for (const r of records) {
|
||||
for (const c of Object.keys(r)) {
|
||||
if (c !== 'id') {
|
||||
colIdSet.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: BulkColValues = {};
|
||||
for (const colId of colIdSet) {
|
||||
result[colId] = records.map(r => r[colId]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of [before, after] rows into an object of changes, skipping columns which
|
||||
* haven't changed.
|
||||
*/
|
||||
function getColChanges(pairs: Array<[RowRecord, RowRecord]>): BulkColValues {
|
||||
const colIdSet = new Set<string>();
|
||||
for (const [before, after] of pairs) {
|
||||
for (const c of Object.keys(after)) {
|
||||
if (c !== 'id' && !isEqual(before[c], after[c])) {
|
||||
colIdSet.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: BulkColValues = {};
|
||||
for (const colId of colIdSet) {
|
||||
result[colId] = pairs.map(([before, after]) => after[colId]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeResource(rec: RowRecord): string {
|
||||
return JSON.stringify([rec.tableId, rec.colIds]);
|
||||
}
|
||||
|
||||
function flatten<T>(...args: T[][]): T[] {
|
||||
return ([] as T[]).concat(...args);
|
||||
}
|
||||
|
||||
function removeItem<T>(observableArray: MutableObsArray<T>, item: T): boolean {
|
||||
const i = observableArray.get().indexOf(item);
|
||||
if (i >= 0) {
|
||||
observableArray.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const cssAddTableRow = styled('div', `
|
||||
margin: 0 64px 16px 64px;
|
||||
margin: 16px 64px 0 64px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
`);
|
||||
|
||||
const cssTableRule = styled('div', `
|
||||
margin: 16px 64px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px 16px 16px;
|
||||
margin: 24px 64px;
|
||||
`);
|
||||
|
||||
const cssTableBody = styled('div', `
|
||||
border: 2px solid ${colors.slate};
|
||||
border-radius: 8px;
|
||||
`);
|
||||
|
||||
const cssTableHeader = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
color: ${colors.slate};
|
||||
`);
|
||||
|
||||
const cssTableBody = styled('div', `
|
||||
const cssTableName = styled('span', `
|
||||
color: ${colors.dark};
|
||||
`);
|
||||
|
||||
const cssRuleSet = styled('div', `
|
||||
display: flex;
|
||||
border-bottom: 2px solid ${colors.slate};
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssResource = styled('div', `
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 2px solid ${colors.slate};
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
`);
|
||||
|
||||
|
||||
const cssRuleSetBody = styled('div', `
|
||||
flex: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
`);
|
||||
|
||||
const cssRulePart = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
&-default {
|
||||
margin-top: auto;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPermissions = styled('div', `
|
||||
const cssConditionInput = styled(textInput, `
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
color: ${colors.lightGreen};
|
||||
|
||||
&[disabled] {
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
color: ${colors.dark};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPrincipals = styled('div', `
|
||||
flex: 1;
|
||||
color: ${colors.lightGreen};
|
||||
`);
|
||||
|
||||
const cssRemove = styled('div', `
|
||||
const cssPermissionsInput = styled(cssConditionInput, `
|
||||
margin-left: 8px;
|
||||
width: 64px;
|
||||
flex: none;
|
||||
`);
|
||||
|
||||
const cssIconSpace = styled('div', `
|
||||
flex: none;
|
||||
margin: 0 4px 0 auto;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
`);
|
||||
|
||||
const cssIconButton = styled(cssIconSpace, `
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 0px;
|
||||
cursor: default;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
@@ -201,3 +856,10 @@ const cssRemove = styled('div', `
|
||||
--icon-color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssUserAttribute = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 16px 8px;
|
||||
`);
|
||||
|
||||
@@ -6,22 +6,28 @@ import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/clie
|
||||
import { cssLinkText, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { commonUrls } from 'app/common/gristUrls';
|
||||
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
|
||||
|
||||
const testId = makeTestId('test-tools-');
|
||||
|
||||
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
||||
const isEfcr = (gristDoc.app.topAppModel.productFlavor === 'efcr');
|
||||
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
|
||||
|
||||
return cssTools(
|
||||
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
|
||||
cssSectionHeader("TOOLS"),
|
||||
|
||||
isEfcr ? cssPageEntry(
|
||||
cssPageLink(cssPageIcon('FieldReference'), cssLinkText('eFC-Connect'),
|
||||
{href: commonUrls.efcrConnect, target: '_blank'}),
|
||||
) : null,
|
||||
|
||||
(aclUIEnabled ?
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
|
||||
cssPageLink(cssPageIcon('EyeShow'),
|
||||
cssLinkText('Access Rules'),
|
||||
urlState().setLinkUrl({docPage: 'acl'})
|
||||
),
|
||||
testId('access-rules'),
|
||||
) :
|
||||
null
|
||||
),
|
||||
cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
|
||||
dom.on('click', () => gristDoc.showTool('docHistory')))
|
||||
|
||||
@@ -21,15 +21,15 @@ import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel';
|
||||
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
|
||||
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
|
||||
import {AccessRules} from 'app/client/ui/AccessRules';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
|
||||
import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
|
||||
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
|
||||
export interface IUserManagerOptions {
|
||||
permissionData: Promise<PermissionData>;
|
||||
@@ -56,9 +56,24 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
const modelObs: Observable<UserManagerModel|null> = observable(null);
|
||||
|
||||
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
|
||||
const gristDoc = aclUIEnabled ? options.docPageModel?.gristDoc.get() : null;
|
||||
const accessRules = gristDoc ? AccessRules.create(null, gristDoc) : null;
|
||||
const accessRulesOpen = observable(false);
|
||||
|
||||
async function onConfirm(ctl: IModalControl) {
|
||||
const model = modelObs.get();
|
||||
if (model) {
|
||||
// Save changes to the server, reporting any errors to the app.
|
||||
try {
|
||||
if (model.isAnythingChanged.get()) {
|
||||
await model.save(userApi, options.resourceId);
|
||||
}
|
||||
await options.onSave?.();
|
||||
ctl.close();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
} else {
|
||||
ctl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the model and assign it to the observable. Report errors to the app.
|
||||
getModel(options)
|
||||
@@ -70,69 +85,37 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
|
||||
cssModalTitle(
|
||||
{ style: 'margin: 40px 64px 0 64px;' },
|
||||
dom.domComputed(accessRulesOpen, rules =>
|
||||
rules ?
|
||||
['Access Rules'] :
|
||||
[
|
||||
`Invite people to ${renderType(options.resourceType)}`,
|
||||
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
||||
]
|
||||
),
|
||||
`Invite people to ${renderType(options.resourceType)}`,
|
||||
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
||||
testId('um-header')
|
||||
),
|
||||
|
||||
cssModalBody(
|
||||
dom.autoDispose(accessRules),
|
||||
cssUserManagerBody(
|
||||
// TODO: Show a loading indicator before the model is loaded.
|
||||
dom.maybe(modelObs, model => new UserManager(model, options.linkToCopy).buildDom()),
|
||||
dom.hide(accessRulesOpen),
|
||||
),
|
||||
cssUserManagerBody(
|
||||
accessRules?.buildDom(),
|
||||
dom.show(accessRulesOpen),
|
||||
),
|
||||
),
|
||||
cssModalButtons(
|
||||
{ style: 'margin: 32px 64px; display: flex;' },
|
||||
bigPrimaryButton('Confirm',
|
||||
dom.boolAttr('disabled', (use) => (
|
||||
(!use(modelObs) || !use(use(modelObs)!.isAnythingChanged)) &&
|
||||
(!accessRules || !use(accessRules.isAnythingChanged))
|
||||
)),
|
||||
dom.on('click', async () => {
|
||||
const model = modelObs.get();
|
||||
if (model) {
|
||||
// Save changes to the server, reporting any errors to the app.
|
||||
try {
|
||||
if (model.isAnythingChanged.get()) {
|
||||
await model.save(userApi, options.resourceId);
|
||||
}
|
||||
await accessRules?.save();
|
||||
await options.onSave?.();
|
||||
ctl.close();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
} else {
|
||||
ctl.close();
|
||||
}
|
||||
}),
|
||||
dom.boolAttr('disabled', (use) => !use(modelObs) || !use(use(modelObs)!.isAnythingChanged)),
|
||||
dom.on('click', () => onConfirm(ctl)),
|
||||
testId('um-confirm')
|
||||
),
|
||||
bigBasicButton('Cancel',
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('um-cancel')
|
||||
),
|
||||
(accessRules ?
|
||||
bigBasicButton({style: 'margin-left: auto'},
|
||||
dom.domComputed(accessRulesOpen, rules => rules ?
|
||||
[cssBigIcon('Expand', cssBigIcon.cls('-reflect')), 'Back to Users'] :
|
||||
['Access Rules', cssBigIcon('Expand')]
|
||||
),
|
||||
dom.on('click', () => accessRulesOpen.set(!accessRulesOpen.get())),
|
||||
) :
|
||||
null
|
||||
(aclUIEnabled ?
|
||||
cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
||||
dom.text(use => (use(modelObs) && use(use(modelObs)!.isAnythingChanged)) ? 'Save & ' : ''),
|
||||
'Open Access Rules',
|
||||
dom.on('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
||||
}),
|
||||
) : null
|
||||
),
|
||||
testId('um-buttons'),
|
||||
)
|
||||
@@ -622,13 +605,9 @@ const cssUserImagePlus = styled(cssUserImage, `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssBigIcon = styled(icon, `
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin: -8px 0 -4px 0;
|
||||
&-reflect {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
const cssAccessLink = styled(cssLink, `
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
`);
|
||||
|
||||
// Render the name "organization" as "team site" in UI
|
||||
|
||||
Reference in New Issue
Block a user