(core) add a checkbox for owner "boss mode"

Summary:
Implement a checkbox that grants owners full access to tables by default, when creating new table/column rules.
 * Checkbox appears above default rules.
 * When set, a rule giving owners full access will be inserted in any new rule set started for tables or columns.
 * The checkbox can be expanded to allow customization of the rules.

https://gristlabs.getgrist.com/doc/check-ins/p/3#a1.s7.r2251.c19

Test Plan: added tests

Reviewers: jarek

Reviewed By: jarek

Subscribers: anaisconce

Differential Revision: https://phab.getgrist.com/D3756
This commit is contained in:
Paul Fitzpatrick 2023-01-09 12:49:58 -05:00
parent b59829d57e
commit e6692c2793
6 changed files with 318 additions and 73 deletions

View File

@ -37,7 +37,7 @@ import {
UserAttributeRule UserAttributeRule
} from 'app/common/GranularAccessClause'; } from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes'; import {isHiddenCol} from 'app/common/gristTypes';
import {isNonNullish} from 'app/common/gutil'; import {isNonNullish, unwrap} from 'app/common/gutil';
import {SchemaTypes} from 'app/common/schema'; import {SchemaTypes} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
import { import {
@ -98,7 +98,10 @@ export class AccessRules extends Disposable {
private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null); private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
// Special document-level rules, for resources of the form ("*SPECIAL:<RuleType>"). // Special document-level rules, for resources of the form ("*SPECIAL:<RuleType>").
private _specialRules = Observable.create<SpecialRules|null>(this, null); // 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. // Array of all UserAttribute rules.
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>()); private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
@ -126,7 +129,8 @@ export class AccessRules extends Disposable {
this._ruleStatus = Computed.create(this, (use) => { this._ruleStatus = Computed.create(this, (use) => {
const defRuleSet = use(this._docDefaultRuleSet); const defRuleSet = use(this._docDefaultRuleSet);
const tableRules = use(this._tableRules); const tableRules = use(this._tableRules);
const specialRules = use(this._specialRules); const specialRulesWithDefault = use(this._specialRulesWithDefault);
const specialRulesSeparate = use(this._specialRulesSeparate);
const userAttr = use(this._userAttrRules); const userAttr = use(this._userAttrRules);
return Math.max( return Math.max(
defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged, defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged,
@ -136,7 +140,8 @@ export class AccessRules extends Disposable {
getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size), getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),
...tableRules.map(tr => use(tr.ruleStatus)), ...tableRules.map(tr => use(tr.ruleStatus)),
...userAttr.map(u => use(u.ruleStatus)), ...userAttr.map(u => use(u.ruleStatus)),
specialRules ? use(specialRules.ruleStatus) : RuleStatus.Unchanged, specialRulesWithDefault ? use(specialRulesWithDefault.ruleStatus) : RuleStatus.Unchanged,
specialRulesSeparate ? use(specialRulesSeparate.ruleStatus) : RuleStatus.Unchanged,
); );
}); });
@ -211,10 +216,17 @@ export class AccessRules extends Disposable {
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId))) tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
); );
SpecialRules.create(this._specialRules, SPECIAL_RULES_TABLE_ID, this, const withDefaultRules = ['SeedRule'];
rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID), const separateRules = ['FullCopies', 'AccessRules'];
rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID));
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()); DefaultObsRuleSet.create(this._docDefaultRuleSet, this, null, undefined, rules.getDocDefaultRuleSet());
this._userAttrRules.set( this._userAttrRules.set(
Array.from(rules.getUserAttributeRules().values(), userAttr => Array.from(rules.getUserAttributeRules().values(), userAttr =>
@ -238,7 +250,8 @@ export class AccessRules extends Disposable {
// Add/remove resources to have just the ones we need. // Add/remove resources to have just the ones we need.
const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten( const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten(
[{tableId: '*', colIds: '*'}], [{tableId: '*', colIds: '*'}],
this._specialRules.get()?.getResources() || [], this._specialRulesWithDefault.get()?.getResources() || [],
this._specialRulesSeparate.get()?.getResources() || [],
...this._tableRules.get().map(tr => tr.getResources())) ...this._tableRules.get().map(tr => tr.getResources()))
.map(r => ({id: -1, ...r})); .map(r => ({id: -1, ...r}));
@ -395,6 +408,8 @@ export class AccessRules extends Disposable {
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()), dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
cssSection( cssSection(
cssSectionHeading(t("Default Rules"), testId('rule-table-header')), cssSectionHeading(t("Default Rules"), testId('rule-table-header')),
dom.maybe(this._specialRulesWithDefault, tableRules => cssSeedRule(
tableRules.buildCheckBoxes())),
cssTableRounded( cssTableRounded(
cssTableHeaderRow( cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')), cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
@ -412,7 +427,7 @@ export class AccessRules extends Disposable {
), ),
testId('rule-table'), testId('rule-table'),
), ),
dom.maybe(this._specialRules, tableRules => tableRules.buildDom()), dom.maybe(this._specialRulesSeparate, tableRules => tableRules.buildDom()),
), ),
); );
} }
@ -443,7 +458,8 @@ export class AccessRules extends Disposable {
public getRules(): RuleRec[] { public getRules(): RuleRec[] {
return flatten( return flatten(
...this._tableRules.get().map(tr => tr.getRules()), ...this._tableRules.get().map(tr => tr.getRules()),
this._specialRules.get()?.getRules() || [], this._specialRulesWithDefault.get()?.getRules() || [],
this._specialRulesSeparate.get()?.getRules() || [],
this._docDefaultRuleSet.get()?.getRules('*') || [] this._docDefaultRuleSet.get()?.getRules('*') || []
); );
} }
@ -483,12 +499,20 @@ export class AccessRules extends Disposable {
return this._aclResources.get(tableId)?.colIds.filter(id => !isHiddenCol(id)).sort(); 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) { private _addTableRules(tableId: string) {
if (this._tableRules.get().some(tr => tr.tableId === tableId)) { if (this._tableRules.get().some(tr => tr.tableId === tableId)) {
throw new Error(`Trying to add TableRules for existing table ${tableId}`); throw new Error(`Trying to add TableRules for existing table ${tableId}`);
} }
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []}; const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
this._tableRules.push(TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet)); const tableRules = TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet);
this._tableRules.push(tableRules);
tableRules.addDefaultRules(this.getSeedRules());
} }
private _addUserAttributes() { private _addUserAttributes() {
@ -617,6 +641,27 @@ class TableRules extends Disposable {
}); });
} }
/**
* 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() { public remove() {
this._accessRules.removeTableRules(this); this._accessRules.removeTableRules(this);
} }
@ -749,12 +794,15 @@ class TableRules extends Disposable {
} }
private _addColumnRuleSet() { private _addColumnRuleSet() {
this._columnRuleSets.push(ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, [])); const ruleSet = ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, []);
this._columnRuleSets.push(ruleSet);
ruleSet.addRuleParts(this._accessRules.getSeedRules(), {foldEveryoneRule: true});
} }
private _addDefaultRuleSet() { private _addDefaultRuleSet() {
if (!this._defaultRuleSet.get()) { if (!this._defaultRuleSet.get()) {
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules); DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);
this.addDefaultRules(this._accessRules.getSeedRules());
} }
} }
} }
@ -762,13 +810,21 @@ class TableRules extends Disposable {
class SpecialRules extends TableRules { class SpecialRules extends TableRules {
public buildDom() { public buildDom() {
return cssSection( return cssSection(
cssSectionHeading(t("Special Rules"), testId('rule-table-header')), cssSectionHeading(t('Special Rules'), testId('rule-table-header')),
this.buildColumnRuleSets(), this.buildCheckBoxes(),
this.buildErrors(),
testId('rule-table'), 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[] { public getResources(): ResourceRec[] {
return this._columnRuleSets.get() return this._columnRuleSets.get()
.filter(rs => !rs.hasOnlyBuiltInRules()) .filter(rs => !rs.hasOnlyBuiltInRules())
@ -796,18 +852,20 @@ abstract class ObsRuleSet extends Disposable {
// ruleSet is omitted for a new ObsRuleSet added by the user. // ruleSet is omitted for a new ObsRuleSet added by the user.
constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) { constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
super(); super();
if (this._ruleSet) { const parts = this._ruleSet?.body.map(part => ObsRulePart.create(this._body, this, part)) || [];
this._body.set(this._ruleSet.body.map(part => ObsRulePart.create(this._body, this, part))); if (parts.length === 0) {
} else { // If creating a new RuleSet, or if there are no rules,
// If creating a new RuleSet, start with just a default permission part. // start with just a default permission part.
this._body.set([ObsRulePart.create(this._body, this, undefined)]); parts.push(ObsRulePart.create(this._body, this, undefined));
} }
this._body.set(parts);
this.ruleStatus = Computed.create(this, this._body, (use, body) => { this.ruleStatus = Computed.create(this, this._body, (use, body) => {
// If anything was changed or added, some part.ruleStatus will be other than Unchanged. If // 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. // there were only removals, then body.length will have changed.
// Ignore empty rules.
return Math.max( return Math.max(
getChangedStatus(body.length < (this._ruleSet?.body?.length || 0)), getChangedStatus(body.filter(part => !part.isEmpty(use)).length < (this._ruleSet?.body?.length || 0)),
...body.map(part => use(part.ruleStatus))); ...body.map(part => use(part.ruleStatus)));
}); });
} }
@ -873,10 +931,54 @@ abstract class ObsRuleSet extends Disposable {
} }
} }
public addRulePart(beforeRule: ObsRulePart|null) { public addRulePart(beforeRule: ObsRulePart|null,
content?: RulePart,
isNew: boolean = false): ObsRulePart {
const body = this._body.get(); const body = this._body.get();
const i = beforeRule ? body.indexOf(beforeRule) : body.length; const i = beforeRule ? body.indexOf(beforeRule) : body.length;
this._body.splice(i, 0, ObsRulePart.create(this._body, this, undefined)); 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,
);
}
} }
/** /**
@ -887,6 +989,11 @@ abstract class ObsRuleSet extends Disposable {
return this._body.get().find(p => p.isBuiltIn()); 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 * When an empty-condition RulePart is the only part of a RuleSet, we can say it applies to
* "Everyone". * "Everyone".
@ -939,6 +1046,11 @@ abstract class ObsRuleSet extends Disposable {
public hasOnlyBuiltInRules() { public hasOnlyBuiltInRules() {
return this._body.get().every(rule => rule.isBuiltIn()); 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 { class ColumnObsRuleSet extends ObsRuleSet {
@ -1006,58 +1118,91 @@ class DefaultObsRuleSet extends ObsRuleSet {
} }
} }
function getSpecialRuleDescription(type: string): string { /**
switch (type) { * Properties we need to know about how a special rule should function and
case 'AccessRules': * be rendered.
return t("Allow everyone to view Access Rules."); */
case 'FullCopies': interface SpecialRuleProperties {
return t(`Allow everyone to copy the entire document, or view it in full in fiddle mode. description: string;
Useful for examples and templates, but not for sensitive data.`); name: string;
default: return type; availableBits: PermissionKey[];
} permissions: string;
formula: string;
} }
function getSpecialRuleName(type: string): string { const specialRuleProperties: Record<string, SpecialRuleProperties> = {
switch (type) { AccessRules: {
case 'AccessRules': return t("Permission to view Access Rules"); name: t('Permission to view Access Rules'),
case 'FullCopies': return t("Permission to access the document in full when needed"); description: t('Allow everyone to view Access Rules.'),
default: return type; 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]',
},
};
function getSpecialRuleProperties(name: string): SpecialRuleProperties {
return specialRuleProperties[name] || {
...specialRuleProperties.AccessRules,
name,
description: name,
};
} }
class SpecialObsRuleSet extends ColumnObsRuleSet { class SpecialObsRuleSet extends ColumnObsRuleSet {
private _isExpanded = Observable.create<boolean>(this, false);
public get props() {
return getSpecialRuleProperties(this.getColIds());
}
public buildRuleSetDom() { public buildRuleSetDom() {
const isNonStandard: Observable<boolean> = Computed.create(null, this._body, (use, body) => const isNonStandard: Observable<boolean> = Computed.create(null, this._body, (use, body) =>
!body.every(rule => rule.isBuiltIn() || rule.matches(use, 'True', '+R'))); !body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));
const allowEveryone: Observable<boolean> = Computed.create(null, this._body, const allowEveryone: Observable<boolean> = Computed.create(null, this._body,
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltIn())) (use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))
.onWrite(val => this._allowEveryone(val)); .onWrite(val => this._allowEveryone(val));
const isExpanded = Observable.create<boolean>(null, isNonStandard.get()); if (isNonStandard.get()) {
this._isExpanded.set(true);
}
return dom('div', return dom('div',
dom.autoDispose(isExpanded),
dom.autoDispose(allowEveryone), dom.autoDispose(allowEveryone),
cssRuleDescription( cssRuleDescription(
{style: 'white-space: pre-line;'}, // preserve line breaks in long descriptions {style: 'white-space: pre-line;'}, // preserve line breaks in long descriptions
cssIconButton(icon('Expand'), cssIconButton(icon('Expand'),
dom.style('transform', (use) => use(isExpanded) ? 'rotate(90deg)' : ''), dom.style('transform', (use) => use(this._isExpanded) ? 'rotate(90deg)' : ''),
dom.on('click', () => isExpanded.set(!isExpanded.get())), dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
testId('rule-special-expand'), testId('rule-special-expand'),
), ),
squareCheckbox(allowEveryone, squareCheckbox(allowEveryone,
dom.prop('disabled', isNonStandard), dom.prop('disabled', isNonStandard),
testId('rule-special-checkbox'), testId('rule-special-checkbox'),
), ),
getSpecialRuleDescription(this.getColIds()), this.props.description,
), ),
dom.maybe(isExpanded, () => dom.maybe(this._isExpanded, () =>
cssTableRounded( cssTableRounded(
{style: 'margin-left: 56px'}, {style: 'margin-left: 56px'},
cssTableHeaderRow( cssTableHeaderRow(
cssCellIcon(), cssCellIcon(),
cssCell4(cssColHeaderCell(getSpecialRuleName(this.getColIds()))), cssCell4(cssColHeaderCell(this.props.name)),
cssCell1(cssColHeaderCell('Permissions')), cssCell1(cssColHeaderCell('Permissions')),
cssCellIconWithMargins(), cssCellIconWithMargins(),
cssCellIcon(), cssCellIcon(),
@ -1065,6 +1210,19 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
cssTableRow( cssTableRow(
cssRuleBody.cls(''), cssRuleBody.cls(''),
dom.forEach(this._body, part => part.buildRulePartDom(true)), 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-set'),
) )
@ -1075,20 +1233,31 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
} }
public getAvailableBits(): PermissionKey[] { public getAvailableBits(): PermissionKey[] {
return ['read']; 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);
}
} }
private _allowEveryone(value: boolean) { private _allowEveryone(value: boolean) {
const builtInRules = this._body.get().filter(r => r.isBuiltIn()); const builtInRules = this._body.get().filter(r => r.isBuiltIn());
if (value === true) { if (value) {
const rulePart: RulePart = { const rulePart: RulePart = {
aclFormula: 'True', aclFormula: this.props.formula,
permissionsText: '+R', permissionsText: this.props.permissions,
permissions: parsePermissions('+R'), permissions: parsePermissions(this.props.permissions),
}; };
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]); this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
} else if (value === false) { } else {
this._body.set(builtInRules); this._body.set(builtInRules);
if (builtInRules.length === 0) {
this._body.push(ObsRulePart.create(this._body, this, undefined));
}
} }
} }
} }
@ -1307,16 +1476,15 @@ class ObsRulePart extends Disposable {
super(); super();
this._memo = Observable.create(this, _rulePart?.memo ?? ''); this._memo = Observable.create(this, _rulePart?.memo ?? '');
// If this rule has a blank memo, don't show the editor.
this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== '');
if (_rulePart && isNew) { if (_rulePart && isNew) {
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to // 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. // treat the rule as new and only use the rulePart for its initialization.
this._rulePart = undefined; 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) => { this._error = Computed.create(this, (use) => {
return use(this._formulaError) || return use(this._formulaError) ||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) || this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
@ -1327,13 +1495,14 @@ class ObsRulePart extends Disposable {
); );
}); });
const emptyPerms = emptyPermissionSet();
this.ruleStatus = Computed.create(this, (use) => { this.ruleStatus = Computed.create(this, (use) => {
if (use(this._error)) { return RuleStatus.Invalid; } if (use(this._error)) { return RuleStatus.Invalid; }
if (use(this._checkPending)) { return RuleStatus.CheckPending; } if (use(this._checkPending)) { return RuleStatus.CheckPending; }
return getChangedStatus( return getChangedStatus(
use(this._aclFormula) !== this._rulePart?.aclFormula || use(this._aclFormula) !== (this._rulePart?.aclFormula ?? '') ||
use(this._memo) !== (this._rulePart?.memo ?? '') || use(this._memo) !== (this._rulePart?.memo ?? '') ||
!isEqual(use(this._permissions), this._rulePart?.permissions) !isEqual(use(this._permissions), this._rulePart?.permissions ?? emptyPerms)
); );
}); });
} }
@ -1475,6 +1644,17 @@ class ObsRulePart extends Disposable {
return this._rulePart ? !this._rulePart.origRecord?.id : false; 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 { private _isNonFirstBuiltIn(): boolean {
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this; return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
} }
@ -1605,6 +1785,22 @@ function getAclFormulaProperties(part?: RulePart): FormulaProperties {
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(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', ` const cssOuter = styled('div', `
flex: auto; flex: auto;
height: 100%; height: 100%;
@ -1798,3 +1994,7 @@ const cssMemoIcon = styled(icon, `
margin-left: 8px; margin-left: 8px;
margin-right: 8px; margin-right: 8px;
`); `);
const cssSeedRule = styled('div', `
margin-bottom: 16px;
`);

View File

@ -15,6 +15,9 @@ import {makeT} from 'app/client/lib/localization';
// One of the strings 'read', 'update', etc. // One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PartialPermissionSet; export type PermissionKey = keyof PartialPermissionSet;
// Canonical order of permission bits when rendered in a permissionsWidget.
const PERMISSION_BIT_ORDER = 'RUCDS';
const t = makeT('PermissionsWidget'); const t = makeT('PermissionsWidget');
/** /**
@ -26,6 +29,7 @@ export function permissionsWidget(
options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void}, options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void},
...args: DomElementArg[] ...args: DomElementArg[]
) { ) {
availableBits = sortBits(availableBits);
// These are the permission sets available to set via the dropdown. // These are the permission sets available to set via the dropdown.
const empty: PartialPermissionSet = emptyPermissionSet(); const empty: PartialPermissionSet = emptyPermissionSet();
const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow'); const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow');
@ -125,6 +129,20 @@ function psetDescription(permissionSet: PartialPermissionSet): string {
return parts.join(' '); return parts.join(' ');
} }
/**
* Sort the bits in a standard way for viewing, since they could be in any order
* in the underlying rule store. And in fact ACLPermissions.permissionSetToText
* uses an order (CRUDS) that is different from how things have been historically
* rendered in the UI (RUCDS).
*/
function sortBits(bits: PermissionKey[]) {
return bits.sort((a, b) => {
const aIndex = PERMISSION_BIT_ORDER.indexOf(a.slice(0, 1).toUpperCase());
const bIndex = PERMISSION_BIT_ORDER.indexOf(b.slice(0, 1).toUpperCase());
return aIndex - bIndex;
});
}
const cssPermissions = styled('div', ` const cssPermissions = styled('div', `
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@ -20,7 +20,7 @@ import {UserAction} from 'app/common/DocActions';
import {Computed, dom, fromKo, Observable} from 'grainjs'; import {Computed, dom, fromKo, Observable} from 'grainjs';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
const t = makeT('TypeTransformation'); const t = makeT('TypeTransform');
// To simplify diff (avoid rearranging methods to satisfy private/public order). // To simplify diff (avoid rearranging methods to satisfy private/public order).
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */

View File

@ -64,6 +64,11 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
permissions: parsePermissions('-R'), permissions: parsePermissions('-R'),
permissionsText: '-R', permissionsText: '-R',
}], }],
},
SeedRule: {
tableId: SPECIAL_RULES_TABLE_ID,
colIds: ['SeedRule'],
body: [],
} }
}; };
@ -179,11 +184,15 @@ export class ACLRuleCollection {
const specialType = String(ruleSet.colIds); const specialType = String(ruleSet.colIds);
const specialDefault = specialRuleSets.get(specialType); const specialDefault = specialRuleSets.get(specialType);
if (!specialDefault) { if (!specialDefault) {
throw new Error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`); // Log that we are seeing an invalid rule, but don't fail.
} // (Historically, older versions of the Grist app will attempt to
// open newer documents).
options.log.error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);
} else {
specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]}); specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]});
} }
} }
}
// Insert the special rule sets into colRuleSets. // Insert the special rule sets into colRuleSets.
for (const ruleSet of specialRuleSets.values()) { for (const ruleSet of specialRuleSets.values()) {

View File

@ -134,7 +134,13 @@ export class MinIOExternalStorage implements ExternalStorage {
} }
public isFatalError(err: any) { public isFatalError(err: any) {
return err.code !== 'NotFound' && err.code !== 'NoSuchKey'; // ECONNRESET should not count as fatal:
// https://github.com/aws/aws-sdk-js/pull/3739
// Likewise for "We encountered an internal error. Please try again."
// These are errors associated with the AWS S3 backend, and which
// the AWS S3 SDK would typically handle.
return err.code !== 'NotFound' && err.code !== 'NoSuchKey' &&
err.code !== 'ECONNRESET' && err.code !== 'InternalError';
} }
public async close() { public async close() {

View File

@ -18,13 +18,15 @@ const parser = new Parser({
nsSeparator: null, nsSeparator: null,
}); });
async function* walk(dir) { async function* walk(dirs) {
for (const dir of dirs) {
for await (const d of await fs.promises.opendir(dir)) { for await (const d of await fs.promises.opendir(dir)) {
const entry = path.join(dir, d.name); const entry = path.join(dir, d.name);
if (d.isDirectory()) yield* walk(entry); if (d.isDirectory()) yield* walk([entry]);
else if (d.isFile()) yield entry; else if (d.isFile()) yield entry;
} }
} }
}
const customHandler = (fileName) => (key, options) => { const customHandler = (fileName) => (key, options) => {
const keyWithFile = `${fileName}/${key}`; const keyWithFile = `${fileName}/${key}`;
@ -38,6 +40,15 @@ const customHandler = (fileName) => (key, options) => {
} }
}; };
function sort(obj) {
if (typeof obj !== "object" || Array.isArray(obj))
return obj;
const sortedObject = {};
const keys = Object.keys(obj).sort();
keys.forEach(key => sortedObject[key] = sort(obj[key]));
return sortedObject;
}
const getKeysFromFile = (filePath, fileName) => { const getKeysFromFile = (filePath, fileName) => {
const content = fs.readFileSync(filePath, "utf-8"); const content = fs.readFileSync(filePath, "utf-8");
parser.parseFuncFromString( parser.parseFuncFromString(
@ -54,19 +65,20 @@ const getKeysFromFile = (filePath, fileName) => {
return keys; return keys;
}; };
async function walkTranslation(dirPath) { async function walkTranslation(dirs) {
for await (const p of walk(dirPath)) { for await (const p of walk(dirs)) {
const { name } = path.parse(p); const { name } = path.parse(p);
if (p.endsWith('.map')) { continue; }
getKeysFromFile(p, name); getKeysFromFile(p, name);
} }
const keys = parser.get({ sort: true }); const keys = parser.get({ sort: true });
const newTranslations = _.merge(keys.en.translation, englishKeys); const newTranslations = _.merge(keys.en.translation, englishKeys);
await fs.promises.writeFile( await fs.promises.writeFile(
"static/locales/en.client.json", "static/locales/en.client.json",
JSON.stringify(newTranslations, null, 2), JSON.stringify(sort(newTranslations), null, 2),
"utf-8" "utf-8"
); );
return keys; return keys;
} }
walkTranslation("app/client"); walkTranslation(["_build/app/client", ...process.argv.slice(2)]);