gristlabs_grist-core/app/client/aclui/AccessRules.ts
George Gevoian cb0ce9b20f (core) Avoid reporting unhelpful ResizeObserver error
Summary: The error appears to be benign and not caused by any of our code.

Test Plan:
Reproducing was tricky.

On the Access Rules page, changing the browser's zoom level caused the error to surface. Weirdly enough, it only happened when connected to a larger, external monitor, and not on my laptop's internal monitor.

In any case, manual testing was done to confirm the error is no longer reported to the user after this change.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4095
2023-10-27 15:26:20 -04:00

2114 lines
75 KiB
TypeScript

/**
* UI for managing granular ACLs.
*/
import {aclColumnList} from 'app/client/aclui/ACLColumnList';
import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor';
import {aclMemoEditor} from 'app/client/aclui/ACLMemoEditor';
import {aclSelect} from 'app/client/aclui/ACLSelect';
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
import {GristDoc} from 'app/client/components/GristDoc';
import {reportError, UserError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuItemAsync} from 'app/client/ui2018/menus';
import {
emptyPermissionSet,
MixedPermissionValue,
parsePermissions,
PartialPermissionSet,
permissionSetToText,
summarizePermissions,
summarizePermissionSet
} from 'app/common/ACLPermissions';
import {ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
import {AclRuleProblem, AclTableDescription, getTableTitle} from 'app/common/ActiveDocAPI';
import {BulkColValues, getColValues, RowRecord, UserAction} from 'app/common/DocActions';
import {
FormulaProperties,
getFormulaProperties,
RulePart,
RuleSet,
UserAttributeRule
} from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes';
import {isNonNullish, unwrap} from 'app/common/gutil';
import {SchemaTypes} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData';
import {
BaseObservable,
Computed,
Disposable,
dom,
DomContents,
DomElementArg,
IDisposableOwner,
MutableObsArray,
obsArray,
Observable,
styled
} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import isEqual = require('lodash/isEqual');
const t = makeT('AccessRules');
// tslint:disable:max-classes-per-file no-console
// 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};
type UseCB = <T>(obs: BaseObservable<T>) => T;
// Status of rules, which determines whether the "Save" button is enabled. The order of the values
// matters, as we take the max of all the parts to determine the ultimate status.
enum RuleStatus {
Unchanged,
ChangedValid,
Invalid,
CheckPending,
}
// UserAttribute autocomplete choices. RuleIndex is used to filter for only those user
// attributes made available by the previous rules.
interface IAttrOption {
ruleIndex: number;
value: string;
}
/**
* 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.
private _ruleStatus: Computed<RuleStatus>;
// Parsed rules obtained from DocData during last call to update(). Used for _ruleStatus.
private _ruleCollection = new ACLRuleCollection();
// 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);
// Special document-level rules, for resources of the form ("*SPECIAL:<RuleType>").
// These rules are shown in different places - currently most are shown as a separate
// section, and one is folded into the default rule section (for SeedRule).
private _specialRulesWithDefault = Observable.create<SpecialRules|null>(this, null);
private _specialRulesSeparate = Observable.create<SpecialRules|null>(this, null);
// Array of all UserAttribute rules.
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
// Array of all user-attribute choices created by UserAttribute rules. Used for lookup items in
// rules, and for ACLFormula completions.
private _userAttrChoices: Computed<IAttrOption[]>;
// Whether the save button should be enabled.
private _savingEnabled: Computed<boolean>;
// Error or warning message to show next to Save/Reset buttons if non-empty.
private _errorMessage = Observable.create(this, '');
// Details of rule problems, for offering solutions to the user.
private _ruleProblems = this.autoDispose(obsArray<AclRuleProblem>());
// Map of tableId to basic metadata for all tables in the document.
private _aclResources = new Map<string, AclTableDescription>();
private _aclUsersPopup = ACLUsersPopup.create(this, this.gristDoc.docPageModel);
constructor(public gristDoc: GristDoc) {
super();
this._ruleStatus = Computed.create(this, (use) => {
const defRuleSet = use(this._docDefaultRuleSet);
const tableRules = use(this._tableRules);
const specialRulesWithDefault = use(this._specialRulesWithDefault);
const specialRulesSeparate = use(this._specialRulesSeparate);
const userAttr = use(this._userAttrRules);
return Math.max(
defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged,
// If any tables/userAttrs were changed or added, they will be considered changed. If
// there were only removals, then length will be reduced.
getChangedStatus(tableRules.length < this._ruleCollection.getAllTableIds().length),
getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),
...tableRules.map(tr => use(tr.ruleStatus)),
...userAttr.map(u => use(u.ruleStatus)),
specialRulesWithDefault ? use(specialRulesWithDefault.ruleStatus) : RuleStatus.Unchanged,
specialRulesSeparate ? use(specialRulesSeparate.ruleStatus) : RuleStatus.Unchanged,
);
});
this._savingEnabled = Computed.create(this, this._ruleStatus, (use, s) =>
(s === RuleStatus.ChangedValid));
this._userAttrChoices = Computed.create(this, this._userAttrRules, (use, rules) => {
const result: IAttrOption[] = [
{ruleIndex: -1, value: 'user.Access'},
{ruleIndex: -1, value: 'user.Email'},
{ruleIndex: -1, value: 'user.UserID'},
{ruleIndex: -1, value: 'user.Name'},
{ruleIndex: -1, value: 'user.LinkKey.'},
{ruleIndex: -1, value: 'user.Origin'},
{ruleIndex: -1, value: 'user.SessionID'},
{ruleIndex: -1, value: 'user.IsLoggedIn'},
{ruleIndex: -1, value: 'user.UserRef'},
];
for (const [i, rule] of rules.entries()) {
const tableId = use(rule.tableId);
const name = use(rule.name);
for (const colId of this.getValidColIds(tableId) || []) {
result.push({ruleIndex: i, value: `user.${name}.${colId}`});
}
}
return result;
});
// The UI in this module isn't really dynamic (that would be tricky while allowing unsaved
// changes). Instead, react deliberately if rules change. Note that table/column renames would
// trigger changes to rules, so we don't need to listen for those separately.
for (const tableId of ['_grist_ACLResources', '_grist_ACLRules']) {
const tableData = this.gristDoc.docData.getTable(tableId)!;
this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this));
}
this.autoDispose(this.gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this));
this.update().catch((e) => this._errorMessage.set(e.message));
}
public get allTableIds() { return Array.from(this._aclResources.keys()).sort(); }
public get userAttrRules() { return this._userAttrRules; }
public get userAttrChoices() { return this._userAttrChoices; }
public getTableTitle(tableId: string) {
const table = this._aclResources.get(tableId);
if (!table) { return `#Invalid (${tableId})`; }
return getTableTitle(table);
}
/**
* Replace internal state from the rules in DocData.
*/
public async update() {
if (this.isDisposed()) { return; }
this._errorMessage.set('');
const rules = this._ruleCollection;
const [ , , aclResources] = await Promise.all([
rules.update(this.gristDoc.docData, {log: console, pullOutSchemaEdit: true}),
this._updateDocAccessData(),
this.gristDoc.docComm.getAclResources(),
]);
this._aclResources = new Map(Object.entries(aclResources.tables));
this._ruleProblems.set(aclResources.problems);
if (this.isDisposed()) { return; }
this._tableRules.set(
rules.getAllTableIds()
.filter(tableId => (tableId !== SPECIAL_RULES_TABLE_ID))
.map(tableId => TableRules.create(this._tableRules,
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
);
const withDefaultRules = ['SeedRule'];
const separateRules = ['SchemaEdit', 'FullCopies', 'AccessRules'];
SpecialRules.create(
this._specialRulesWithDefault, SPECIAL_RULES_TABLE_ID, this,
filterRuleSets(withDefaultRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),
filterRuleSet(withDefaultRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));
SpecialRules.create(
this._specialRulesSeparate, SPECIAL_RULES_TABLE_ID, this,
filterRuleSets(separateRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),
filterRuleSet(separateRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));
DefaultObsRuleSet.create(this._docDefaultRuleSet, this, 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._savingEnabled.get()) { return; }
// 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.
const docData = this.gristDoc.docData;
const resourcesTable = docData.getMetaTable('_grist_ACLResources');
const rulesTable = docData.getMetaTable('_grist_ACLRules');
// Add/remove resources to have just the ones we need.
const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten(
[{tableId: '*', colIds: '*'}],
this._specialRulesWithDefault.get()?.getResources() || [],
this._specialRulesSeparate.get()?.getResources() || [],
...this._tableRules.get().map(tr => tr.getResources())
)
// Skip the fake "*SPECIAL:SchemaEdit" resource (frontend-specific); these rules are saved to the default resource.
.filter(resource => !isSchemaEditResource(resource))
.map(r => ({id: -1, ...r}));
// Prepare userActions and a mapping of serializedResource to rowIds.
const resourceSync = syncRecords(resourcesTable, newResources, serializeResource);
const defaultResourceRowId = resourceSync.rowIdMap.get(serializeResource({id: -1, tableId: '*', colIds: '*'}));
if (!defaultResourceRowId) {
throw new Error('Default resource missing in resource map');
}
// For syncing rules, we'll go by rowId that we store with each RulePart and with the RuleSet.
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.
let resourceRowId: number|undefined;
// Assign the rules for the fake "*SPECIAL:SchemaEdit" resource to the default resource where they belong.
if (isSchemaEditResource(rule.resourceRec!)) {
resourceRowId = defaultResourceRowId;
} else {
const resourceKey = serializeResource(rule.resourceRec as RowRecord);
resourceRowId = resourceSync.rowIdMap.get(resourceKey);
if (!resourceRowId) {
throw new Error(`Resource missing in resource map: ${resourceKey}`);
}
}
newRules.push({
id: rule.id || -1,
resource: resourceRowId,
aclFormula: rule.aclFormula!,
permissionsText: rule.permissionsText!,
rulePos: rule.rulePos || null,
memo: rule.memo ?? '',
});
}
// UserAttribute rules are listed in the same rulesTable.
for (const userAttr of this._userAttrRules.get()) {
const rule = userAttr.getRule();
newRules.push({
id: rule.id || -1,
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 = lastGoodRulePos + 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;
}
// Prepare the UserActions for syncing the Rules table.
const rulesSync = syncRecords(rulesTable, newRules);
// Finally collect and apply all the actions together.
try {
await docData.sendActions([...resourceSync.userActions, ...rulesSync.userActions]);
} catch (e) {
// Report the error, but go on to update the rules. The user may lose their entries, but
// will see what's in the document. To preserve entries and show what's wrong, we try to
// catch errors earlier.
reportError(e);
}
// Re-populate the state from DocData once the records are synced.
await this.update();
}
public buildDom() {
return cssOuter(
dom('div', this.gristDoc.behavioralPromptsManager.attachTip('accessRules', {
hideArrow: true,
})),
cssAddTableRow(
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
dom.text((use) => {
const s = use(this._ruleStatus);
return s === RuleStatus.CheckPending ? t("Checking...") :
s === RuleStatus.Unchanged ? t("Saved") : t("Invalid");
}),
testId('rules-non-save')
),
bigPrimaryButton(t("Save"), dom.show(this._savingEnabled),
dom.on('click', () => this.save()),
testId('rules-save'),
),
bigBasicButton(t("Reset"), dom.show(use => use(this._ruleStatus) !== RuleStatus.Unchanged),
dom.on('click', () => this.update()),
testId('rules-revert'),
),
bigBasicButton(t("Add Table Rules"), cssDropdownIcon('Dropdown'), {style: 'margin-left: auto'},
menu(() =>
this.allTableIds.map((tableId) =>
// Add the table on a timeout, to avoid disabling the clicked menu item
// synchronously, which prevents the menu from closing on click.
menuItemAsync(() => this._addTableRules(tableId),
this.getTableTitle(tableId),
dom.cls('disabled', (use) => use(this._tableRules).some(tr => tr.tableId === tableId)),
)
),
),
),
bigBasicButton(t('Add User Attributes'), dom.on('click', () => this._addUserAttributes())),
bigBasicButton(t('View As'), cssDropdownIcon('Dropdown'),
elem => this._aclUsersPopup.attachPopup(elem, {placement: 'bottom-end', resetDocPage: true}),
dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
),
cssConditionError({style: 'margin-left: 16px'},
dom.text(this._errorMessage),
testId('access-rules-error')
),
dom.maybe(use => {
const ruleProblems = use(this._ruleProblems);
return ruleProblems.length > 0 ? ruleProblems : null;
}, ruleProblems =>
cssSection(
cssRuleProblems(
this.buildRuleProblemsDom(ruleProblems)))),
shadowScroll(
dom.maybe(use => use(this._userAttrRules).length, () =>
cssSection(
cssSectionHeading(t("User Attributes")),
cssTableRounded(
cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Name')),
cssCell4(
cssColumnGroup(
cssCell1(cssColHeaderCell(t("Attribute to Look Up"))),
cssCell1(cssColHeaderCell(t("Lookup Table"))),
cssCell1(cssColHeaderCell(t("Lookup Column"))),
cssCellIcon(),
),
),
),
dom.forEach(this._userAttrRules, (userAttr) => userAttr.buildUserAttrDom()),
),
),
),
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
cssSection(
cssSectionHeading(t("Default Rules"), testId('rule-table-header')),
dom.maybe(this._specialRulesWithDefault, tableRules => cssSeedRule(
tableRules.buildCheckBoxes())),
cssTableRounded(
cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
cssCell4(
cssColumnGroup(
cssCellIcon(),
cssCell2(cssColHeaderCell(t('Condition'))),
cssCell1(cssColHeaderCell(t('Permissions'))),
cssCellIconWithMargins(),
cssCellIcon(),
)
)
),
dom.maybe(this._docDefaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()),
),
testId('rule-table'),
),
dom.maybe(this._specialRulesSeparate, tableRules => tableRules.buildDom()),
),
);
}
public buildRuleProblemsDom(ruleProblems: AclRuleProblem[]) {
const buttons: Array<HTMLAnchorElement | HTMLButtonElement> = [];
for (const problem of ruleProblems) {
// Is the problem a missing table?
if (problem.tables) {
this._addButtonsForMissingTables(buttons, problem.tables.tableIds);
}
// Is the problem a missing column?
if (problem.columns) {
this._addButtonsForMissingColumns(buttons, problem.columns.tableId, problem.columns.colIds);
}
// Is the problem a misconfigured user attribute?
if (problem.userAttributes) {
const names = problem.userAttributes.names;
this._addButtonsForMisconfiguredUserAttributes(buttons, names);
}
}
return buttons.map(button => dom('span', button));
}
/**
* Get a list of all rule records, for saving.
*/
public getRules(): RuleRec[] {
return flatten(
...this._tableRules.get().map(tr => tr.getRules()),
this._specialRulesWithDefault.get()?.getRules() || [],
this._specialRulesSeparate.get()?.getRules() || [],
this._docDefaultRuleSet.get()?.getRules('*') || []
);
}
public removeTableRules(tableRules: TableRules) {
removeItem(this._tableRules, tableRules);
}
public removeUserAttributes(userAttr: ObsUserAttributeRule) {
removeItem(this._userAttrRules, userAttr);
}
public async checkAclFormula(text: string): Promise<FormulaProperties> {
if (text) {
return this.gristDoc.docComm.checkAclFormula(text);
}
return {};
}
// Check if the given tableId, and optionally a list of colIds, are present in this document.
// Returns '' if valid, or an error string if not. Exempt colIds will not trigger an error.
public checkTableColumns(tableId: string, colIds?: string[], exemptColIds?: string[]): string {
if (!tableId || tableId === SPECIAL_RULES_TABLE_ID) { return ''; }
const tableColIds = this._aclResources.get(tableId)?.colIds;
if (!tableColIds) { return `Invalid table: ${tableId}`; }
if (colIds) {
const validColIds = new Set([...tableColIds, ...exemptColIds || []]);
const invalidColIds = colIds.filter(c => !validColIds.has(c));
if (invalidColIds.length === 0) { return ''; }
return `Invalid columns in table ${tableId}: ${invalidColIds.join(', ')}`;
}
return '';
}
// Returns a list of valid colIds for the given table, or undefined if the table isn't valid.
public getValidColIds(tableId: string): string[]|undefined {
return this._aclResources.get(tableId)?.colIds.filter(id => !isHiddenCol(id)).sort();
}
// Get rules to use for seeding any new set of table/column rules, e.g. to give owners
// broad rights over the table/column contents.
public getSeedRules(): ObsRulePart[] {
return this._specialRulesWithDefault.get()?.getCustomRules('SeedRule') || [];
}
private _addTableRules(tableId: string) {
if (this._tableRules.get().some(tr => tr.tableId === tableId)) {
throw new Error(`Trying to add TableRules for existing table ${tableId}`);
}
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
const tableRules = TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet);
this._tableRules.push(tableRules);
tableRules.addDefaultRules(this.getSeedRules());
}
private _addUserAttributes() {
this._userAttrRules.push(ObsUserAttributeRule.create(this._userAttrRules, this, undefined, {focus: true}));
}
private _onChange() {
if (this._ruleStatus.get() === RuleStatus.Unchanged) {
// If no changes, it's safe to just reload the rules from docData.
this.update().catch((e) => this._errorMessage.set(e.message));
} else {
this._errorMessage.set(
'Access rules have changed. Click Reset to revert your changes and refresh the rules.'
);
}
}
private async _updateDocAccessData() {
await this._aclUsersPopup.load();
}
private _addButtonsForMissingTables(buttons: Array<HTMLAnchorElement | HTMLButtonElement>, tableIds: string[]) {
for (const tableId of tableIds) {
// We don't know what the table's name was, just its tableId.
// Hopefully, the user will understand.
const title = t('Remove {{- tableId }} rules', { tableId });
const button = bigBasicButton(title, cssRemoveIcon('Remove'), dom.on('click', async () => {
await Promise.all(this._tableRules.get()
.filter(rules => rules.tableId === tableId)
.map(rules => rules.remove()));
button.style.display = 'none';
}));
buttons.push(button);
}
}
private _addButtonsForMissingColumns(buttons: Array<HTMLAnchorElement | HTMLButtonElement>,
tableId: string, colIds: string[]) {
const removeColRules = (rules: TableRules, colId: string) => {
for (const rule of rules.columnRuleSets.get()) {
const ruleColIds = new Set(rule.getColIdList());
if (!ruleColIds.has(colId)) { continue; }
if (ruleColIds.size === 1) {
rule.remove();
} else {
rule.removeColId(colId);
}
}
};
for (const colId of colIds) {
// TODO: we could translate tableId to table name in this case.
const title = t('Remove column {{- colId }} from {{- tableId }} rules', { tableId, colId });
const button = bigBasicButton(title, cssRemoveIcon('Remove'), dom.on('click', async () => {
await Promise.all(this._tableRules.get()
.filter(rules => rules.tableId === tableId)
.map(rules => removeColRules(rules, colId)));
button.style.display = 'none';
}));
buttons.push(button);
}
}
private _addButtonsForMisconfiguredUserAttributes(
buttons: Array<HTMLAnchorElement | HTMLButtonElement>,
names: string[]
) {
for (const name of names) {
const title = t('Remove {{- name }} user attribute', {name});
const button = bigBasicButton(title, cssRemoveIcon('Remove'), dom.on('click', async () => {
await Promise.all(this._userAttrRules.get()
.filter(rule => rule.name.get() === name)
.map(rule => rule.remove()));
button.style.display = 'none';
}));
buttons.push(button);
}
}
}
// Represents all rules for a table.
class TableRules extends Disposable {
// Whether any table rules changed, and if they are valid.
public ruleStatus: Computed<RuleStatus>;
// The column-specific rule sets.
protected _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, public _accessRules: AccessRules,
private _colRuleSets?: RuleSet[], private _defRuleSet?: RuleSet) {
super();
this._columnRuleSets.set(this._colRuleSets?.map(rs =>
this._createColumnObsRuleSet(this._columnRuleSets, this._accessRules, 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._accessRules, this, this._haveColumnRules);
} else if (this._defRuleSet) {
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules,
this._defRuleSet);
}
this.ruleStatus = Computed.create(this, (use) => {
const columnRuleSets = use(this._columnRuleSets);
const d = use(this._defaultRuleSet);
return Math.max(
getChangedStatus(
!this._colRuleSets || // This TableRules object must be newly-added
Boolean(d) !== Boolean(this._defRuleSet) || // Default rule set got added or removed
columnRuleSets.length < this._colRuleSets.length // There was a removal
),
d ? use(d.ruleStatus) : RuleStatus.Unchanged, // Default rule set got changed.
...columnRuleSets.map(rs => use(rs.ruleStatus))); // Column rule set was added or changed.
});
}
/**
* Get all custom rules for the specific column. Used to gather the current
* setting of a special rule. Returns an empty list for unknown columns.
*/
public getCustomRules(colId: string): ObsRulePart[] {
for (const ruleSet of this._columnRuleSets.get()) {
if (ruleSet.getColIds() === colId) {
return ruleSet.getCustomRules();
}
}
return [];
}
/**
* Add the provided rules, copying their formula, permissions, and memo.
*/
public addDefaultRules(rules: ObsRulePart[]) {
const ruleSet = this._defaultRuleSet.get();
ruleSet?.addRuleParts(rules, {foldEveryoneRule: true});
}
public remove() {
this._accessRules.removeTableRules(this);
}
public get columnRuleSets() {
return this._columnRuleSets;
}
public buildDom() {
return cssSection(
cssSectionHeading(
dom('span', t("Rules for table "), cssTableName(this._accessRules.getTableTitle(this.tableId))),
cssIconButton(icon('Dots'), {style: 'margin-left: auto'},
menu(() => [
menuItemAsync(() => this._addColumnRuleSet(), t("Add Column Rule")),
menuItemAsync(() => this._addDefaultRuleSet(), t("Add Default Rule"),
dom.cls('disabled', use => Boolean(use(this._defaultRuleSet)))),
menuItemAsync(() => this._accessRules.removeTableRules(this), t("Delete Table Rules")),
]),
testId('rule-table-menu-btn'),
),
testId('rule-table-header'),
),
cssTableRounded(
cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
cssCell4(
cssColumnGroup(
cssCellIcon(),
cssCell2(cssColHeaderCell(t('Condition'))),
cssCell1(cssColHeaderCell(t('Permissions'))),
cssCellIconWithMargins(),
cssCellIcon(),
)
),
),
this.buildColumnRuleSets(),
),
this.buildErrors(),
testId('rule-table'),
);
}
public buildColumnRuleSets() {
return [
dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildRuleSetDom()),
dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()),
];
}
public buildErrors() {
return dom.forEach(this._columnRuleSets, c => cssConditionError(dom.text(c.formulaError)));
}
/**
* 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 = {
allow: new Set<string>(), // columns mentioned in rules that only have 'allow's.
deny: new Set<string>(), // columns mentioned in rules that only have 'deny's.
mixed: new Set<string>() // columns mentioned in any rules.
};
for (const ruleSet of this._columnRuleSets.get()) {
const sign = ruleSet.summarizePermissions();
const counterSign = sign === 'mixed' ? 'mixed' : (sign === 'allow' ? 'deny' : 'allow');
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[counterSign].has(colId)) {
// There may be an order dependency between rules. We've done a little analysis, to
// allow the useful pattern of forbidding all access to columns, and then adding back
// access to different sets for different teams/conditions (or allowing all access
// by default, and then forbidding different sets). But if there's a mix of
// allows and denies, then we throw up our hands.
// TODO: could analyze more deeply. An easy step would be to analyze per permission bit.
// Could also allow order dependency and provide a way to control the order.
// TODO: could be worth also flagging multiple rulesets with the same columns as
// undesirable.
throw new UserError(`Column ${colId} appears in multiple rules for table ${this.tableId}` +
` that might be order-dependent. Try splitting rules up differently?`);
}
if (sign === 'mixed') {
seen.allow.add(colId);
seen.deny.add(colId);
seen.mixed.add(colId);
} else {
seen[sign].add(colId);
seen.mixed.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);
}
}
protected _createColumnObsRuleSet(
owner: IDisposableOwner, accessRules: AccessRules, tableRules: TableRules,
ruleSet: RuleSet|undefined, initialColIds: string[],
): ColumnObsRuleSet {
return ColumnObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
}
private _addColumnRuleSet() {
const ruleSet = ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, []);
this._columnRuleSets.push(ruleSet);
ruleSet.addRuleParts(this._accessRules.getSeedRules(), {foldEveryoneRule: true});
}
private _addDefaultRuleSet() {
if (!this._defaultRuleSet.get()) {
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);
this.addDefaultRules(this._accessRules.getSeedRules());
}
}
}
class SpecialRules extends TableRules {
public buildDom() {
return cssSection(
cssSectionHeading(t('Special Rules'), testId('rule-table-header')),
this.buildCheckBoxes(),
testId('rule-table'),
);
}
// Build dom with checkboxes, without a section wrapping it.
// Used for folding a special rule into another section.
public buildCheckBoxes() {
return [
this.buildColumnRuleSets(),
this.buildErrors(),
];
}
public getResources(): ResourceRec[] {
return this._columnRuleSets.get()
.filter(rs => !rs.hasOnlyBuiltInRules())
.map(rs => ({tableId: this.tableId, colIds: rs.getColIds()}));
}
protected _createColumnObsRuleSet(
owner: IDisposableOwner, accessRules: AccessRules, tableRules: TableRules,
ruleSet: RuleSet|undefined, initialColIds: string[],
): ColumnObsRuleSet {
if (isEqual(ruleSet?.colIds, ['SchemaEdit'])) {
// The special rule for "schemaEdit" permissions.
return SpecialSchemaObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
} else {
return SpecialObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
}
}
}
// 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, and if they are valid. Never unchanged if this._ruleSet is undefined.
public ruleStatus: Computed<RuleStatus>;
// List of individual rule parts for this entity. The default permissions may be included as the
// last rule part, with an empty aclFormula.
protected readonly _body = this.autoDispose(obsArray<ObsRulePart>());
// ruleSet is omitted for a new ObsRuleSet added by the user.
constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
super();
const parts = this._ruleSet?.body.map(part => ObsRulePart.create(this._body, this, part)) || [];
if (parts.length === 0) {
// If creating a new RuleSet, or if there are no rules,
// start with just a default permission part.
parts.push(ObsRulePart.create(this._body, this, undefined));
}
this._body.set(parts);
this.ruleStatus = Computed.create(this, this._body, (use, body) => {
// If anything was changed or added, some part.ruleStatus will be other than Unchanged. If
// there were only removals, then body.length will have changed.
// Ignore empty rules.
return Math.max(
getChangedStatus(body.filter(part => !part.isEmpty(use)).length < (this._ruleSet?.body?.length || 0)),
...body.map(part => use(part.ruleStatus)));
});
}
public remove() {
this._tableRules?.removeRuleSet(this);
}
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()}
}))
// Skip entirely empty rule parts: they are invalid and dropping them is the best fix.
.filter(part => part.aclFormula || part.permissionsText);
}
public getColIds(): string {
return '*';
}
/**
* Check if RuleSet may only add permissions, only remove permissions, or may do either.
* A rule that neither adds nor removes permissions is treated as mixed for simplicity,
* though this would be suboptimal if this were a useful case to support.
*/
public summarizePermissions(): MixedPermissionValue {
return summarizePermissions(this._body.get().map(p => p.summarizePermissions()));
}
public abstract buildResourceDom(): DomElementArg;
public buildRuleSetDom() {
return cssTableRow(
cssCell1(cssCell.cls('-rborder'),
this.buildResourceDom(),
testId('rule-resource')
),
cssCell4(cssRuleBody.cls(''),
dom.forEach(this._body, part => part.buildRulePartDom()),
dom.maybe(use => !this.hasDefaultCondition(use), () =>
cssColumnGroup(
{style: 'min-height: 28px'},
cssCellIcon(
cssIconButton(icon('Plus'),
dom.on('click', () => this.addRulePart(null)),
testId('rule-add'),
)
),
testId('rule-extra-add'),
)
),
),
testId('rule-set'),
);
}
public removeRulePart(rulePart: ObsRulePart) {
removeItem(this._body, rulePart);
if (this._body.get().length === 0) {
this._tableRules?.removeRuleSet(this);
}
}
public addRulePart(beforeRule: ObsRulePart|null,
content?: RulePart,
isNew: boolean = false): ObsRulePart {
const body = this._body.get();
const i = beforeRule ? body.indexOf(beforeRule) : body.length;
const part = ObsRulePart.create(this._body, this, content, isNew);
this._body.splice(i, 0, part);
return part;
}
/**
* Add a sequence of rules, taking priority over existing rules.
* optionally, if lowest-priority rule being added applies to
* everyone, and the existing rule also applies to everyone,
* fold those rules into one.
* This method is currently only called on newly created rule
* sets, so there's no need to check permissions and memos.
*/
public addRuleParts(newParts: ObsRulePart[], options: {foldEveryoneRule?: boolean}) {
// Check if we need to consider folding rules that apply to everyone.
if (options.foldEveryoneRule) {
const oldParts = this._body.get();
const myEveryonePart = (oldParts.length === 1 && !oldParts[0].getRulePart().aclFormula) ? oldParts[0] : null;
const newEveryonePart = newParts[newParts.length - 1]?.getRulePart().aclFormula ? null :
newParts[newParts.length - 1];
if (myEveryonePart && newEveryonePart) {
// It suffices to remove the existing rule that applies to everyone,
// which is just an empty default from rule set creation.
removeItem(this._body, myEveryonePart);
}
}
for (const part of [...newParts].reverse()) {
const {permissionsText, aclFormula, memo} = part.getRulePart();
if (permissionsText === undefined || aclFormula === undefined) {
// Should not happen.
continue;
}
this.addRulePart(
this.getFirst() || null,
{
aclFormula,
permissionsText,
permissions: parsePermissions(permissionsText),
memo,
},
true,
);
}
}
/**
* 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());
}
// Get first rule part, built-in or not.
public getFirst(): ObsRulePart|undefined {
return this._body.get()[0];
}
/**
* When an empty-condition 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-condition 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;
}
public hasDefaultCondition(use: UseCB): boolean {
const body = use(this._body);
return body.length > 0 && body[body.length - 1].hasEmptyCondition(use);
}
/**
* Which permission bits to allow the user to set.
*/
public getAvailableBits(): PermissionKey[] {
return ['read', 'update', 'create', 'delete'];
}
/**
* Get valid colIds for the table that this RuleSet is for.
*/
public getValidColIds(): string[] {
const tableId = this._tableRules?.tableId;
return (tableId && this.accessRules.getValidColIds(tableId)) || [];
}
/**
* Check if this rule set is limited to a set of columns.
*/
public hasColumns() {
return false;
}
public hasOnlyBuiltInRules() {
return this._body.get().every(rule => rule.isBuiltIn());
}
// Get rule parts that are neither built-in nor empty.
public getCustomRules(): ObsRulePart[] {
return this._body.get().filter(rule => !rule.isBuiltInOrEmpty());
}
}
class ColumnObsRuleSet extends ObsRuleSet {
// Error message for this rule set, or '' if valid.
public formulaError: Computed<string>;
private _colIds = Observable.create<string[]>(this, this._initialColIds);
constructor(accessRules: AccessRules, tableRules: TableRules, ruleSet: RuleSet|undefined,
private _initialColIds: string[]) {
super(accessRules, tableRules, ruleSet);
this.formulaError = Computed.create(this, (use) => {
// Exempt existing colIds from checks, by including as a third argument.
return accessRules.checkTableColumns(tableRules.tableId, use(this._colIds), this._initialColIds);
});
const baseRuleStatus = this.ruleStatus;
this.ruleStatus = Computed.create(this, (use) => {
if (use(this.formulaError)) { return RuleStatus.Invalid; }
return Math.max(
getChangedStatus(!isEqual(use(this._colIds), this._initialColIds)),
use(baseRuleStatus));
});
}
public buildResourceDom(): DomElementArg {
return aclColumnList(this._colIds, this._getValidColIdsList());
}
public getColIdList(): string[] {
return this._colIds.get();
}
public removeColId(colId: string) {
this._colIds.set(this._colIds.get().filter(c => (c !== colId)));
}
public getColIds(): string {
return this._colIds.get().join(",");
}
public getAvailableBits(): PermissionKey[] {
// Create/Delete bits can't be set on a column-specific rule.
return ['read', 'update'];
}
public hasColumns() {
return true;
}
private _getValidColIdsList(): string[] {
return this.getValidColIds().filter(id => id !== 'id');
}
}
class DefaultObsRuleSet extends ObsRuleSet {
constructor(accessRules: AccessRules, tableRules: TableRules|null,
private _haveColumnRules?: Observable<boolean>, ruleSet?: RuleSet) {
super(accessRules, tableRules, ruleSet);
}
public buildResourceDom() {
return [
cssCenterContent.cls(''),
cssDefaultLabel(
dom.text(use => this._haveColumnRules && use(this._haveColumnRules) ? 'All Other' : 'All'),
)
];
}
}
interface SpecialRuleBody {
permissions: string;
formula: string;
}
/**
* Properties we need to know about how a special rule should function and
* be rendered.
*/
interface SpecialRuleProperties extends SpecialRuleBody {
description: string;
name: string;
availableBits: PermissionKey[];
}
const schemaEditRules: {[key: string]: SpecialRuleBody} = {
allowEditors: {
permissions: '+S',
formula: 'user.Access == EDITOR',
},
denyEditors: {
permissions: '-S',
formula: 'user.Access != OWNER',
},
};
const specialRuleProperties: Record<string, SpecialRuleProperties> = {
AccessRules: {
name: t('Permission to view Access Rules'),
description: t('Allow everyone to view Access Rules.'),
availableBits: ['read'],
permissions: '+R',
formula: 'True',
},
FullCopies: {
name: t('Permission to access the document in full when needed'),
description: t(`Allow everyone to copy the entire document, or view it in full in fiddle mode.
Useful for examples and templates, but not for sensitive data.`),
availableBits: ['read'],
permissions: '+R',
formula: 'True',
},
SeedRule: {
name: t('Seed rules'),
description: t('When adding table rules, automatically add a rule to grant OWNER full access.'),
availableBits: ['read', 'create', 'update', 'delete'],
permissions: '+CRUD',
formula: 'user.Access in [OWNER]',
},
SchemaEdit: {
name: t("Permission to edit document structure"),
description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, \
layouts), and to write formulas, which give access to all data regardless of read restrictions."),
availableBits: ['schemaEdit'],
...schemaEditRules.denyEditors,
},
};
function getSpecialRuleProperties(name: string): SpecialRuleProperties {
return specialRuleProperties[name] || {
...specialRuleProperties.AccessRules,
name,
description: name,
};
}
class SpecialObsRuleSet extends ColumnObsRuleSet {
private _isExpanded = Observable.create<boolean>(this, false);
public get props() {
return getSpecialRuleProperties(this.getColIds());
}
public buildRuleSetDom() {
const isNonStandard = this._createIsNonStandardObs();
const isChecked = this._createIsCheckedObs(isNonStandard);
if (isNonStandard.get()) {
this._isExpanded.set(true);
}
return dom('div',
dom.autoDispose(isChecked),
dom.autoDispose(isNonStandard),
cssRuleDescription(
cssIconButton(icon('Expand'),
dom.style('transform', (use) => use(this._isExpanded) ? 'rotate(90deg)' : ''),
dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
testId('rule-special-expand'),
{style: 'margin: -4px'}, // subtract padding to align better.
),
cssCheckbox(isChecked,
dom.prop('disabled', isNonStandard),
testId('rule-special-checkbox'),
),
this.props.description,
),
this._buildDomWarning(),
dom.maybe(this._isExpanded, () =>
cssTableRounded(
{style: 'margin-left: 56px'},
cssTableHeaderRow(
cssCellIcon(),
cssCell4(cssColHeaderCell(this.props.name)),
cssCell1(cssColHeaderCell('Permissions')),
cssCellIconWithMargins(),
cssCellIcon(),
),
cssTableRow(
cssRuleBody.cls(''),
dom.forEach(this._body, part => part.buildRulePartDom(true)),
dom.maybe(use => !this.hasDefaultCondition(use), () =>
cssColumnGroup(
{style: 'min-height: 28px'},
cssCellIcon(
cssIconButton(
icon('Plus'),
dom.on('click', () => this.addRulePart(null)),
testId('rule-add'),
)
),
testId('rule-extra-add'),
)
),
),
testId('rule-set'),
)
),
testId('rule-special'),
testId(`rule-special-${this.getColIds()}`), // Make accessible in tests as, e.g. rule-special-FullCopies
);
}
public getAvailableBits(): PermissionKey[] {
return this.props.availableBits;
}
public removeRulePart(rulePart: ObsRulePart) {
removeItem(this._body, rulePart);
if (this._body.get().length === 0) {
this._isExpanded.set(false);
this._allowEveryone(false);
}
}
protected _buildDomWarning(): DomContents {
return null;
}
// Observable for whether this ruleSet is "standard", i.e. checked or unchecked state, without
// any strange rules that need to be shown expanded with the checkbox greyed out.
protected _createIsNonStandardObs(): Observable<boolean> {
return Computed.create(null, this._body, (use, body) =>
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));
}
// Observable for whether the checkbox should be shown as checked. Writing to it will update
// rules so as to toggle the checkbox.
protected _createIsCheckedObs(isNonStandard: Observable<boolean>): Observable<boolean> {
return Computed.create(null, this._body,
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))
.onWrite(val => this._allowEveryone(val));
}
private _allowEveryone(value: boolean) {
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
if (value) {
const rulePart = makeRulePart(this.props);
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
} else {
this._body.set(builtInRules);
if (builtInRules.length === 0) {
this._body.push(ObsRulePart.create(this._body, this, undefined));
}
}
}
}
function makeRulePart({permissions, formula}: SpecialRuleBody): RulePart {
const rulePart: RulePart = {
aclFormula: formula,
permissionsText: permissions,
permissions: parsePermissions(permissions),
};
return rulePart;
}
/**
* SchemaEdit permissions are moved out to a special fake resource "*SPECIAL:SchemaEdit" in the
* frontend, to be presented under their own checkbox option. Its behaviors are a bit different
* from other checkbox options; the differences are in the overridden methods here.
*/
class SpecialSchemaObsRuleSet extends SpecialObsRuleSet {
protected _buildDomWarning(): DomContents {
return dom.maybe(
(use) => use(this._body).every(rule => rule.isBuiltInOrEmpty(use)),
() => cssError(
t("This default should be changed if editors' access is to be limited. "),
dom('a', {style: 'color: inherit; text-decoration: underline'},
'Dismiss', dom.on('click', () => this._allowEditors('confirm'))),
testId('rule-schema-edit-warning'),
)
);
}
// SchemaEdit rules support an extra "standard" state, where a no-op rule exists (explicit rule
// allowing EDITORs SchemaEdit permission), in which case we don't show a warning.
protected _createIsNonStandardObs(): Observable<boolean> {
return Computed.create(null, this._body, (use, body) =>
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)
|| rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)));
}
protected _createIsCheckedObs(isNonStandard: Observable<boolean>): Observable<boolean> {
return Computed.create(null, this._body,
(use, body) => body.every(rule => rule.isBuiltInOrEmpty(use)
|| rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)))
.onWrite(val => this._allowEditors(val));
}
// The third "confirm" option is used by the "Dismiss" link in the warning.
private _allowEditors(value: boolean|'confirm') {
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
if (value === 'confirm') {
const rulePart = makeRulePart(schemaEditRules.allowEditors);
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
} else if (!value) {
const rulePart = makeRulePart(schemaEditRules.denyEditors);
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
} else {
this._body.set(builtInRules);
}
}
}
class ObsUserAttributeRule extends Disposable {
public ruleStatus: Computed<RuleStatus>;
// If the rule failed validation, the error message to show. Blank if valid.
public formulaError: Computed<string>;
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, 'user.' + (this._userAttr?.charId || ''));
private _validColIds = Computed.create(this, this._tableId, (use, tableId) =>
this._accessRules.getValidColIds(tableId) || []);
private _userAttrChoices: Computed<IAttrOption[]>;
private _userAttrError = Observable.create(this, '');
constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule,
private _options: {focus?: boolean} = {}) {
super();
this.formulaError = Computed.create(
this, this._tableId, this._lookupColId, this._userAttrError,
(use, tableId, colId, userAttrError) => {
if (userAttrError.length) {
return userAttrError;
}
// Don't check for errors if it's an existing rule and hasn't changed.
if (use(this._tableId) === this._userAttr?.tableId &&
use(this._lookupColId) === this._userAttr?.lookupColId) {
return '';
}
return _accessRules.checkTableColumns(tableId, colId ? [colId] : undefined);
});
this.ruleStatus = Computed.create(this, use => {
if (use(this.formulaError)) { return RuleStatus.Invalid; }
return getChangedStatus(
use(this._name) !== this._userAttr?.name ||
use(this._tableId) !== this._userAttr?.tableId ||
use(this._lookupColId) !== this._userAttr?.lookupColId ||
use(this._charId) !== 'user.' + this._userAttr?.charId
);
});
// Reset lookupColId when tableId changes, since a colId from a different table would usually be wrong
this.autoDispose(this._tableId.addListener(() => this._lookupColId.set('')));
this._userAttrChoices = Computed.create(this, _accessRules.userAttrRules, (use, rules) => {
// Filter for only those choices created by previous rules.
const index = rules.indexOf(this);
return use(this._accessRules.userAttrChoices).filter(c => (c.ruleIndex < index));
});
}
public remove() {
this._accessRules.removeUserAttributes(this);
}
public get name() { return this._name; }
public get tableId() { return this._tableId; }
public buildUserAttrDom() {
return cssTableRow(
cssCell1(cssCell.cls('-rborder'),
cssCellContent(
cssInput(this._name, async (val) => this._name.set(val),
{placeholder: t("Attribute name")},
(this._options.focus ? (elem) => { setTimeout(() => elem.focus(), 0); } : null),
testId('rule-userattr-name'),
),
),
),
cssCell4(cssRuleBody.cls(''),
cssColumnGroup(
cssCell1(
aclFormulaEditor({
gristTheme: this._accessRules.gristDoc.currentTheme,
initialValue: this._charId.get(),
readOnly: false,
setValue: (text) => this._setUserAttr(text),
placeholder: '',
getSuggestions: () => this._userAttrChoices.get().map(choice => choice.value),
customiseEditor: (editor => {
editor.on('focus', () => {
if (editor.getValue() == 'user.') {
// TODO this weirdly only works on the first click
(editor as any).completer?.showPopup(editor);
}
});
})
}),
testId('rule-userattr-attr'),
),
cssCell1(
aclSelect(
this._tableId,
this._accessRules.allTableIds.map(tableId => ({
value: tableId,
label: this._accessRules.getTableTitle(tableId),
})),
{defaultLabel: '[Select Table]'},
),
testId('rule-userattr-table'),
),
cssCell1(
aclSelect(this._lookupColId, this._validColIds,
{defaultLabel: '[Select Column]'}),
testId('rule-userattr-col'),
),
cssCellIcon(
cssIconButton(icon('Remove'),
dom.on('click', () => this._accessRules.removeUserAttributes(this)))
),
dom.maybe(this.formulaError, (msg) => cssConditionError(msg, testId('rule-error'))),
),
),
testId('rule-userattr'),
);
}
public getRule() {
const fullCharId = this._charId.get().trim();
const strippedCharId = fullCharId.startsWith('user.') ?
fullCharId.substring('user.'.length) : fullCharId;
const spec = {
name: this._name.get(),
tableId: this._tableId.get(),
lookupColId: this._lookupColId.get(),
charId: strippedCharId,
};
for (const [prop, value] of Object.entries(spec)) {
if (!value) {
throw new UserError(`Invalid user attribute rule: ${prop} must be set`);
}
}
if (this._getUserAttrError(fullCharId)) {
throw new UserError(`Invalid user attribute to look up`);
}
return {
id: this._userAttr?.origRecord?.id,
rulePos: this._userAttr?.origRecord?.rulePos as number|undefined,
userAttributes: JSON.stringify(spec),
};
}
private _setUserAttr(text: string) {
if (text === this._charId.get()) {
return;
}
this._charId.set(text);
this._userAttrError.set(this._getUserAttrError(text) || '');
}
private _getUserAttrError(text: string): string | null {
text = text.trim();
if (text.startsWith('user.LinkKey')) {
if (/user\.LinkKey\.\w+$/.test(text)) {
return null;
}
return 'Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something';
}
const isChoice = this._userAttrChoices.get().map(choice => choice.value).includes(text);
if (!isChoice) {
return 'Not a valid user attribute';
}
return null;
}
}
// 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 the rule part, and if it's valid or being checked.
public ruleStatus: Computed<RuleStatus>;
// Formula to show in the formula editor.
private _aclFormula = Observable.create<string>(this, this._rulePart?.aclFormula || "");
// Rule-specific completions for editing the formula, e.g. "user.Email" or "rec.City".
private _completions = Computed.create<string[]>(this, (use) => [
...use(this._ruleSet.accessRules.userAttrChoices).map(opt => opt.value),
...this._ruleSet.getValidColIds().map(colId => `rec.${colId}`),
...this._ruleSet.getValidColIds().map(colId => `$${colId}`),
...this._ruleSet.getValidColIds().map(colId => `newRec.${colId}`),
]);
// The permission bits.
private _permissions = Observable.create<PartialPermissionSet>(
this, this._rulePart?.permissions || emptyPermissionSet());
// The memo text. Updated whenever changes are made within `_memoEditor`.
private _memo: Observable<string>;
// Reference to the memo editor element, for triggering focus. Shown when
// `_showMemoEditor` is true.
private _memoEditor: HTMLInputElement | undefined;
// Is the memo editor visible? Initialized to true if a saved memo exists for this rule.
private _showMemoEditor: Observable<boolean>;
// Whether the rule is being checked after a change. Saving will wait for such checks to finish.
private _checkPending = Observable.create(this, false);
// If the formula failed validation, the error message to show. Blank if valid.
private _formulaError = Observable.create(this, '');
private _formulaProperties = Observable.create<FormulaProperties>(this, getAclFormulaProperties(this._rulePart));
// Error message if any validation failed.
private _error: Computed<string>;
constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart, isNew = false) {
super();
this._memo = Observable.create(this, _rulePart?.memo ?? '');
if (_rulePart && isNew) {
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to
// treat the rule as new and only use the rulePart for its initialization.
this._rulePart = undefined;
}
// If this rule has a blank memo, don't show the editor.
this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== '');
this._error = Computed.create(this, (use) => {
return use(this._formulaError) ||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
( !this._ruleSet.isLastCondition(use, this) &&
use(this._aclFormula) === '' &&
permissionSetToText(use(this._permissions)) !== '' ?
'Condition cannot be blank' : ''
);
});
const emptyPerms = emptyPermissionSet();
this.ruleStatus = Computed.create(this, (use) => {
if (use(this._error)) { return RuleStatus.Invalid; }
if (use(this._checkPending)) { return RuleStatus.CheckPending; }
return getChangedStatus(
use(this._aclFormula) !== (this._rulePart?.aclFormula ?? '') ||
use(this._memo) !== (this._rulePart?.memo ?? '') ||
!isEqual(use(this._permissions), this._rulePart?.permissions ?? emptyPerms)
);
});
}
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,
memo: this._memo.get(),
};
}
public hasEmptyCondition(use: UseCB): boolean {
return use(this._aclFormula) === '';
}
public matches(use: UseCB, aclFormula: string, permissionsText: string): boolean {
return (use(this._aclFormula) === aclFormula &&
permissionSetToText(use(this._permissions)) === permissionsText);
}
/**
* Check if RulePart may only add permissions, only remove permissions, or may do either.
* A rule that neither adds nor removes permissions is treated as mixed for simplicity,
* though this would be suboptimal if this were a useful case to support.
*/
public summarizePermissions(): MixedPermissionValue {
return summarizePermissionSet(this._permissions.get());
}
/**
* Verify that the rule is in a good state, optionally given a proposed permission change.
*/
public sanityCheck(pset?: PartialPermissionSet) {
// Nothing to do! We now support all expressible rule permutations.
}
public buildRulePartDom(wide: boolean = false) {
return cssRulePartAndMemo(
cssColumnGroup(
cssCellIcon(
(this._isNonFirstBuiltIn() ?
null :
cssIconButton(icon('Plus'),
dom.on('click', () => this._ruleSet.addRulePart(this)),
testId('rule-add'),
)
),
),
cssCell2(
wide ? cssCell4.cls('') : null,
aclFormulaEditor({
gristTheme: this._ruleSet.accessRules.gristDoc.currentTheme,
initialValue: this._aclFormula.get(),
readOnly: this.isBuiltIn(),
setValue: (value) => this._setAclFormula(value),
placeholder: dom.text((use) => {
return (
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
this._ruleSet.isLastCondition(use, this) ? t('Everyone Else') :
t('Enter Condition')
);
}),
getSuggestions: (prefix) => this._completions.get(),
}),
testId('rule-acl-formula'),
),
cssCell1(cssCell.cls('-stretch'),
permissionsWidget(this._ruleSet.getAvailableBits(), this._permissions,
{disabled: this.isBuiltIn(), sanityCheck: (pset) => this.sanityCheck(pset)},
testId('rule-permissions')
),
),
cssCellIconWithMargins(
dom.maybe(use => !this.isBuiltIn() && !use(this._showMemoEditor), () =>
cssIconButton(icon('Memo'),
dom.on('click', () => {
this._showMemoEditor.set(true);
// Note that focus is set when the memo icon is clicked, and not when
// the editor is attached to the DOM; because rules with non-blank
// memos have their editors visible by default when the page is first
// loaded, focusing on creation could cause unintended focusing.
setTimeout(() => this._memoEditor?.focus(), 0);
}),
testId('rule-memo-add'),
)
),
),
cssCellIcon(
(this.isBuiltIn() ?
null :
cssIconButton(icon('Remove'),
dom.on('click', () => this._ruleSet.removeRulePart(this)),
testId('rule-remove'),
)
),
),
dom.maybe(this._error, (msg) => cssConditionError(msg, testId('rule-error'))),
testId('rule-part'),
),
dom.maybe(this._showMemoEditor, () =>
cssMemoColumnGroup(
cssCellIcon(),
cssMemoIcon('Memo'),
cssCell2(
wide ? cssCell4.cls('') : null,
this._memoEditor = aclMemoEditor(this._memo,
{
placeholder: t("Type a message..."),
},
dom.onKeyDown({
// Match the behavior of the formula editor.
Enter: (_ev, el) => el.blur(),
}),
),
testId('rule-memo-editor'),
),
cssCellIconWithMargins(),
cssCellIcon(
cssIconButton(icon('Remove'),
dom.on('click', () => {
this._showMemoEditor.set(false);
this._memo.set('');
}),
testId('rule-memo-remove'),
),
),
testId('rule-memo'),
),
),
testId('rule-part-and-memo'),
);
}
public isBuiltIn(): boolean {
return this._rulePart ? !this._rulePart.origRecord?.id : false;
}
// return true if formula, permissions, and memo are all empty.
public isEmpty(use: UseCB = unwrap): boolean {
return use(this._aclFormula) === '' &&
isEqual(use(this._permissions), emptyPermissionSet()) &&
use(this._memo) === '';
}
public isBuiltInOrEmpty(use: UseCB = unwrap): boolean {
return this.isBuiltIn() || this.isEmpty(use);
}
private _isNonFirstBuiltIn(): boolean {
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
}
private async _setAclFormula(text: string) {
if (text === this._aclFormula.get()) { return; }
this._aclFormula.set(text);
this._checkPending.set(true);
this._formulaProperties.set({});
this._formulaError.set('');
try {
this._formulaProperties.set(await this._ruleSet.accessRules.checkAclFormula(text));
this.sanityCheck();
} catch (e) {
this._formulaError.set(e.message);
} finally {
this._checkPending.set(false);
}
}
private _warnInvalidColIds(colIds?: string[]) {
if (!colIds || !colIds.length) { return false; }
const allValid = new Set(this._ruleSet.getValidColIds());
const invalid = colIds.filter(c => !allValid.has(c));
if (invalid.length > 0) {
return `Invalid columns: ${invalid.join(', ')}`;
}
}
}
/**
* Produce UserActions to create/update/remove records, to replace data in tableData
* with newRecords. Records are matched on uniqueId(record), which defaults to returning
* String(record.id). UniqueIds of new records don't need to be unique as long as they don't
* overlap with uniqueIds of existing records.
*
* Return also a rowIdMap, mapping uniqueId(record) to a rowId used in the actions. The rowIds may
* include negative values (auto-generated when newRecords doesn't include one). These may be used
* in Reference values within the same action bundle.
*
* TODO This is a general-purpose function, and should live in a separate module.
*/
function syncRecords(tableData: TableData, newRecords: RowRecord[],
uniqueId: (r: RowRecord) => string = (r => String(r.id))
): {userActions: UserAction[], rowIdMap: Map<string, number>} {
const oldRecords = tableData.getRecords();
const rowIdMap = new Map<string, number>(oldRecords.map(r => [uniqueId(r), r.id]));
const newRecordMap = new Map<string, RowRecord>(newRecords.map(r => [uniqueId(r), r]));
const removedRecords: RowRecord[] = oldRecords.filter(r => !newRecordMap.has(uniqueId(r)));
// Generate a unique negative rowId for each added record.
const addedRecords: RowRecord[] = newRecords.filter(r => !rowIdMap.has(uniqueId(r)))
.map((r, index) => ({...r, id: -(index + 1)}));
// 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(isNonNullish);
console.log("syncRecords: removing [%s], adding [%s], updating [%s]",
removedRecords.map(uniqueId).join(", "),
addedRecords.map(uniqueId).join(", "),
updatedRecords.map(([r]) => uniqueId(r)).join(", "));
const tableId = tableData.tableId;
const userActions: UserAction[] = [];
if (removedRecords.length > 0) {
userActions.push(['BulkRemoveRecord', tableId, removedRecords.map(r => r.id)]);
}
if (updatedRecords.length > 0) {
userActions.push(['BulkUpdateRecord', tableId, updatedRecords.map(([r]) => r.id), getColChanges(updatedRecords)]);
}
if (addedRecords.length > 0) {
userActions.push(['BulkAddRecord', tableId, addedRecords.map(r => r.id), getColValues(addedRecords)]);
}
// Include generated rowIds for added records into the returned map.
addedRecords.forEach(r => rowIdMap.set(uniqueId(r), r.id));
return {userActions, rowIdMap};
}
/**
* 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;
}
function getChangedStatus(value: boolean): RuleStatus {
return value ? RuleStatus.ChangedValid : RuleStatus.Unchanged;
}
function getAclFormulaProperties(part?: RulePart): FormulaProperties {
const aclFormulaParsed = part?.origRecord?.aclFormulaParsed;
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
}
// Return a rule set if it applies to one of the specified columns.
function filterRuleSet(colIds: string[], ruleSet?: RuleSet): RuleSet|undefined {
if (!ruleSet) { return undefined; }
if (ruleSet.colIds === '*') { return ruleSet; }
for (const colId of ruleSet.colIds) {
if (colIds.includes(colId)) { return ruleSet; }
}
return undefined;
}
// Filter an array of rule sets for just those that apply to one of the specified
// columns.
function filterRuleSets(colIds: string[], ruleSets: RuleSet[]): RuleSet[] {
return ruleSets.map(ruleSet => filterRuleSet(colIds, ruleSet)).filter(rs => rs) as RuleSet[];
}
const cssOuter = styled('div', `
flex: auto;
height: 100%;
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
`);
const cssAddTableRow = styled('div', `
flex: none;
margin: 16px 16px 8px 16px;
display: flex;
gap: 16px;
`);
const cssDropdownIcon = styled(icon, `
margin: -2px -2px 0 4px;
`);
const cssRemoveIcon = styled(icon, `
margin: -2px -2px 0 4px;
`);
const cssSection = styled('div', `
margin: 16px 16px 24px 16px;
`);
const cssSectionHeading = styled('div', `
display: flex;
align-items: center;
margin-bottom: 8px;
font-weight: bold;
color: ${theme.lightText};
`);
const cssTableName = styled('span', `
color: ${theme.text};
`);
const cssInput = styled(textInput, `
color: ${theme.inputFg};
background-color: ${theme.inputBg};
width: 100%;
border: 1px solid transparent;
cursor: pointer;
&:hover {
border: 1px solid ${theme.inputBorder};
}
&:focus {
box-shadow: inset 0 0 0 1px ${theme.controlFg};
border-color: ${theme.controlFg};
cursor: unset;
}
&[disabled] {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
box-shadow: unset;
border-color: transparent;
}
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
`);
const cssError = styled('div', `
color: ${theme.errorText};
margin-left: 56px;
margin-bottom: 8px;
margin-top: 4px;
`);
const cssConditionError = styled('div', `
color: ${theme.errorText};
margin-top: 4px;
width: 100%;
`);
/**
* Fairly general table styles.
*/
const cssTableRounded = styled('div', `
border: 1px solid ${theme.accessRulesTableBorder};
border-radius: 8px;
overflow: hidden;
`);
// Row with a border
const cssTableRow = styled('div', `
display: flex;
border-bottom: 1px solid ${theme.accessRulesTableBorder};
&:last-child {
border-bottom: none;
}
`);
// Darker table header
const cssTableHeaderRow = styled(cssTableRow, `
background-color: ${theme.accessRulesTableHeaderBg};
color: ${theme.accessRulesTableHeaderFg};
`);
// Cell for table column header.
const cssColHeaderCell = styled('div', `
margin: 4px 8px;
text-transform: uppercase;
font-weight: 500;
font-size: 10px;
`);
// General table cell.
const cssCell = styled('div', `
min-width: 0px;
overflow: hidden;
&-rborder {
border-right: 1px solid ${theme.accessRulesTableBorder};
}
&-center {
text-align: center;
}
&-stretch {
min-width: unset;
overflow: visible;
}
`);
// Variations on columns of different widths.
const cssCellIcon = styled(cssCell, `flex: none; width: 24px;`);
const cssCellIconWithMargins = styled(cssCellIcon, `margin: 0px 8px;`);
const cssCell1 = styled(cssCell, `flex: 1;`);
const cssCell2 = styled(cssCell, `flex: 2;`);
const cssCell4 = styled(cssCell, `flex: 4;`);
// Group of columns, which may be placed inside a cell.
const cssColumnGroup = styled('div', `
display: flex;
align-items: center;
gap: 0px 8px;
margin: 0 8px;
flex-wrap: wrap;
`);
const cssRuleBody = styled('div', `
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0;
`);
const cssRuleDescription = styled('div', `
color: ${theme.text};
display: flex;
align-items: top;
margin: 16px 0 8px 0;
gap: 12px;
white-space: pre-line; /* preserve line breaks in long descriptions */
`);
const cssCheckbox = styled(squareCheckbox, `
flex: none;
`);
const cssCellContent = styled('div', `
margin: 4px 8px;
`);
const cssCenterContent = styled('div', `
display: flex;
align-items: center;
justify-content: center;
`);
const cssDefaultLabel = styled('div', `
color: ${theme.accessRulesTableBodyFg};
font-weight: bold;
`);
const cssRuleProblems = styled('div', `
flex: auto;
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
`);
const cssRulePartAndMemo = styled('div', `
display: flex;
flex-direction: column;
row-gap: 4px;
`);
const cssMemoColumnGroup = styled(cssColumnGroup, `
margin-bottom: 8px;
`);
const cssMemoIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
margin-left: 8px;
margin-right: 8px;
`);
const cssSeedRule = styled('div', `
margin-bottom: 16px;
`);