mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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:
		
							parent
							
								
									b59829d57e
								
							
						
					
					
						commit
						e6692c2793
					
				| @ -37,7 +37,7 @@ import { | ||||
|   UserAttributeRule | ||||
| } from 'app/common/GranularAccessClause'; | ||||
| 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 {MetaRowRecord} from 'app/common/TableData'; | ||||
| import { | ||||
| @ -98,7 +98,10 @@ export class AccessRules extends Disposable { | ||||
|   private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null); | ||||
| 
 | ||||
|   // 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.
 | ||||
|   private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>()); | ||||
| @ -126,7 +129,8 @@ export class AccessRules extends Disposable { | ||||
|     this._ruleStatus = Computed.create(this, (use) => { | ||||
|       const defRuleSet = use(this._docDefaultRuleSet); | ||||
|       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); | ||||
|       return Math.max( | ||||
|         defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged, | ||||
| @ -136,7 +140,8 @@ export class AccessRules extends Disposable { | ||||
|         getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size), | ||||
|         ...tableRules.map(tr => use(tr.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))) | ||||
|     ); | ||||
| 
 | ||||
|     SpecialRules.create(this._specialRules, SPECIAL_RULES_TABLE_ID, this, | ||||
|       rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID), | ||||
|       rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)); | ||||
|     const withDefaultRules = ['SeedRule']; | ||||
|     const separateRules = ['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 => | ||||
| @ -238,7 +250,8 @@ export class AccessRules extends Disposable { | ||||
|     // Add/remove resources to have just the ones we need.
 | ||||
|     const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten( | ||||
|       [{tableId: '*', colIds: '*'}], | ||||
|       this._specialRules.get()?.getResources() || [], | ||||
|       this._specialRulesWithDefault.get()?.getResources() || [], | ||||
|       this._specialRulesSeparate.get()?.getResources() || [], | ||||
|       ...this._tableRules.get().map(tr => tr.getResources())) | ||||
|       .map(r => ({id: -1, ...r})); | ||||
| 
 | ||||
| @ -395,6 +408,8 @@ export class AccessRules extends Disposable { | ||||
|         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')), | ||||
| @ -412,7 +427,7 @@ export class AccessRules extends Disposable { | ||||
|           ), | ||||
|           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[] { | ||||
|     return flatten( | ||||
|       ...this._tableRules.get().map(tr => tr.getRules()), | ||||
|       this._specialRules.get()?.getRules() || [], | ||||
|       this._specialRulesWithDefault.get()?.getRules() || [], | ||||
|       this._specialRulesSeparate.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(); | ||||
|   } | ||||
| 
 | ||||
|   // 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: []}; | ||||
|     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() { | ||||
| @ -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() { | ||||
|     this._accessRules.removeTableRules(this); | ||||
|   } | ||||
| @ -749,12 +794,15 @@ class TableRules extends Disposable { | ||||
|   } | ||||
| 
 | ||||
|   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() { | ||||
|     if (!this._defaultRuleSet.get()) { | ||||
|       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 { | ||||
|   public buildDom() { | ||||
|     return cssSection( | ||||
|       cssSectionHeading(t("Special Rules"), testId('rule-table-header')), | ||||
|       this.buildColumnRuleSets(), | ||||
|       this.buildErrors(), | ||||
|       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()) | ||||
| @ -796,18 +852,20 @@ abstract class ObsRuleSet extends Disposable { | ||||
|   // ruleSet is omitted for a new ObsRuleSet added by the user.
 | ||||
|   constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) { | ||||
|     super(); | ||||
|     if (this._ruleSet) { | ||||
|       this._body.set(this._ruleSet.body.map(part => ObsRulePart.create(this._body, this, part))); | ||||
|     } else { | ||||
|       // If creating a new RuleSet, start with just a default permission part.
 | ||||
|       this._body.set([ObsRulePart.create(this._body, this, undefined)]); | ||||
|     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.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))); | ||||
|     }); | ||||
|   } | ||||
| @ -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 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()); | ||||
|   } | ||||
| 
 | ||||
|   // 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". | ||||
| @ -939,6 +1046,11 @@ abstract class ObsRuleSet extends Disposable { | ||||
|   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 { | ||||
| @ -1006,58 +1118,91 @@ class DefaultObsRuleSet extends ObsRuleSet { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function getSpecialRuleDescription(type: string): string { | ||||
|   switch (type) { | ||||
|     case 'AccessRules': | ||||
|       return t("Allow everyone to view Access Rules."); | ||||
|     case 'FullCopies': | ||||
|       return 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.`);
 | ||||
|     default: return type; | ||||
|   } | ||||
| /** | ||||
|  * Properties we need to know about how a special rule should function and | ||||
|  * be rendered. | ||||
|  */ | ||||
| interface SpecialRuleProperties { | ||||
|   description: string; | ||||
|   name: string; | ||||
|   availableBits: PermissionKey[]; | ||||
|   permissions: string; | ||||
|   formula: string; | ||||
| } | ||||
| 
 | ||||
| function getSpecialRuleName(type: string): string { | ||||
|   switch (type) { | ||||
|     case 'AccessRules': return t("Permission to view Access Rules"); | ||||
|     case 'FullCopies': return t("Permission to access the document in full when needed"); | ||||
|     default: return type; | ||||
|   } | ||||
| 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]', | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| 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: 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, | ||||
|       (use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltIn())) | ||||
|       (use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use))) | ||||
|       .onWrite(val => this._allowEveryone(val)); | ||||
| 
 | ||||
|     const isExpanded = Observable.create<boolean>(null, isNonStandard.get()); | ||||
|     if (isNonStandard.get()) { | ||||
|       this._isExpanded.set(true); | ||||
|     } | ||||
| 
 | ||||
|     return dom('div', | ||||
|       dom.autoDispose(isExpanded), | ||||
|       dom.autoDispose(allowEveryone), | ||||
|       cssRuleDescription( | ||||
|         {style: 'white-space: pre-line;'},  // preserve line breaks in long descriptions
 | ||||
|         cssIconButton(icon('Expand'), | ||||
|           dom.style('transform', (use) => use(isExpanded) ? 'rotate(90deg)' : ''), | ||||
|           dom.on('click', () => isExpanded.set(!isExpanded.get())), | ||||
|           dom.style('transform', (use) => use(this._isExpanded) ? 'rotate(90deg)' : ''), | ||||
|           dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())), | ||||
|           testId('rule-special-expand'), | ||||
|         ), | ||||
|         squareCheckbox(allowEveryone, | ||||
|           dom.prop('disabled', isNonStandard), | ||||
|           testId('rule-special-checkbox'), | ||||
|         ), | ||||
|         getSpecialRuleDescription(this.getColIds()), | ||||
|         this.props.description, | ||||
|       ), | ||||
|       dom.maybe(isExpanded, () => | ||||
|       dom.maybe(this._isExpanded, () => | ||||
|         cssTableRounded( | ||||
|           {style: 'margin-left: 56px'}, | ||||
|           cssTableHeaderRow( | ||||
|             cssCellIcon(), | ||||
|             cssCell4(cssColHeaderCell(getSpecialRuleName(this.getColIds()))), | ||||
|             cssCell4(cssColHeaderCell(this.props.name)), | ||||
|             cssCell1(cssColHeaderCell('Permissions')), | ||||
|             cssCellIconWithMargins(), | ||||
|             cssCellIcon(), | ||||
| @ -1065,6 +1210,19 @@ class SpecialObsRuleSet extends ColumnObsRuleSet { | ||||
|           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'), | ||||
|         ) | ||||
| @ -1075,20 +1233,31 @@ class SpecialObsRuleSet extends ColumnObsRuleSet { | ||||
|   } | ||||
| 
 | ||||
|   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) { | ||||
|     const builtInRules = this._body.get().filter(r => r.isBuiltIn()); | ||||
|     if (value === true) { | ||||
|     if (value) { | ||||
|       const rulePart: RulePart = { | ||||
|         aclFormula: 'True', | ||||
|         permissionsText: '+R', | ||||
|         permissions: parsePermissions('+R'), | ||||
|         aclFormula: this.props.formula, | ||||
|         permissionsText: this.props.permissions, | ||||
|         permissions: parsePermissions(this.props.permissions), | ||||
|       }; | ||||
|       this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]); | ||||
|     } else if (value === false) { | ||||
|     } else { | ||||
|       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(); | ||||
|     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) { | ||||
|       // 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) || | ||||
| @ -1327,13 +1495,14 @@ class ObsRulePart extends Disposable { | ||||
|         ); | ||||
|     }); | ||||
| 
 | ||||
|     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._aclFormula) !== (this._rulePart?.aclFormula ?? '') || | ||||
|         use(this._memo) !== (this._rulePart?.memo ?? '') || | ||||
|         !isEqual(use(this._permissions), this._rulePart?.permissions) | ||||
|         !isEqual(use(this._permissions), this._rulePart?.permissions ?? emptyPerms) | ||||
|       ); | ||||
|     }); | ||||
|   } | ||||
| @ -1397,7 +1566,7 @@ class ObsRulePart extends Disposable { | ||||
|               return ( | ||||
|                 this._ruleSet.isSoleCondition(use, this) ? t('Everyone') : | ||||
|                 this._ruleSet.isLastCondition(use, this) ? t('Everyone Else') : | ||||
|                 t('EnterCondition') | ||||
|                 t('Enter Condition') | ||||
|               ); | ||||
|             }), | ||||
|             getSuggestions: (prefix) => this._completions.get(), | ||||
| @ -1475,6 +1644,17 @@ class ObsRulePart extends Disposable { | ||||
|     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; | ||||
|   } | ||||
| @ -1605,6 +1785,22 @@ function getAclFormulaProperties(part?: RulePart): FormulaProperties { | ||||
|   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%; | ||||
| @ -1798,3 +1994,7 @@ const cssMemoIcon = styled(icon, ` | ||||
|   margin-left: 8px; | ||||
|   margin-right: 8px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSeedRule = styled('div', ` | ||||
|   margin-bottom: 16px; | ||||
| `);
 | ||||
|  | ||||
| @ -15,6 +15,9 @@ import {makeT} from 'app/client/lib/localization'; | ||||
| // One of the strings 'read', 'update', etc.
 | ||||
| export type PermissionKey = keyof PartialPermissionSet; | ||||
| 
 | ||||
| // Canonical order of permission bits when rendered in a permissionsWidget.
 | ||||
| const PERMISSION_BIT_ORDER = 'RUCDS'; | ||||
| 
 | ||||
| const t = makeT('PermissionsWidget'); | ||||
| 
 | ||||
| /** | ||||
| @ -26,6 +29,7 @@ export function permissionsWidget( | ||||
|   options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void}, | ||||
|   ...args: DomElementArg[] | ||||
| ) { | ||||
|   availableBits = sortBits(availableBits); | ||||
|   // These are the permission sets available to set via the dropdown.
 | ||||
|   const empty: PartialPermissionSet = emptyPermissionSet(); | ||||
|   const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow'); | ||||
| @ -125,6 +129,20 @@ function psetDescription(permissionSet: PartialPermissionSet): string { | ||||
|   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', ` | ||||
|   display: flex; | ||||
|   gap: 4px; | ||||
|  | ||||
| @ -20,7 +20,7 @@ import {UserAction} from 'app/common/DocActions'; | ||||
| import {Computed, dom, fromKo, Observable} from 'grainjs'; | ||||
| 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).
 | ||||
| /* eslint-disable @typescript-eslint/member-ordering */ | ||||
|  | ||||
| @ -64,6 +64,11 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = { | ||||
|       permissions: parsePermissions('-R'), | ||||
|       permissionsText: '-R', | ||||
|     }], | ||||
|   }, | ||||
|   SeedRule: { | ||||
|     tableId: SPECIAL_RULES_TABLE_ID, | ||||
|     colIds: ['SeedRule'], | ||||
|     body: [], | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| @ -179,9 +184,13 @@ export class ACLRuleCollection { | ||||
|         const specialType = String(ruleSet.colIds); | ||||
|         const specialDefault = specialRuleSets.get(specialType); | ||||
|         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]}); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -134,7 +134,13 @@ export class MinIOExternalStorage implements ExternalStorage { | ||||
|   } | ||||
| 
 | ||||
|   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() { | ||||
|  | ||||
| @ -18,11 +18,13 @@ const parser = new Parser({ | ||||
|   nsSeparator: null, | ||||
| }); | ||||
| 
 | ||||
| async function* walk(dir) { | ||||
|   for await (const d of await fs.promises.opendir(dir)) { | ||||
|     const entry = path.join(dir, d.name); | ||||
|     if (d.isDirectory()) yield* walk(entry); | ||||
|     else if (d.isFile()) yield entry; | ||||
| async function* walk(dirs) { | ||||
|   for (const dir of dirs) { | ||||
|     for await (const d of await fs.promises.opendir(dir)) { | ||||
|       const entry = path.join(dir, d.name); | ||||
|       if (d.isDirectory()) yield* walk([entry]); | ||||
|       else if (d.isFile()) yield entry; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -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 content = fs.readFileSync(filePath, "utf-8"); | ||||
|   parser.parseFuncFromString( | ||||
| @ -54,19 +65,20 @@ const getKeysFromFile = (filePath, fileName) => { | ||||
|   return keys; | ||||
| }; | ||||
| 
 | ||||
| async function walkTranslation(dirPath) { | ||||
|   for await (const p of walk(dirPath)) { | ||||
| async function walkTranslation(dirs) { | ||||
|   for await (const p of walk(dirs)) { | ||||
|     const { name } = path.parse(p); | ||||
|     if (p.endsWith('.map')) { continue; } | ||||
|     getKeysFromFile(p, name); | ||||
|   } | ||||
|   const keys = parser.get({ sort: true }); | ||||
|   const newTranslations = _.merge(keys.en.translation, englishKeys); | ||||
|   await fs.promises.writeFile( | ||||
|     "static/locales/en.client.json", | ||||
|     JSON.stringify(newTranslations, null, 2), | ||||
|     JSON.stringify(sort(newTranslations), null, 2), | ||||
|     "utf-8" | ||||
|   ); | ||||
|   return keys; | ||||
| } | ||||
| 
 | ||||
| walkTranslation("app/client"); | ||||
| walkTranslation(["_build/app/client", ...process.argv.slice(2)]); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user