mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Support user variable in dropdown conditions
				
					
				
			Summary: Dropdown conditions can now reference a `user` variable, similar to the one available in Access Rules. Test Plan: Browser test. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Differential Revision: https://phab.getgrist.com/D4255
This commit is contained in:
		
							parent
							
								
									50077540e2
								
							
						
					
					
						commit
						72066bf0e4
					
				| @ -1,4 +1,5 @@ | ||||
| import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ViewFieldRec} from 'app/client/models/DocModel'; | ||||
| import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; | ||||
| @ -7,7 +8,9 @@ import {textButton } from 'app/client/ui2018/buttons'; | ||||
| import {testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; | ||||
| import {getPredicateFormulaProperties} from 'app/common/PredicateFormula'; | ||||
| import {UserInfo} from 'app/common/User'; | ||||
| import {Computed, Disposable, dom, Observable, styled} from 'grainjs'; | ||||
| import isPlainObject from 'lodash/isPlainObject'; | ||||
| 
 | ||||
| const t = makeT('DropdownConditionConfig'); | ||||
| 
 | ||||
| @ -99,7 +102,7 @@ export class DropdownConditionConfig extends Disposable { | ||||
| 
 | ||||
|   private _editorElement: HTMLElement; | ||||
| 
 | ||||
|   constructor(private _field: ViewFieldRec) { | ||||
|   constructor(private _field: ViewFieldRec, private _gristDoc: GristDoc) { | ||||
|     super(); | ||||
| 
 | ||||
|     this.autoDispose(this._text.addListener(() => { | ||||
| @ -167,6 +170,10 @@ export class DropdownConditionConfig extends Disposable { | ||||
| 
 | ||||
|   private _getAutocompleteSuggestions(): ISuggestionWithValue[] { | ||||
|     const variables = ['choice']; | ||||
|     const user = this._gristDoc.docPageModel.user.get(); | ||||
|     if (user) { | ||||
|       variables.push(...getUserCompletions(user)); | ||||
|     } | ||||
|     const refColumns = this._refColumns.get(); | ||||
|     if (refColumns) { | ||||
|       variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`)); | ||||
| @ -176,7 +183,6 @@ export class DropdownConditionConfig extends Disposable { | ||||
|       ...columns.map(({colId}) => `$${colId.peek()}`), | ||||
|       ...columns.map(({colId}) => `rec.${colId.peek()}`), | ||||
|     ); | ||||
| 
 | ||||
|     const suggestions = [ | ||||
|       'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None', | ||||
|       'OWNER', 'EDITOR', 'VIEWER', | ||||
| @ -186,6 +192,20 @@ export class DropdownConditionConfig extends Disposable { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function getUserCompletions(user: UserInfo) { | ||||
|   return Object.entries(user).flatMap(([key, value]) => { | ||||
|     if (key === 'LinkKey') { | ||||
|       return 'user.LinkKey.'; | ||||
|     } else if (isPlainObject(value)) { | ||||
|       return Object.keys(value as {[key: string]: any}) | ||||
|         .filter(valueKey => valueKey !== 'manualSort') | ||||
|         .map(valueKey => `user.${key}.${valueKey}`); | ||||
|     } else { | ||||
|       return `user.${key}`; | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| const cssSetDropdownConditionRow = styled(cssRow, ` | ||||
|   margin-top: 16px; | ||||
| `);
 | ||||
|  | ||||
| @ -63,7 +63,7 @@ export class TypeTransform extends ColumnTransform { | ||||
|         if (use(this._isFormWidget)) { | ||||
|           return transformWidget.buildFormTransformConfigDom(); | ||||
|         } else { | ||||
|           return transformWidget.buildTransformConfigDom(); | ||||
|           return transformWidget.buildTransformConfigDom(this.gristDoc); | ||||
|         } | ||||
|       }), | ||||
|       dom.maybe(this._reviseTypeChange, () => | ||||
|  | ||||
| @ -1,13 +1,13 @@ | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {ACIndex, ACResults} from 'app/client/lib/ACIndex'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ICellItem} from 'app/client/models/ColumnACIndexes'; | ||||
| import {ColumnCache} from 'app/client/models/ColumnCache'; | ||||
| import {DocData} from 'app/client/models/DocData'; | ||||
| import {ColumnRec} from 'app/client/models/entities/ColumnRec'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| import {TableData} from 'app/client/models/TableData'; | ||||
| import {getReferencedTableId, isRefListType} from 'app/common/gristTypes'; | ||||
| import {EmptyRecordView} from 'app/common/PredicateFormula'; | ||||
| import {EmptyRecordView} from 'app/common/RecordView'; | ||||
| import {BaseFormatter} from 'app/common/ValueFormatter'; | ||||
| import {Disposable, dom, Observable} from 'grainjs'; | ||||
| 
 | ||||
| @ -26,9 +26,10 @@ export class ReferenceUtils extends Disposable { | ||||
|   public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text); | ||||
| 
 | ||||
|   private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>; | ||||
|   private readonly _docData = this._gristDoc.docData; | ||||
|   private _dropdownConditionError = Observable.create<string | null>(this, null); | ||||
| 
 | ||||
|   constructor(public readonly field: ViewFieldRec, private readonly _docData: DocData) { | ||||
|   constructor(public readonly field: ViewFieldRec, private readonly _gristDoc: GristDoc) { | ||||
|     super(); | ||||
| 
 | ||||
|     const colType = field.column().type(); | ||||
| @ -38,7 +39,7 @@ export class ReferenceUtils extends Disposable { | ||||
|     } | ||||
|     this.refTableId = refTableId; | ||||
| 
 | ||||
|     const tableData = _docData.getTable(refTableId); | ||||
|     const tableData = this._docData.getTable(refTableId); | ||||
|     if (!tableData) { | ||||
|       throw new Error("Invalid referenced table " + refTableId); | ||||
|     } | ||||
| @ -131,12 +132,13 @@ export class ReferenceUtils extends Disposable { | ||||
|     if (!table) { throw new Error(`Table ${tableId} not found`); } | ||||
| 
 | ||||
|     const {result: predicate} = dropdownConditionCompiled; | ||||
|     const user = this._gristDoc.docPageModel.user.get() ?? undefined; | ||||
|     const rec = table.getRecord(rowId) || new EmptyRecordView(); | ||||
|     return (item: ICellItem) => { | ||||
|       const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId); | ||||
|       if (!choice) { throw new Error(`Reference ${item.rowId} not found`); } | ||||
| 
 | ||||
|       return predicate({rec, choice}); | ||||
|       return predicate({user, rec, choice}); | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -23,6 +23,7 @@ import {Features, mergedFeatures, Product} from 'app/common/Features'; | ||||
| import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; | ||||
| import {getReconnectTimeout} from 'app/common/gutil'; | ||||
| import {canEdit, isOwner} from 'app/common/roles'; | ||||
| import {UserInfo} from 'app/common/User'; | ||||
| import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI'; | ||||
| import {Holder, Observable, subscribe} from 'grainjs'; | ||||
| import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; | ||||
| @ -38,6 +39,7 @@ export interface DocInfo extends Document { | ||||
|   isPreFork: boolean; | ||||
|   isFork: boolean; | ||||
|   isRecoveryMode: boolean; | ||||
|   user: UserInfo|null; | ||||
|   userOverride: UserOverride|null; | ||||
|   isBareFork: boolean;  // a document created without logging in, which is treated as a
 | ||||
|                         // fork without an original.
 | ||||
| @ -78,6 +80,7 @@ export interface DocPageModel { | ||||
|   isPrefork: Observable<boolean>; | ||||
|   isFork: Observable<boolean>; | ||||
|   isRecoveryMode: Observable<boolean>; | ||||
|   user: Observable<UserInfo|null>; | ||||
|   userOverride: Observable<UserOverride|null>; | ||||
|   isBareFork: Observable<boolean>; | ||||
|   isSnapshot: Observable<boolean>; | ||||
| @ -134,6 +137,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { | ||||
|   public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false); | ||||
|   public readonly isRecoveryMode = Computed.create(this, this.currentDoc, | ||||
|                                                    (use, doc) => doc ? doc.isRecoveryMode : false); | ||||
|   public readonly user = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.user : null); | ||||
|   public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null); | ||||
|   public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false); | ||||
|   public readonly isSnapshot = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSnapshot : false); | ||||
| @ -265,8 +269,9 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { | ||||
|     // TODO It would be bad if a new doc gets opened while this getDoc() is pending...
 | ||||
|     const newDoc = await getDoc(this._api, urlId); | ||||
|     const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode); | ||||
|     const user = this.currentDoc.get()?.user || null; | ||||
|     const userOverride = this.currentDoc.get()?.userOverride || null; | ||||
|     this.currentDoc.set({...buildDocInfo(newDoc, openMode), isRecoveryMode, userOverride}); | ||||
|     this.currentDoc.set({...buildDocInfo(newDoc, openMode), isRecoveryMode, user, userOverride}); | ||||
|     return newDoc; | ||||
|   } | ||||
| 
 | ||||
| @ -407,11 +412,13 @@ It also disables formulas. [{{error}}]", {error: err.message}) | ||||
|       linkParameters, | ||||
|       originalUrlId: options.originalUrlId, | ||||
|     }); | ||||
|     if (openDocResponse.recoveryMode || openDocResponse.userOverride) { | ||||
|       doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode); | ||||
|       doc.userOverride = openDocResponse.userOverride || null; | ||||
|       this.currentDoc.set({...doc}); | ||||
|     const {user, recoveryMode, userOverride} = openDocResponse; | ||||
|     doc.user = user; | ||||
|     if (recoveryMode || userOverride) { | ||||
|       doc.isRecoveryMode = Boolean(recoveryMode); | ||||
|       doc.userOverride = userOverride || null; | ||||
|     } | ||||
|     this.currentDoc.set({...doc}); | ||||
|     if (openDocResponse.docUsage) { | ||||
|       this.updateCurrentDocUsage(openDocResponse.docUsage); | ||||
|     } | ||||
| @ -520,6 +527,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { | ||||
|     ...doc, | ||||
|     isFork, | ||||
|     isRecoveryMode: false,  // we don't know yet, will learn when doc is opened.
 | ||||
|     user: null,             // ditto.
 | ||||
|     userOverride: null,     // ditto.
 | ||||
|     isPreFork, | ||||
|     isBareFork, | ||||
|  | ||||
| @ -21,7 +21,7 @@ dispose.makeDisposable(AbstractWidget); | ||||
| /** | ||||
|  * Builds the DOM showing configuration buttons and fields in the sidebar. | ||||
|  */ | ||||
| AbstractWidget.prototype.buildConfigDom = function() { | ||||
| AbstractWidget.prototype.buildConfigDom = function(_gristDoc) { | ||||
|   throw new Error("Not Implemented"); | ||||
| }; | ||||
| 
 | ||||
| @ -29,7 +29,7 @@ AbstractWidget.prototype.buildConfigDom = function() { | ||||
|  * Builds the transform prompt config DOM in the few cases where it is necessary. | ||||
|  * Child classes need not override this function if they do not require transform config options. | ||||
|  */ | ||||
| AbstractWidget.prototype.buildTransformConfigDom = function() { | ||||
| AbstractWidget.prototype.buildTransformConfigDom = function(_gristDoc) { | ||||
|   return null; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -129,7 +129,7 @@ ChoiceEditor.prototype.buildDropdownConditionFilter = function() { | ||||
| 
 | ||||
|   return buildDropdownConditionFilter({ | ||||
|     dropdownConditionCompiled: dropdownConditionCompiled.result, | ||||
|     docData: this.options.gristDoc.docData, | ||||
|     gristDoc: this.options.gristDoc, | ||||
|     tableId: this.options.field.tableId(), | ||||
|     rowId: this.options.rowId, | ||||
|   }); | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| import {createGroup} from 'app/client/components/commands'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {ACIndexImpl, ACItem, ACResults, | ||||
|         buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex'; | ||||
| import {IAutocompleteOptions} from 'app/client/lib/autocomplete'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField'; | ||||
| import {DocData} from 'app/client/models/DocData'; | ||||
| import {colors, testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| import {menuCssClass} from 'app/client/ui2018/menus'; | ||||
| import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; | ||||
| @ -12,7 +12,8 @@ import {EditorPlacement} from 'app/client/widgets/EditorPlacement'; | ||||
| import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; | ||||
| import {csvEncodeRow} from 'app/common/csvFormat'; | ||||
| import {CellValue} from "app/common/DocActions"; | ||||
| import {CompiledPredicateFormula, EmptyRecordView} from 'app/common/PredicateFormula'; | ||||
| import {CompiledPredicateFormula} from 'app/common/PredicateFormula'; | ||||
| import {EmptyRecordView} from 'app/common/RecordView'; | ||||
| import {decodeObject, encodeObject} from 'app/plugin/objtypes'; | ||||
| import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox'; | ||||
| import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken'; | ||||
| @ -246,7 +247,7 @@ export class ChoiceListEditor extends NewBaseEditor { | ||||
| 
 | ||||
|     return buildDropdownConditionFilter({ | ||||
|       dropdownConditionCompiled: dropdownConditionCompiled.result, | ||||
|       docData: this.options.gristDoc.docData, | ||||
|       gristDoc: this.options.gristDoc, | ||||
|       tableId: this.options.field.tableId(), | ||||
|       rowId: this.options.rowId, | ||||
|     }); | ||||
| @ -311,7 +312,7 @@ export class ChoiceListEditor extends NewBaseEditor { | ||||
| 
 | ||||
| export interface GetACFilterFuncParams { | ||||
|   dropdownConditionCompiled: CompiledPredicateFormula; | ||||
|   docData: DocData; | ||||
|   gristDoc: GristDoc; | ||||
|   tableId: string; | ||||
|   rowId: number; | ||||
| } | ||||
| @ -319,12 +320,13 @@ export interface GetACFilterFuncParams { | ||||
| export function buildDropdownConditionFilter( | ||||
|   params: GetACFilterFuncParams | ||||
| ): (item: ChoiceItem) => boolean { | ||||
|   const {dropdownConditionCompiled, docData, tableId, rowId} = params; | ||||
|   const table = docData.getTable(tableId); | ||||
|   const {dropdownConditionCompiled, gristDoc, tableId, rowId} = params; | ||||
|   const table = gristDoc.docData.getTable(tableId); | ||||
|   if (!table) { throw new Error(`Table ${tableId} not found`); } | ||||
| 
 | ||||
|   const user = gristDoc.docPageModel.user.get() ?? undefined; | ||||
|   const rec = table.getRecord(rowId) || new EmptyRecordView(); | ||||
|   return (item: ChoiceItem) => dropdownConditionCompiled({rec, choice: item.label}); | ||||
|   return (item: ChoiceItem) => dropdownConditionCompiled({user, rec, choice: item.label}); | ||||
| } | ||||
| 
 | ||||
| const cssCellEditor = styled('div', ` | ||||
|  | ||||
| @ -4,6 +4,7 @@ import { | ||||
|   FormSelectConfig, | ||||
| } from 'app/client/components/Forms/FormConfig'; | ||||
| import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {DataRowModel} from 'app/client/models/DataRowModel'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| @ -79,17 +80,17 @@ export class ChoiceTextBox extends NTextBox { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public buildConfigDom() { | ||||
|   public buildConfigDom(gristDoc: GristDoc) { | ||||
|     return [ | ||||
|       super.buildConfigDom(), | ||||
|       super.buildConfigDom(gristDoc), | ||||
|       this.buildChoicesConfigDom(), | ||||
|       dom.create(DropdownConditionConfig, this.field), | ||||
|       dom.create(DropdownConditionConfig, this.field, gristDoc), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   public buildTransformConfigDom() { | ||||
|   public buildTransformConfigDom(gristDoc: GristDoc) { | ||||
|     return [ | ||||
|       super.buildConfigDom(), | ||||
|       super.buildConfigDom(gristDoc), | ||||
|       this.buildChoicesConfigDom(), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
| @ -54,7 +54,7 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype); | ||||
|  * Builds the config dom for the DateTime TextBox. If isTransformConfig is true, | ||||
|  * builds only the necessary dom for the transform config menu. | ||||
|  */ | ||||
| DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) { | ||||
| DateTimeTextBox.prototype.buildConfigDom = function(_gristDoc, isTransformConfig) { | ||||
|   const disabled = ko.pureComputed(() => { | ||||
|     return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData(); | ||||
|   }); | ||||
| @ -92,8 +92,8 @@ DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) { | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| DateTimeTextBox.prototype.buildTransformConfigDom = function() { | ||||
|   return this.buildConfigDom(true); | ||||
| DateTimeTextBox.prototype.buildTransformConfigDom = function(gristDoc) { | ||||
|   return this.buildConfigDom(gristDoc, true); | ||||
| }; | ||||
| 
 | ||||
| // clean up old koform styles
 | ||||
|  | ||||
| @ -499,7 +499,7 @@ export class FieldBuilder extends Disposable { | ||||
|     // the dom created by the widgetImpl to get out of sync.
 | ||||
|     return dom('div', | ||||
|       kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) => | ||||
|         dom('div', widget.buildConfigDom()) | ||||
|         dom('div', widget.buildConfigDom(this.gristDoc)) | ||||
|       ) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; | ||||
| import { GristDoc } from 'app/client/components/GristDoc'; | ||||
| import { fromKoSave } from 'app/client/lib/fromKoSave'; | ||||
| import { makeT } from 'app/client/lib/localization'; | ||||
| import { DataRowModel } from 'app/client/models/DataRowModel'; | ||||
| @ -32,7 +33,7 @@ export class NTextBox extends NewAbstractWidget { | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
|   public buildConfigDom(): DomContents { | ||||
|   public buildConfigDom(_gristDoc: GristDoc): DomContents { | ||||
|     const toggle = () => { | ||||
|       const newValue = !this.field.config.wrap.peek(); | ||||
|       this.field.config.wrap.setAndSave(newValue).catch(reportError); | ||||
|  | ||||
| @ -60,7 +60,7 @@ export abstract class NewAbstractWidget extends Disposable { | ||||
|   /** | ||||
|    * Builds the DOM showing configuration buttons and fields in the sidebar. | ||||
|    */ | ||||
|   public buildConfigDom(): DomContents { | ||||
|   public buildConfigDom(_gristDoc: GristDoc): DomContents { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
| @ -68,7 +68,7 @@ export abstract class NewAbstractWidget extends Disposable { | ||||
|    * Builds the transform prompt config DOM in the few cases where it is necessary. | ||||
|    * Child classes need not override this function if they do not require transform config options. | ||||
|    */ | ||||
|   public buildTransformConfigDom(): DomContents { | ||||
|   public buildTransformConfigDom(_gristDoc: GristDoc): DomContents { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  * See app/common/NumberFormat for description of options we support. | ||||
|  */ | ||||
| import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {fromKoSave} from 'app/client/lib/fromKoSave'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| @ -39,7 +40,7 @@ export class NumericTextBox extends NTextBox { | ||||
|     super(field); | ||||
|   } | ||||
| 
 | ||||
|   public buildConfigDom(): DomContents { | ||||
|   public buildConfigDom(gristDoc: GristDoc): DomContents { | ||||
|     // Holder for all computeds created here. It gets disposed with the returned DOM element.
 | ||||
|     const holder = new MultiHolder(); | ||||
| 
 | ||||
| @ -89,7 +90,7 @@ export class NumericTextBox extends NTextBox { | ||||
|     const disabledStyle = cssButtonSelect.cls('-disabled', disabled); | ||||
| 
 | ||||
|     return [ | ||||
|       super.buildConfigDom(), | ||||
|       super.buildConfigDom(gristDoc), | ||||
|       cssLabel(t('Number Format')), | ||||
|       cssRow( | ||||
|         dom.autoDispose(holder), | ||||
|  | ||||
| @ -4,6 +4,7 @@ import { | ||||
|   FormSelectConfig | ||||
| } from 'app/client/components/Forms/FormConfig'; | ||||
| import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {DataRowModel} from 'app/client/models/DataRowModel'; | ||||
| import {TableRec} from 'app/client/models/DocModel'; | ||||
| @ -53,12 +54,12 @@ export class Reference extends NTextBox { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public buildConfigDom() { | ||||
|   public buildConfigDom(gristDoc: GristDoc) { | ||||
|     return [ | ||||
|       this.buildTransformConfigDom(), | ||||
|       dom.create(DropdownConditionConfig, this.field), | ||||
|       dom.create(DropdownConditionConfig, this.field, gristDoc), | ||||
|       cssLabel(t('CELL FORMAT')), | ||||
|       super.buildConfigDom(), | ||||
|       super.buildConfigDom(gristDoc), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -23,8 +23,8 @@ export class ReferenceEditor extends NTextEditor { | ||||
|   constructor(options: FieldOptions) { | ||||
|     super(options); | ||||
| 
 | ||||
|     const docData = options.gristDoc.docData; | ||||
|     this._utils = new ReferenceUtils(options.field, docData); | ||||
|     const gristDoc = options.gristDoc; | ||||
|     this._utils = new ReferenceUtils(options.field, gristDoc); | ||||
| 
 | ||||
|     const vcol = this._utils.visibleColModel; | ||||
|     this._enableAddNew = ( | ||||
| @ -47,7 +47,7 @@ export class ReferenceEditor extends NTextEditor { | ||||
| 
 | ||||
|     // The referenced table has probably already been fetched (because there must already be a
 | ||||
|     // Reference widget instantiated), but it's better to avoid this assumption.
 | ||||
|     docData.fetchTable(this._utils.refTableId).then(() => { | ||||
|     gristDoc.docData.fetchTable(this._utils.refTableId).then(() => { | ||||
|       if (this.isDisposed()) { return; } | ||||
|       if (needReload && this.textInput.value === '') { | ||||
|         this.textInput.value = undef(options.state, options.editValue, this._idToText()); | ||||
|  | ||||
| @ -55,8 +55,8 @@ export class ReferenceListEditor extends NewBaseEditor { | ||||
|   constructor(protected options: FieldOptions) { | ||||
|     super(options); | ||||
| 
 | ||||
|     const docData = options.gristDoc.docData; | ||||
|     this._utils = new ReferenceUtils(options.field, docData); | ||||
|     const gristDoc = options.gristDoc; | ||||
|     this._utils = new ReferenceUtils(options.field, gristDoc); | ||||
| 
 | ||||
|     const vcol = this._utils.visibleColModel; | ||||
|     this._enableAddNew = ( | ||||
| @ -130,7 +130,7 @@ export class ReferenceListEditor extends NewBaseEditor { | ||||
| 
 | ||||
|     // The referenced table has probably already been fetched (because there must already be a
 | ||||
|     // Reference widget instantiated), but it's better to avoid this assumption.
 | ||||
|     docData.fetchTable(this._utils.refTableId).then(() => { | ||||
|     gristDoc.docData.fetchTable(this._utils.refTableId).then(() => { | ||||
|       if (this.isDisposed()) { return; } | ||||
|       if (needReload) { | ||||
|         this._tokenField.setTokens( | ||||
|  | ||||
| @ -3,6 +3,7 @@ import {TableDataAction} from 'app/common/DocActions'; | ||||
| import {FilteredDocUsageSummary} from 'app/common/DocUsage'; | ||||
| import {Role} from 'app/common/roles'; | ||||
| import {StringUnion} from 'app/common/StringUnion'; | ||||
| import {UserInfo} from 'app/common/User'; | ||||
| import {FullUser} from 'app/common/UserAPI'; | ||||
| 
 | ||||
| // Possible flavors of items in a list of documents.
 | ||||
| @ -75,6 +76,7 @@ export interface OpenLocalDocResult { | ||||
|   doc: {[tableId: string]: TableDataAction}; | ||||
|   log: MinimalActionGroup[]; | ||||
|   isTimingOn: boolean; | ||||
|   user: UserInfo; | ||||
|   recoveryMode?: boolean; | ||||
|   userOverride?: UserOverride; | ||||
|   docUsage?: FilteredDocUsageSummary; | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| import {PartialPermissionSet} from 'app/common/ACLPermissions'; | ||||
| import {CellValue, RowRecord} from 'app/common/DocActions'; | ||||
| import {CompiledPredicateFormula} from 'app/common/PredicateFormula'; | ||||
| import {Role} from 'app/common/roles'; | ||||
| import {MetaRowRecord} from 'app/common/TableData'; | ||||
| 
 | ||||
| export interface RuleSet { | ||||
| @ -25,12 +24,6 @@ export interface RulePart { | ||||
|   memo?: string; | ||||
| } | ||||
| 
 | ||||
| // Light wrapper for reading records or user attributes.
 | ||||
| export interface InfoView { | ||||
|   get(key: string): CellValue; | ||||
|   toJSON(): {[key: string]: any}; | ||||
| } | ||||
| 
 | ||||
| // As InfoView, but also supporting writing.
 | ||||
| export interface InfoEditor { | ||||
|   get(key: string): CellValue; | ||||
| @ -38,22 +31,6 @@ export interface InfoEditor { | ||||
|   toJSON(): {[key: string]: any}; | ||||
| } | ||||
| 
 | ||||
| // Represents user info, which may include properties which are themselves RowRecords.
 | ||||
| export interface UserInfo { | ||||
|   Name: string | null; | ||||
|   Email: string | null; | ||||
|   Access: Role | null; | ||||
|   Origin: string | null; | ||||
|   LinkKey: Record<string, string | undefined>; | ||||
|   UserID: number | null; | ||||
|   UserRef: string | null; | ||||
|   SessionID: string | null; | ||||
|   ShareRef: number | null;   // This is a rowId in the _grist_Shares table, if the user
 | ||||
|                              // is accessing a document via a share. Otherwise null.
 | ||||
|   [attributes: string]: unknown; | ||||
|   toJSON(): {[key: string]: any}; | ||||
| } | ||||
| 
 | ||||
| export interface UserAttributeRule { | ||||
|   origRecord?: RowRecord;         // Original record used to create this UserAttributeRule.
 | ||||
|   name: string;       // Should be unique among UserAttributeRules.
 | ||||
|  | ||||
| @ -10,7 +10,8 @@ | ||||
|  */ | ||||
| import {CellValue, RowRecord} from 'app/common/DocActions'; | ||||
| import {ErrorWithCode} from 'app/common/ErrorWithCode'; | ||||
| import {InfoView, UserInfo} from 'app/common/GranularAccessClause'; | ||||
| import {InfoView} from 'app/common/RecordView'; | ||||
| import {UserInfo} from 'app/common/User'; | ||||
| import {decodeObject} from 'app/plugin/objtypes'; | ||||
| import constant = require('lodash/constant'); | ||||
| 
 | ||||
| @ -31,11 +32,6 @@ export interface PredicateFormulaInput { | ||||
|   choice?: string|RowRecord|InfoView; | ||||
| } | ||||
| 
 | ||||
| export class EmptyRecordView implements InfoView { | ||||
|   public get(_colId: string): CellValue { return null; } | ||||
|   public toJSON() { return {}; } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * The result of compiling ParsedPredicateFormula. | ||||
|  */ | ||||
| @ -102,7 +98,7 @@ export function compilePredicateFormula( | ||||
|             break; | ||||
|           } | ||||
|           case 'dropdown-condition': { | ||||
|             validNames = ['rec', 'choice']; | ||||
|             validNames = ['rec', 'choice', 'user']; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|  | ||||
							
								
								
									
										43
									
								
								app/common/RecordView.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/common/RecordView.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| import {CellValue, TableDataAction} from 'app/common/DocActions'; | ||||
| 
 | ||||
| /** Light wrapper for reading records or user attributes. */ | ||||
| export interface InfoView { | ||||
|   get(key: string): CellValue; | ||||
|   toJSON(): {[key: string]: any}; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A row-like view of TableDataAction, which is columnar in nature. | ||||
|  * | ||||
|  * If index value is undefined, acts as an EmptyRecordRow. | ||||
|  */ | ||||
| export class RecordView implements InfoView { | ||||
|   public constructor(public data: TableDataAction, public index: number|undefined) { | ||||
|   } | ||||
| 
 | ||||
|   public get(colId: string): CellValue { | ||||
|     if (this.index === undefined) { return null; } | ||||
|     if (colId === 'id') { | ||||
|       return this.data[2][this.index]; | ||||
|     } | ||||
|     return this.data[3][colId]?.[this.index]; | ||||
|   } | ||||
| 
 | ||||
|   public has(colId: string) { | ||||
|     return colId === 'id' || colId in this.data[3]; | ||||
|   } | ||||
| 
 | ||||
|   public toJSON() { | ||||
|     if (this.index === undefined) { return {}; } | ||||
|     const results: {[key: string]: any} = {id: this.index}; | ||||
|     for (const key of Object.keys(this.data[3])) { | ||||
|       results[key] = this.data[3][key]?.[this.index]; | ||||
|     } | ||||
|     return results; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class EmptyRecordView implements InfoView { | ||||
|   public get(_colId: string): CellValue { return null; } | ||||
|   public toJSON() { return {}; } | ||||
| } | ||||
							
								
								
									
										89
									
								
								app/common/User.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								app/common/User.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,89 @@ | ||||
| import {getTableId} from 'app/common/DocActions'; | ||||
| import {EmptyRecordView, RecordView} from 'app/common/RecordView'; | ||||
| import {Role} from 'app/common/roles'; | ||||
| 
 | ||||
| /** | ||||
|  * Information about a user, including any user attributes. | ||||
|  */ | ||||
| export interface UserInfo { | ||||
|   Name: string | null; | ||||
|   Email: string | null; | ||||
|   Access: Role | null; | ||||
|   Origin: string | null; | ||||
|   LinkKey: Record<string, string | undefined>; | ||||
|   UserID: number | null; | ||||
|   UserRef: string | null; | ||||
|   SessionID: string | null; | ||||
|   /** | ||||
|    * This is a rowId in the _grist_Shares table, if the user is accessing a document | ||||
|    * via a share. Otherwise null. | ||||
|    */ | ||||
|   ShareRef: number | null; | ||||
|   [attributes: string]: unknown; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Wrapper class for `UserInfo`. | ||||
|  * | ||||
|  * Contains methods for converting itself to different representations. | ||||
|  */ | ||||
| export class User implements UserInfo { | ||||
|   public Name: string | null = null; | ||||
|   public UserID: number | null = null; | ||||
|   public Access: Role | null = null; | ||||
|   public Origin: string | null = null; | ||||
|   public LinkKey: Record<string, string | undefined> = {}; | ||||
|   public Email: string | null = null; | ||||
|   public SessionID: string | null = null; | ||||
|   public UserRef: string | null = null; | ||||
|   public ShareRef: number | null = null; | ||||
|   [attribute: string]: any; | ||||
| 
 | ||||
|   constructor(info: Record<string, unknown> = {}) { | ||||
|     Object.assign(this, info); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Returns a JSON representation of this class that excludes full row data, | ||||
|    * only keeping user info and table/row ids for any user attributes. | ||||
|    * | ||||
|    * Used by the sandbox to support `user` variables in formulas (see `user.py`). | ||||
|    */ | ||||
|   public toJSON() { | ||||
|     return this._toObject((value) => { | ||||
|       if (value instanceof RecordView) { | ||||
|         return [getTableId(value.data), value.get('id')]; | ||||
|       } else if (value instanceof EmptyRecordView) { | ||||
|         return null; | ||||
|       } else { | ||||
|         return value; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Returns a record representation of this class, with all user attributes | ||||
|    * converted from `RecordView` instances to their JSON representations. | ||||
|    * | ||||
|    * Used by the client to support `user` variables in dropdown conditions. | ||||
|    */ | ||||
|   public toUserInfo(): UserInfo { | ||||
|     return this._toObject((value) => { | ||||
|       if (value instanceof RecordView) { | ||||
|         return value.toJSON(); | ||||
|       } else if (value instanceof EmptyRecordView) { | ||||
|         return null; | ||||
|       } else { | ||||
|         return value; | ||||
|       } | ||||
|     }) as UserInfo; | ||||
|   } | ||||
| 
 | ||||
|   private _toObject(mapValue: (value: unknown) => unknown) { | ||||
|     const results: {[key: string]: any} = {}; | ||||
|     for (const [key, value] of Object.entries(this)) { | ||||
|       results[key] = mapValue(value); | ||||
|     } | ||||
|     return results; | ||||
|   } | ||||
| } | ||||
| @ -443,6 +443,10 @@ export class ActiveDoc extends EventEmitter { | ||||
|     return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary()); | ||||
|   } | ||||
| 
 | ||||
|   public getUser(docSession: OptDocSession) { | ||||
|     return this._granularAccess.getUser(docSession); | ||||
|   } | ||||
| 
 | ||||
|   public async getUserOverride(docSession: OptDocSession) { | ||||
|     return this._granularAccess.getUserOverride(docSession); | ||||
|   } | ||||
|  | ||||
| @ -395,9 +395,10 @@ export class DocManager extends EventEmitter { | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       const [metaTables, recentActions, userOverride] = await Promise.all([ | ||||
|       const [metaTables, recentActions, user, userOverride] = await Promise.all([ | ||||
|         activeDoc.fetchMetaTables(docSession), | ||||
|         activeDoc.getRecentMinimalActions(docSession), | ||||
|         activeDoc.getUser(docSession), | ||||
|         activeDoc.getUserOverride(docSession), | ||||
|       ]); | ||||
| 
 | ||||
| @ -414,6 +415,7 @@ export class DocManager extends EventEmitter { | ||||
|         doc: metaTables, | ||||
|         log: recentActions, | ||||
|         recoveryMode: activeDoc.recoveryMode, | ||||
|         user: user.toUserInfo(), | ||||
|         userOverride, | ||||
|         docUsage, | ||||
|         isTimingOn: activeDoc.isTimingOn, | ||||
|  | ||||
| @ -26,12 +26,14 @@ import { UserOverride } from 'app/common/DocListAPI'; | ||||
| import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage'; | ||||
| import { normalizeEmail } from 'app/common/emails'; | ||||
| import { ErrorWithCode } from 'app/common/ErrorWithCode'; | ||||
| import { InfoEditor, InfoView, UserInfo } from 'app/common/GranularAccessClause'; | ||||
| import { InfoEditor } from 'app/common/GranularAccessClause'; | ||||
| import * as gristTypes from 'app/common/gristTypes'; | ||||
| import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil'; | ||||
| import { compilePredicateFormula, EmptyRecordView, PredicateFormulaInput } from 'app/common/PredicateFormula'; | ||||
| import { compilePredicateFormula, PredicateFormulaInput } from 'app/common/PredicateFormula'; | ||||
| import { MetaRowRecord, SingleCell } from 'app/common/TableData'; | ||||
| import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView'; | ||||
| import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; | ||||
| import { User } from 'app/common/User'; | ||||
| import { FullUser, UserAccessData } from 'app/common/UserAPI'; | ||||
| import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; | ||||
| import { GristObjCode } from 'app/plugin/GristData'; | ||||
| @ -330,11 +332,106 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|     this._userAttributesMap = new WeakMap(); | ||||
|   } | ||||
| 
 | ||||
|   public getUser(docSession: OptDocSession): Promise<UserInfo> { | ||||
|     return this._getUser(docSession); | ||||
|   /** | ||||
|    * Construct the UserInfo needed for evaluating rules. This also enriches the user with values | ||||
|    * created by user-attribute rules. | ||||
|    */ | ||||
|   public async getUser(docSession: OptDocSession): Promise<User> { | ||||
|     const linkParameters = docSession.authorizer?.getLinkParameters() || {}; | ||||
|     let access: Role | null; | ||||
|     let fullUser: FullUser | null; | ||||
|     const attrs = this._getUserAttributes(docSession); | ||||
|     access = getDocSessionAccess(docSession); | ||||
| 
 | ||||
|     const linkId = getDocSessionShare(docSession); | ||||
|     let shareRef: number = 0; | ||||
|     if (linkId) { | ||||
|       const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({ | ||||
|         linkId, | ||||
|       }); | ||||
|       if (rowIds.length > 1) { | ||||
|         throw new Error('Share identifier is not unique'); | ||||
|       } | ||||
|       if (rowIds.length === 1) { | ||||
|         shareRef = rowIds[0]; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (docSession.forkingAsOwner) { | ||||
|       // For granular access purposes, we become an owner.
 | ||||
|       // It is a bit of a bluff, done on the understanding that this session will
 | ||||
|       // never be used to edit the document, and that any edits will be done on a
 | ||||
|       // fork.
 | ||||
|       access = 'owners'; | ||||
|     } | ||||
| 
 | ||||
|     // If aclAsUserId/aclAsUser is set, then override user for acl purposes.
 | ||||
|     if (linkParameters.aclAsUserId || linkParameters.aclAsUser) { | ||||
|       if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); } | ||||
|       if (attrs.override) { | ||||
|         // Used cached properties.
 | ||||
|         access = attrs.override.access; | ||||
|         fullUser = attrs.override.user; | ||||
|       } else { | ||||
|         attrs.override = await this._getViewAsUser(linkParameters); | ||||
|         fullUser = attrs.override.user; | ||||
|       } | ||||
|     } else { | ||||
|       fullUser = getDocSessionUser(docSession); | ||||
|     } | ||||
|     const user = new User(); | ||||
|     user.Access = access; | ||||
|     user.ShareRef = shareRef || null; | ||||
|     const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || | ||||
|       fullUser?.id === null; | ||||
|     user.UserID = (!isAnonymous && fullUser?.id) || null; | ||||
|     user.Email = fullUser?.email || null; | ||||
|     user.Name = fullUser?.name || null; | ||||
|     // If viewed from a websocket, collect any link parameters included.
 | ||||
|     // TODO: could also get this from rest api access, just via a different route.
 | ||||
|     user.LinkKey = linkParameters; | ||||
|     // Include origin info if accessed via the rest api.
 | ||||
|     // TODO: could also get this for websocket access, just via a different route.
 | ||||
|     user.Origin = docSession.req?.get('origin') || null; | ||||
|     user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`; | ||||
|     user.IsLoggedIn = !isAnonymous; | ||||
|     user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.
 | ||||
| 
 | ||||
|     if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) { | ||||
|       // It is important to signal that the doc is in an unexpected state,
 | ||||
|       // and prevent it opening.
 | ||||
|       throw this._ruler.ruleCollection.ruleError; | ||||
|     } | ||||
| 
 | ||||
|     for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) { | ||||
|       if (clause.name in user) { | ||||
|         log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); | ||||
|         continue; | ||||
|       } | ||||
|       if (attrs.rows[clause.name]) { | ||||
|         user[clause.name] = attrs.rows[clause.name]; | ||||
|         continue; | ||||
|       } | ||||
|       let rec = new EmptyRecordView(); | ||||
|       let rows: TableDataAction|undefined; | ||||
|       try { | ||||
|         // Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
 | ||||
|         // TODO: add indexes to db.
 | ||||
|         rows = await this._fetchQueryFromDB({ | ||||
|           tableId: clause.tableId, | ||||
|           filters: { [clause.lookupColId]: [get(user, clause.charId)] } | ||||
|         }); | ||||
|       } catch (e) { | ||||
|         log.warn(`User attribute ${clause.name} failed`, e); | ||||
|       } | ||||
|       if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); } | ||||
|       user[clause.name] = rec; | ||||
|       attrs.rows[clause.name] = rec; | ||||
|     } | ||||
|     return user; | ||||
|   } | ||||
| 
 | ||||
|   public async getCachedUser(docSession: OptDocSession): Promise<UserInfo> { | ||||
|   public async getCachedUser(docSession: OptDocSession): Promise<User> { | ||||
|     const access = await this._getAccess(docSession); | ||||
|     return access.getUser(); | ||||
|   } | ||||
| @ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|    */ | ||||
|   public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> { | ||||
|     return { | ||||
|       user: await this._getUser(docSession), | ||||
|       user: await this.getUser(docSession), | ||||
|       docId: this._docId | ||||
|     }; | ||||
|   } | ||||
| @ -479,7 +576,7 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|   public async canApplyBundle() { | ||||
|     if (!this._activeBundle) { throw new Error('no active bundle'); } | ||||
|     const {docActions, docSession, isDirect} = this._activeBundle; | ||||
|     const currentUser = await this._getUser(docSession); | ||||
|     const currentUser = await this.getUser(docSession); | ||||
|     const userIsOwner = await this.isOwner(docSession); | ||||
|     if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) { | ||||
|       throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules'); | ||||
| @ -1004,7 +1101,7 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|   } | ||||
| 
 | ||||
|   public async getUserOverride(docSession: OptDocSession): Promise<UserOverride|undefined> { | ||||
|     await this._getUser(docSession); | ||||
|     await this.getUser(docSession); | ||||
|     return this._getUserAttributes(docSession).override; | ||||
|   } | ||||
| 
 | ||||
| @ -1120,7 +1217,7 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|     const linkParameters = docSession.authorizer?.getLinkParameters() || {}; | ||||
|     const baseAccess = getDocSessionAccess(docSession); | ||||
|     if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') { | ||||
|       const info = await this._getUser(docSession); | ||||
|       const info = await this.getUser(docSession); | ||||
|       return info.Access; | ||||
|     } | ||||
|     return baseAccess; | ||||
| @ -1838,105 +1935,6 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Construct the UserInfo needed for evaluating rules. This also enriches the user with values | ||||
|    * created by user-attribute rules. | ||||
|    */ | ||||
|   private async _getUser(docSession: OptDocSession): Promise<UserInfo> { | ||||
|     const linkParameters = docSession.authorizer?.getLinkParameters() || {}; | ||||
|     let access: Role | null; | ||||
|     let fullUser: FullUser | null; | ||||
|     const attrs = this._getUserAttributes(docSession); | ||||
|     access = getDocSessionAccess(docSession); | ||||
| 
 | ||||
|     const linkId = getDocSessionShare(docSession); | ||||
|     let shareRef: number = 0; | ||||
|     if (linkId) { | ||||
|       const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({ | ||||
|         linkId, | ||||
|       }); | ||||
|       if (rowIds.length > 1) { | ||||
|         throw new Error('Share identifier is not unique'); | ||||
|       } | ||||
|       if (rowIds.length === 1) { | ||||
|         shareRef = rowIds[0]; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (docSession.forkingAsOwner) { | ||||
|       // For granular access purposes, we become an owner.
 | ||||
|       // It is a bit of a bluff, done on the understanding that this session will
 | ||||
|       // never be used to edit the document, and that any edits will be done on a
 | ||||
|       // fork.
 | ||||
|       access = 'owners'; | ||||
|     } | ||||
| 
 | ||||
|     // If aclAsUserId/aclAsUser is set, then override user for acl purposes.
 | ||||
|     if (linkParameters.aclAsUserId || linkParameters.aclAsUser) { | ||||
|       if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); } | ||||
|       if (attrs.override) { | ||||
|         // Used cached properties.
 | ||||
|         access = attrs.override.access; | ||||
|         fullUser = attrs.override.user; | ||||
|       } else { | ||||
|         attrs.override = await this._getViewAsUser(linkParameters); | ||||
|         fullUser = attrs.override.user; | ||||
|       } | ||||
|     } else { | ||||
|       fullUser = getDocSessionUser(docSession); | ||||
|     } | ||||
|     const user = new User(); | ||||
|     user.Access = access; | ||||
|     user.ShareRef = shareRef || null; | ||||
|     const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || | ||||
|       fullUser?.id === null; | ||||
|     user.UserID = (!isAnonymous && fullUser?.id) || null; | ||||
|     user.Email = fullUser?.email || null; | ||||
|     user.Name = fullUser?.name || null; | ||||
|     // If viewed from a websocket, collect any link parameters included.
 | ||||
|     // TODO: could also get this from rest api access, just via a different route.
 | ||||
|     user.LinkKey = linkParameters; | ||||
|     // Include origin info if accessed via the rest api.
 | ||||
|     // TODO: could also get this for websocket access, just via a different route.
 | ||||
|     user.Origin = docSession.req?.get('origin') || null; | ||||
|     user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`; | ||||
|     user.IsLoggedIn = !isAnonymous; | ||||
|     user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.
 | ||||
| 
 | ||||
|     if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) { | ||||
|       // It is important to signal that the doc is in an unexpected state,
 | ||||
|       // and prevent it opening.
 | ||||
|       throw this._ruler.ruleCollection.ruleError; | ||||
|     } | ||||
| 
 | ||||
|     for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) { | ||||
|       if (clause.name in user) { | ||||
|         log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); | ||||
|         continue; | ||||
|       } | ||||
|       if (attrs.rows[clause.name]) { | ||||
|         user[clause.name] = attrs.rows[clause.name]; | ||||
|         continue; | ||||
|       } | ||||
|       let rec = new EmptyRecordView(); | ||||
|       let rows: TableDataAction|undefined; | ||||
|       try { | ||||
|         // Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
 | ||||
|         // TODO: add indexes to db.
 | ||||
|         rows = await this._fetchQueryFromDB({ | ||||
|           tableId: clause.tableId, | ||||
|           filters: { [clause.lookupColId]: [get(user, clause.charId)] } | ||||
|         }); | ||||
|       } catch (e) { | ||||
|         log.warn(`User attribute ${clause.name} failed`, e); | ||||
|       } | ||||
|       if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); } | ||||
|       user[clause.name] = rec; | ||||
|       attrs.rows[clause.name] = rec; | ||||
|     } | ||||
|     return user; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get the "View As" user specified in link parameters. | ||||
|    * If aclAsUserId is set, we get the user with the specified id. | ||||
| @ -2583,7 +2581,7 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|   /** | ||||
|    * Tests if the user can modify cell's data. | ||||
|    */ | ||||
|   private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) { | ||||
|   private async _canApplyCellActions(currentUser: User, userIsOwner: boolean) { | ||||
|     // Owner can modify all comments, without exceptions.
 | ||||
|     if (userIsOwner) { | ||||
|       return; | ||||
| @ -2654,7 +2652,7 @@ export class Ruler { | ||||
| } | ||||
| 
 | ||||
| export interface RulerOwner { | ||||
|   getUser(docSession: OptDocSession): Promise<UserInfo>; | ||||
|   getUser(docSession: OptDocSession): Promise<User>; | ||||
|   inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>; | ||||
| } | ||||
| 
 | ||||
| @ -2688,36 +2686,6 @@ interface ActionCursor { | ||||
|                               // access control state.
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A row-like view of TableDataAction, which is columnar in nature.  If index value | ||||
|  * is undefined, acts as an EmptyRecordRow. | ||||
|  */ | ||||
| export class RecordView implements InfoView { | ||||
|   public constructor(public data: TableDataAction, public index: number|undefined) { | ||||
|   } | ||||
| 
 | ||||
|   public get(colId: string): CellValue { | ||||
|     if (this.index === undefined) { return null; } | ||||
|     if (colId === 'id') { | ||||
|       return this.data[2][this.index]; | ||||
|     } | ||||
|     return this.data[3][colId]?.[this.index]; | ||||
|   } | ||||
| 
 | ||||
|   public has(colId: string) { | ||||
|     return colId === 'id' || colId in this.data[3]; | ||||
|   } | ||||
| 
 | ||||
|   public toJSON() { | ||||
|     if (this.index === undefined) { return {}; } | ||||
|     const results: {[key: string]: any} = {}; | ||||
|     for (const key of Object.keys(this.data[3])) { | ||||
|       results[key] = this.data[3][key]?.[this.index]; | ||||
|     } | ||||
|     return results; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A read-write view of a DataAction, for use in censorship. | ||||
|  */ | ||||
| @ -3222,47 +3190,6 @@ export function filterColValues(action: DataAction, | ||||
|   return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Information about a user, including any user attributes. | ||||
|  * | ||||
|  * Serializes into a more compact JSON form that excludes full | ||||
|  * row data, only keeping user info and table/row ids for any | ||||
|  * user attributes. | ||||
|  * | ||||
|  * See `user.py` for the sandbox equivalent that deserializes objects of this class. | ||||
|  */ | ||||
| export class User implements UserInfo { | ||||
|   public Name: string | null = null; | ||||
|   public UserID: number | null = null; | ||||
|   public Access: Role | null = null; | ||||
|   public Origin: string | null = null; | ||||
|   public LinkKey: Record<string, string | undefined> = {}; | ||||
|   public Email: string | null = null; | ||||
|   public SessionID: string | null = null; | ||||
|   public UserRef: string | null = null; | ||||
|   public ShareRef: number | null = null; | ||||
|   [attribute: string]: any; | ||||
| 
 | ||||
|   constructor(_info: Record<string, unknown> = {}) { | ||||
|     Object.assign(this, _info); | ||||
|   } | ||||
| 
 | ||||
|   public toJSON() { | ||||
|     const results: {[key: string]: any} = {}; | ||||
|     for (const [key, value] of Object.entries(this)) { | ||||
|       if (value instanceof RecordView) { | ||||
|         // Only include the table id and first matching row id.
 | ||||
|         results[key] = [getTableId(value.data), value.get('id')]; | ||||
|       } else if (value instanceof EmptyRecordView) { | ||||
|         results[key] = null; | ||||
|       } else { | ||||
|         results[key] = value; | ||||
|       } | ||||
|     } | ||||
|     return results; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function validTableIdString(tableId: any): string { | ||||
|   if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); } | ||||
|   return tableId; | ||||
|  | ||||
| @ -3,8 +3,9 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet, | ||||
|          MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet, | ||||
|          toMixed } from 'app/common/ACLPermissions'; | ||||
| import { ACLRuleCollection } from 'app/common/ACLRuleCollection'; | ||||
| import { RuleSet, UserInfo } from 'app/common/GranularAccessClause'; | ||||
| import { RuleSet } from 'app/common/GranularAccessClause'; | ||||
| import { PredicateFormulaInput } from 'app/common/PredicateFormula'; | ||||
| import { User } from 'app/common/User'; | ||||
| import { getSetMapValue } from 'app/common/gutil'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import { mapValues } from 'lodash'; | ||||
| @ -80,8 +81,8 @@ abstract class RuleInfo<MixedT extends TableT, TableT> { | ||||
|     return this._mergeFullAccess(tableAccess); | ||||
|   } | ||||
| 
 | ||||
|   public getUser(): UserInfo { | ||||
|     return this._input.user!; | ||||
|   public getUser(): User { | ||||
|     return this._input.user! as User; | ||||
|   } | ||||
| 
 | ||||
|   protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT; | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| import {UserAPI} from 'app/common/UserAPI'; | ||||
| import {assert, driver, Key} from 'mocha-webdriver'; | ||||
| import * as gu from 'test/nbrowser/gristUtils'; | ||||
| import {setupTestSuite} from 'test/nbrowser/testUtils'; | ||||
| @ -5,22 +6,56 @@ import {setupTestSuite} from 'test/nbrowser/testUtils'; | ||||
| describe('DropdownConditionEditor', function () { | ||||
|   this.timeout(20000); | ||||
|   const cleanup = setupTestSuite(); | ||||
|   let api: UserAPI; | ||||
|   let docId: string; | ||||
| 
 | ||||
|   before(async () => { | ||||
|     const session = await gu.session().login(); | ||||
|     await session.tempDoc(cleanup, 'DropdownCondition.grist'); | ||||
|     const session = await gu.session().user('user1').login(); | ||||
|     api = session.createHomeApi(); | ||||
|     docId = (await session.tempDoc(cleanup, 'DropdownCondition.grist')).id; | ||||
|     await api.updateDocPermissions(docId, {users: { | ||||
|       [gu.translateUser('user2').email]: 'editors', | ||||
|     }}); | ||||
|     await addUserAttributes(); | ||||
|     await gu.openPage('Employees'); | ||||
|     await gu.openColumnPanel(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => gu.checkForErrors()); | ||||
| 
 | ||||
|   async function addUserAttributes() { | ||||
|     await api.applyUserActions(docId, [ | ||||
|       ['AddTable', 'Roles', [{id: 'Email'}, {id: 'Admin', type: 'Bool'}]], | ||||
|       ['AddRecord', 'Roles', null, {Email: gu.translateUser('user1').email, Admin: true}], | ||||
|       ['AddRecord', 'Roles', null, {Email: gu.translateUser('user2').email, Admin: false}], | ||||
|     ]); | ||||
|     await driver.find('.test-tools-access-rules').click(); | ||||
|     await gu.waitForServer(); | ||||
|     await driver.findContentWait('button', /Add User Attributes/, 2000).click(); | ||||
|     const userAttrRule = await driver.find('.test-rule-userattr'); | ||||
|     await userAttrRule.find('.test-rule-userattr-name').click(); | ||||
|     await driver.sendKeys('Roles', Key.ENTER); | ||||
|     await userAttrRule.find('.test-rule-userattr-attr').click(); | ||||
|     await driver.sendKeys('Email', Key.ENTER); | ||||
|     await userAttrRule.find('.test-rule-userattr-table').click(); | ||||
|     await driver.findContent('.test-select-menu li', 'Roles').click(); | ||||
|     await userAttrRule.find('.test-rule-userattr-col').click(); | ||||
|     await driver.sendKeys('Email', Key.ENTER); | ||||
|     await driver.find('.test-rules-save').click(); | ||||
|     await gu.waitForServer(); | ||||
|   } | ||||
| 
 | ||||
|   describe(`in choice columns`, function() { | ||||
|     before(async () => { | ||||
|       const session = await gu.session().user('user1').login(); | ||||
|       await session.loadDoc(`/doc/${docId}`); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates dropdown conditions', async function() { | ||||
|       await gu.getCell(1, 1).click(); | ||||
|       assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); | ||||
|       await driver.find('.test-field-set-dropdown-condition').click(); | ||||
|       await driver.find('.test-field-dropdown-condition').click(); | ||||
|       await gu.waitAppFocus(false); | ||||
|       await gu.sendKeys('c'); | ||||
|       await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'c'); | ||||
|       await gu.waitToPass(async () => { | ||||
|         const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); | ||||
|         assert.deepEqual(completions, [ | ||||
| @ -28,6 +63,7 @@ describe('DropdownConditionEditor', function () { | ||||
|           're\nc\n.Name\n ', | ||||
|           're\nc\n.Role\n ', | ||||
|           're\nc\n.Supervisor\n ', | ||||
|           'user.A\nc\ncess\n ', | ||||
|         ]); | ||||
|       }); | ||||
|       await gu.sendKeysSlowly(['hoice not in $']); | ||||
| @ -141,6 +177,11 @@ describe('DropdownConditionEditor', function () { | ||||
|   }); | ||||
| 
 | ||||
|   describe(`in reference columns`, function() { | ||||
|     before(async () => { | ||||
|       const session = await gu.session().user('user1').login(); | ||||
|       await session.loadDoc(`/doc/${docId}`); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates dropdown conditions', async function() { | ||||
|       await gu.getCell(2, 1).click(); | ||||
|       assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); | ||||
| @ -288,4 +329,44 @@ describe('DropdownConditionEditor', function () { | ||||
|       await gu.sendKeys(Key.ESCAPE); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('supports user variable', async function() { | ||||
|     // Filter dropdown values based on a user attribute.
 | ||||
|     await gu.getCell(1, 1).click(); | ||||
|     await driver.find('.test-field-set-dropdown-condition').click(); | ||||
|     await gu.waitAppFocus(false); | ||||
|     await gu.sendKeysSlowly(['user.']); | ||||
|     await gu.waitToPass(async () => { | ||||
|       const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); | ||||
|       assert.deepEqual(completions, [ | ||||
|         'user.\nAccess\n ', | ||||
|         'user.\nEmail\n ', | ||||
|         'user.\nIsLoggedIn\n ', | ||||
|         'user.\nLinkKey.\n ', | ||||
|         'user.\nName\n ', | ||||
|         'user.\nOrigin\n ', | ||||
|         'user.\nRoles.Admin\n ', | ||||
|         'user.\nRoles.Email\n ', | ||||
|         '' | ||||
|       ]); | ||||
|     }); | ||||
|     await gu.sendKeys('Roles.Admin == True', Key.ENTER); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
|     // Check that user1 (who is an admin) can see dropdown values.
 | ||||
|     await gu.sendKeys(Key.ENTER); | ||||
|     assert.deepEqual(await driver.findAll('.test-autocomplete li', (el) => el.getText()), [ | ||||
|       'Trainee', | ||||
|       'Supervisor', | ||||
|     ]); | ||||
|     await gu.sendKeys(Key.ESCAPE); | ||||
| 
 | ||||
|     // Switch to user2 (who is not an admin), and check that they can't see any dropdown values.
 | ||||
|     const session = await gu.session().user('user2').login(); | ||||
|     await session.loadDoc(`/doc/${docId}`); | ||||
|     await gu.getCell(1, 1).click(); | ||||
|     await gu.sendKeys(Key.ENTER); | ||||
|     assert.deepEqual(await driver.findAll('.test-autocomplete li', (el) => el.getText()), []); | ||||
|     await gu.sendKeys(Key.ESCAPE); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| @ -1,9 +1,9 @@ | ||||
| import {CellValue} from 'app/common/DocActions'; | ||||
| import {InfoView} from 'app/common/GranularAccessClause'; | ||||
| import {GristObjCode} from 'app/plugin/GristData'; | ||||
| import {CompiledPredicateFormula, compilePredicateFormula} from 'app/common/PredicateFormula'; | ||||
| import {InfoView} from 'app/common/RecordView'; | ||||
| import {User} from 'app/common/User'; | ||||
| import {makeExceptionalDocSession} from 'app/server/lib/DocSession'; | ||||
| import {User} from 'app/server/lib/GranularAccess'; | ||||
| import {assert} from 'chai'; | ||||
| import {createDocTools} from 'test/server/docTools'; | ||||
| import * as testUtils from 'test/server/testUtils'; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user