(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:
George Gevoian 2024-05-29 14:55:21 -07:00
parent 50077540e2
commit 72066bf0e4
27 changed files with 426 additions and 268 deletions

View File

@ -1,4 +1,5 @@
import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor'; import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel'; import {ViewFieldRec} from 'app/client/models/DocModel';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; 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 {testId, theme} from 'app/client/ui2018/cssVars';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula'; import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
import {UserInfo} from 'app/common/User';
import {Computed, Disposable, dom, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
import isPlainObject from 'lodash/isPlainObject';
const t = makeT('DropdownConditionConfig'); const t = makeT('DropdownConditionConfig');
@ -99,7 +102,7 @@ export class DropdownConditionConfig extends Disposable {
private _editorElement: HTMLElement; private _editorElement: HTMLElement;
constructor(private _field: ViewFieldRec) { constructor(private _field: ViewFieldRec, private _gristDoc: GristDoc) {
super(); super();
this.autoDispose(this._text.addListener(() => { this.autoDispose(this._text.addListener(() => {
@ -167,6 +170,10 @@ export class DropdownConditionConfig extends Disposable {
private _getAutocompleteSuggestions(): ISuggestionWithValue[] { private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
const variables = ['choice']; const variables = ['choice'];
const user = this._gristDoc.docPageModel.user.get();
if (user) {
variables.push(...getUserCompletions(user));
}
const refColumns = this._refColumns.get(); const refColumns = this._refColumns.get();
if (refColumns) { if (refColumns) {
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`)); 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}) => `$${colId.peek()}`),
...columns.map(({colId}) => `rec.${colId.peek()}`), ...columns.map(({colId}) => `rec.${colId.peek()}`),
); );
const suggestions = [ const suggestions = [
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
'OWNER', 'EDITOR', 'VIEWER', '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, ` const cssSetDropdownConditionRow = styled(cssRow, `
margin-top: 16px; margin-top: 16px;
`); `);

View File

@ -63,7 +63,7 @@ export class TypeTransform extends ColumnTransform {
if (use(this._isFormWidget)) { if (use(this._isFormWidget)) {
return transformWidget.buildFormTransformConfigDom(); return transformWidget.buildFormTransformConfigDom();
} else { } else {
return transformWidget.buildTransformConfigDom(); return transformWidget.buildTransformConfigDom(this.gristDoc);
} }
}), }),
dom.maybe(this._reviseTypeChange, () => dom.maybe(this._reviseTypeChange, () =>

View File

@ -1,13 +1,13 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {ACIndex, ACResults} from 'app/client/lib/ACIndex'; import {ACIndex, ACResults} from 'app/client/lib/ACIndex';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {ICellItem} from 'app/client/models/ColumnACIndexes'; import {ICellItem} from 'app/client/models/ColumnACIndexes';
import {ColumnCache} from 'app/client/models/ColumnCache'; import {ColumnCache} from 'app/client/models/ColumnCache';
import {DocData} from 'app/client/models/DocData';
import {ColumnRec} from 'app/client/models/entities/ColumnRec'; import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {TableData} from 'app/client/models/TableData'; import {TableData} from 'app/client/models/TableData';
import {getReferencedTableId, isRefListType} from 'app/common/gristTypes'; 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 {BaseFormatter} from 'app/common/ValueFormatter';
import {Disposable, dom, Observable} from 'grainjs'; import {Disposable, dom, Observable} from 'grainjs';
@ -26,9 +26,10 @@ export class ReferenceUtils extends Disposable {
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text); public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>; private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
private readonly _docData = this._gristDoc.docData;
private _dropdownConditionError = Observable.create<string | null>(this, null); 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(); super();
const colType = field.column().type(); const colType = field.column().type();
@ -38,7 +39,7 @@ export class ReferenceUtils extends Disposable {
} }
this.refTableId = refTableId; this.refTableId = refTableId;
const tableData = _docData.getTable(refTableId); const tableData = this._docData.getTable(refTableId);
if (!tableData) { if (!tableData) {
throw new Error("Invalid referenced table " + refTableId); 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`); } if (!table) { throw new Error(`Table ${tableId} not found`); }
const {result: predicate} = dropdownConditionCompiled; const {result: predicate} = dropdownConditionCompiled;
const user = this._gristDoc.docPageModel.user.get() ?? undefined;
const rec = table.getRecord(rowId) || new EmptyRecordView(); const rec = table.getRecord(rowId) || new EmptyRecordView();
return (item: ICellItem) => { return (item: ICellItem) => {
const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId); const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);
if (!choice) { throw new Error(`Reference ${item.rowId} not found`); } if (!choice) { throw new Error(`Reference ${item.rowId} not found`); }
return predicate({rec, choice}); return predicate({user, rec, choice});
}; };
} }
} }

View File

@ -23,6 +23,7 @@ import {Features, mergedFeatures, Product} from 'app/common/Features';
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil'; import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit, isOwner} from 'app/common/roles'; 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 {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
import {Holder, Observable, subscribe} from 'grainjs'; import {Holder, Observable, subscribe} from 'grainjs';
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
@ -38,6 +39,7 @@ export interface DocInfo extends Document {
isPreFork: boolean; isPreFork: boolean;
isFork: boolean; isFork: boolean;
isRecoveryMode: boolean; isRecoveryMode: boolean;
user: UserInfo|null;
userOverride: UserOverride|null; userOverride: UserOverride|null;
isBareFork: boolean; // a document created without logging in, which is treated as a isBareFork: boolean; // a document created without logging in, which is treated as a
// fork without an original. // fork without an original.
@ -78,6 +80,7 @@ export interface DocPageModel {
isPrefork: Observable<boolean>; isPrefork: Observable<boolean>;
isFork: Observable<boolean>; isFork: Observable<boolean>;
isRecoveryMode: Observable<boolean>; isRecoveryMode: Observable<boolean>;
user: Observable<UserInfo|null>;
userOverride: Observable<UserOverride|null>; userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>; isBareFork: Observable<boolean>;
isSnapshot: 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 isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
public readonly isRecoveryMode = Computed.create(this, this.currentDoc, public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
(use, doc) => doc ? doc.isRecoveryMode : false); (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 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 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); 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... // TODO It would be bad if a new doc gets opened while this getDoc() is pending...
const newDoc = await getDoc(this._api, urlId); const newDoc = await getDoc(this._api, urlId);
const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode); const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);
const user = this.currentDoc.get()?.user || null;
const userOverride = this.currentDoc.get()?.userOverride || 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; return newDoc;
} }
@ -407,11 +412,13 @@ It also disables formulas. [{{error}}]", {error: err.message})
linkParameters, linkParameters,
originalUrlId: options.originalUrlId, originalUrlId: options.originalUrlId,
}); });
if (openDocResponse.recoveryMode || openDocResponse.userOverride) { const {user, recoveryMode, userOverride} = openDocResponse;
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode); doc.user = user;
doc.userOverride = openDocResponse.userOverride || null; if (recoveryMode || userOverride) {
this.currentDoc.set({...doc}); doc.isRecoveryMode = Boolean(recoveryMode);
doc.userOverride = userOverride || null;
} }
this.currentDoc.set({...doc});
if (openDocResponse.docUsage) { if (openDocResponse.docUsage) {
this.updateCurrentDocUsage(openDocResponse.docUsage); this.updateCurrentDocUsage(openDocResponse.docUsage);
} }
@ -520,6 +527,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
...doc, ...doc,
isFork, isFork,
isRecoveryMode: false, // we don't know yet, will learn when doc is opened. isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
user: null, // ditto.
userOverride: null, // ditto. userOverride: null, // ditto.
isPreFork, isPreFork,
isBareFork, isBareFork,

View File

@ -21,7 +21,7 @@ dispose.makeDisposable(AbstractWidget);
/** /**
* Builds the DOM showing configuration buttons and fields in the sidebar. * 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"); 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. * 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. * 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; return null;
}; };

View File

@ -129,7 +129,7 @@ ChoiceEditor.prototype.buildDropdownConditionFilter = function() {
return buildDropdownConditionFilter({ return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result, dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData, gristDoc: this.options.gristDoc,
tableId: this.options.field.tableId(), tableId: this.options.field.tableId(),
rowId: this.options.rowId, rowId: this.options.rowId,
}); });

View File

@ -1,10 +1,10 @@
import {createGroup} from 'app/client/components/commands'; import {createGroup} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {ACIndexImpl, ACItem, ACResults, import {ACIndexImpl, ACItem, ACResults,
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex'; buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
import {IAutocompleteOptions} from 'app/client/lib/autocomplete'; import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField'; 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 {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus'; import {menuCssClass} from 'app/client/ui2018/menus';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; 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 {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {csvEncodeRow} from 'app/common/csvFormat'; import {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions"; 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 {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken'; import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
@ -246,7 +247,7 @@ export class ChoiceListEditor extends NewBaseEditor {
return buildDropdownConditionFilter({ return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result, dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData, gristDoc: this.options.gristDoc,
tableId: this.options.field.tableId(), tableId: this.options.field.tableId(),
rowId: this.options.rowId, rowId: this.options.rowId,
}); });
@ -311,7 +312,7 @@ export class ChoiceListEditor extends NewBaseEditor {
export interface GetACFilterFuncParams { export interface GetACFilterFuncParams {
dropdownConditionCompiled: CompiledPredicateFormula; dropdownConditionCompiled: CompiledPredicateFormula;
docData: DocData; gristDoc: GristDoc;
tableId: string; tableId: string;
rowId: number; rowId: number;
} }
@ -319,12 +320,13 @@ export interface GetACFilterFuncParams {
export function buildDropdownConditionFilter( export function buildDropdownConditionFilter(
params: GetACFilterFuncParams params: GetACFilterFuncParams
): (item: ChoiceItem) => boolean { ): (item: ChoiceItem) => boolean {
const {dropdownConditionCompiled, docData, tableId, rowId} = params; const {dropdownConditionCompiled, gristDoc, tableId, rowId} = params;
const table = docData.getTable(tableId); const table = gristDoc.docData.getTable(tableId);
if (!table) { throw new Error(`Table ${tableId} not found`); } if (!table) { throw new Error(`Table ${tableId} not found`); }
const user = gristDoc.docPageModel.user.get() ?? undefined;
const rec = table.getRecord(rowId) || new EmptyRecordView(); 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', ` const cssCellEditor = styled('div', `

View File

@ -4,6 +4,7 @@ import {
FormSelectConfig, FormSelectConfig,
} from 'app/client/components/Forms/FormConfig'; } from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig'; import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@ -79,17 +80,17 @@ export class ChoiceTextBox extends NTextBox {
); );
} }
public buildConfigDom() { public buildConfigDom(gristDoc: GristDoc) {
return [ return [
super.buildConfigDom(), super.buildConfigDom(gristDoc),
this.buildChoicesConfigDom(), this.buildChoicesConfigDom(),
dom.create(DropdownConditionConfig, this.field), dom.create(DropdownConditionConfig, this.field, gristDoc),
]; ];
} }
public buildTransformConfigDom() { public buildTransformConfigDom(gristDoc: GristDoc) {
return [ return [
super.buildConfigDom(), super.buildConfigDom(gristDoc),
this.buildChoicesConfigDom(), this.buildChoicesConfigDom(),
]; ];
} }

View File

@ -54,7 +54,7 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true, * Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
* builds only the necessary dom for the transform config menu. * 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(() => { const disabled = ko.pureComputed(() => {
return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData(); return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData();
}); });
@ -92,8 +92,8 @@ DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
); );
}; };
DateTimeTextBox.prototype.buildTransformConfigDom = function() { DateTimeTextBox.prototype.buildTransformConfigDom = function(gristDoc) {
return this.buildConfigDom(true); return this.buildConfigDom(gristDoc, true);
}; };
// clean up old koform styles // clean up old koform styles

View File

@ -499,7 +499,7 @@ export class FieldBuilder extends Disposable {
// the dom created by the widgetImpl to get out of sync. // the dom created by the widgetImpl to get out of sync.
return dom('div', return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) => kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildConfigDom()) dom('div', widget.buildConfigDom(this.gristDoc))
) )
); );
} }

View File

@ -1,4 +1,5 @@
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { GristDoc } from 'app/client/components/GristDoc';
import { fromKoSave } from 'app/client/lib/fromKoSave'; import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization'; import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel'; 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 toggle = () => {
const newValue = !this.field.config.wrap.peek(); const newValue = !this.field.config.wrap.peek();
this.field.config.wrap.setAndSave(newValue).catch(reportError); this.field.config.wrap.setAndSave(newValue).catch(reportError);

View File

@ -60,7 +60,7 @@ export abstract class NewAbstractWidget extends Disposable {
/** /**
* Builds the DOM showing configuration buttons and fields in the sidebar. * Builds the DOM showing configuration buttons and fields in the sidebar.
*/ */
public buildConfigDom(): DomContents { public buildConfigDom(_gristDoc: GristDoc): DomContents {
return null; 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. * 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. * 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; return null;
} }

View File

@ -2,6 +2,7 @@
* See app/common/NumberFormat for description of options we support. * See app/common/NumberFormat for description of options we support.
*/ */
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig'; import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {fromKoSave} from 'app/client/lib/fromKoSave'; import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@ -39,7 +40,7 @@ export class NumericTextBox extends NTextBox {
super(field); super(field);
} }
public buildConfigDom(): DomContents { public buildConfigDom(gristDoc: GristDoc): DomContents {
// Holder for all computeds created here. It gets disposed with the returned DOM element. // Holder for all computeds created here. It gets disposed with the returned DOM element.
const holder = new MultiHolder(); const holder = new MultiHolder();
@ -89,7 +90,7 @@ export class NumericTextBox extends NTextBox {
const disabledStyle = cssButtonSelect.cls('-disabled', disabled); const disabledStyle = cssButtonSelect.cls('-disabled', disabled);
return [ return [
super.buildConfigDom(), super.buildConfigDom(gristDoc),
cssLabel(t('Number Format')), cssLabel(t('Number Format')),
cssRow( cssRow(
dom.autoDispose(holder), dom.autoDispose(holder),

View File

@ -4,6 +4,7 @@ import {
FormSelectConfig FormSelectConfig
} from 'app/client/components/Forms/FormConfig'; } from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig'; import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
@ -53,12 +54,12 @@ export class Reference extends NTextBox {
}); });
} }
public buildConfigDom() { public buildConfigDom(gristDoc: GristDoc) {
return [ return [
this.buildTransformConfigDom(), this.buildTransformConfigDom(),
dom.create(DropdownConditionConfig, this.field), dom.create(DropdownConditionConfig, this.field, gristDoc),
cssLabel(t('CELL FORMAT')), cssLabel(t('CELL FORMAT')),
super.buildConfigDom(), super.buildConfigDom(gristDoc),
]; ];
} }

View File

@ -23,8 +23,8 @@ export class ReferenceEditor extends NTextEditor {
constructor(options: FieldOptions) { constructor(options: FieldOptions) {
super(options); super(options);
const docData = options.gristDoc.docData; const gristDoc = options.gristDoc;
this._utils = new ReferenceUtils(options.field, docData); this._utils = new ReferenceUtils(options.field, gristDoc);
const vcol = this._utils.visibleColModel; const vcol = this._utils.visibleColModel;
this._enableAddNew = ( 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 // 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. // 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 (this.isDisposed()) { return; }
if (needReload && this.textInput.value === '') { if (needReload && this.textInput.value === '') {
this.textInput.value = undef(options.state, options.editValue, this._idToText()); this.textInput.value = undef(options.state, options.editValue, this._idToText());

View File

@ -55,8 +55,8 @@ export class ReferenceListEditor extends NewBaseEditor {
constructor(protected options: FieldOptions) { constructor(protected options: FieldOptions) {
super(options); super(options);
const docData = options.gristDoc.docData; const gristDoc = options.gristDoc;
this._utils = new ReferenceUtils(options.field, docData); this._utils = new ReferenceUtils(options.field, gristDoc);
const vcol = this._utils.visibleColModel; const vcol = this._utils.visibleColModel;
this._enableAddNew = ( 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 // 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. // 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 (this.isDisposed()) { return; }
if (needReload) { if (needReload) {
this._tokenField.setTokens( this._tokenField.setTokens(

View File

@ -3,6 +3,7 @@ import {TableDataAction} from 'app/common/DocActions';
import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Role} from 'app/common/roles'; import {Role} from 'app/common/roles';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {UserInfo} from 'app/common/User';
import {FullUser} from 'app/common/UserAPI'; import {FullUser} from 'app/common/UserAPI';
// Possible flavors of items in a list of documents. // Possible flavors of items in a list of documents.
@ -75,6 +76,7 @@ export interface OpenLocalDocResult {
doc: {[tableId: string]: TableDataAction}; doc: {[tableId: string]: TableDataAction};
log: MinimalActionGroup[]; log: MinimalActionGroup[];
isTimingOn: boolean; isTimingOn: boolean;
user: UserInfo;
recoveryMode?: boolean; recoveryMode?: boolean;
userOverride?: UserOverride; userOverride?: UserOverride;
docUsage?: FilteredDocUsageSummary; docUsage?: FilteredDocUsageSummary;

View File

@ -1,7 +1,6 @@
import {PartialPermissionSet} from 'app/common/ACLPermissions'; import {PartialPermissionSet} from 'app/common/ACLPermissions';
import {CellValue, RowRecord} from 'app/common/DocActions'; import {CellValue, RowRecord} from 'app/common/DocActions';
import {CompiledPredicateFormula} from 'app/common/PredicateFormula'; import {CompiledPredicateFormula} from 'app/common/PredicateFormula';
import {Role} from 'app/common/roles';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
export interface RuleSet { export interface RuleSet {
@ -25,12 +24,6 @@ export interface RulePart {
memo?: string; 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. // As InfoView, but also supporting writing.
export interface InfoEditor { export interface InfoEditor {
get(key: string): CellValue; get(key: string): CellValue;
@ -38,22 +31,6 @@ export interface InfoEditor {
toJSON(): {[key: string]: any}; 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 { export interface UserAttributeRule {
origRecord?: RowRecord; // Original record used to create this UserAttributeRule. origRecord?: RowRecord; // Original record used to create this UserAttributeRule.
name: string; // Should be unique among UserAttributeRules. name: string; // Should be unique among UserAttributeRules.

View File

@ -10,7 +10,8 @@
*/ */
import {CellValue, RowRecord} from 'app/common/DocActions'; import {CellValue, RowRecord} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode'; 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 {decodeObject} from 'app/plugin/objtypes';
import constant = require('lodash/constant'); import constant = require('lodash/constant');
@ -31,11 +32,6 @@ export interface PredicateFormulaInput {
choice?: string|RowRecord|InfoView; choice?: string|RowRecord|InfoView;
} }
export class EmptyRecordView implements InfoView {
public get(_colId: string): CellValue { return null; }
public toJSON() { return {}; }
}
/** /**
* The result of compiling ParsedPredicateFormula. * The result of compiling ParsedPredicateFormula.
*/ */
@ -102,7 +98,7 @@ export function compilePredicateFormula(
break; break;
} }
case 'dropdown-condition': { case 'dropdown-condition': {
validNames = ['rec', 'choice']; validNames = ['rec', 'choice', 'user'];
break; break;
} }
} }

43
app/common/RecordView.ts Normal file
View 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
View 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;
}
}

View File

@ -443,6 +443,10 @@ export class ActiveDoc extends EventEmitter {
return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary()); return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary());
} }
public getUser(docSession: OptDocSession) {
return this._granularAccess.getUser(docSession);
}
public async getUserOverride(docSession: OptDocSession) { public async getUserOverride(docSession: OptDocSession) {
return this._granularAccess.getUserOverride(docSession); return this._granularAccess.getUserOverride(docSession);
} }

View File

@ -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.fetchMetaTables(docSession),
activeDoc.getRecentMinimalActions(docSession), activeDoc.getRecentMinimalActions(docSession),
activeDoc.getUser(docSession),
activeDoc.getUserOverride(docSession), activeDoc.getUserOverride(docSession),
]); ]);
@ -414,6 +415,7 @@ export class DocManager extends EventEmitter {
doc: metaTables, doc: metaTables,
log: recentActions, log: recentActions,
recoveryMode: activeDoc.recoveryMode, recoveryMode: activeDoc.recoveryMode,
user: user.toUserInfo(),
userOverride, userOverride,
docUsage, docUsage,
isTimingOn: activeDoc.isTimingOn, isTimingOn: activeDoc.isTimingOn,

View File

@ -26,12 +26,14 @@ import { UserOverride } from 'app/common/DocListAPI';
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage'; import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
import { normalizeEmail } from 'app/common/emails'; import { normalizeEmail } from 'app/common/emails';
import { ErrorWithCode } from 'app/common/ErrorWithCode'; 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 * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil'; 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 { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { User } from 'app/common/User';
import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData'; import { GristObjCode } from 'app/plugin/GristData';
@ -330,11 +332,106 @@ export class GranularAccess implements GranularAccessForBundle {
this._userAttributesMap = new WeakMap(); 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); const access = await this._getAccess(docSession);
return access.getUser(); return access.getUser();
} }
@ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle {
*/ */
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> { public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
return { return {
user: await this._getUser(docSession), user: await this.getUser(docSession),
docId: this._docId docId: this._docId
}; };
} }
@ -479,7 +576,7 @@ export class GranularAccess implements GranularAccessForBundle {
public async canApplyBundle() { public async canApplyBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); } if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession, isDirect} = this._activeBundle; const {docActions, docSession, isDirect} = this._activeBundle;
const currentUser = await this._getUser(docSession); const currentUser = await this.getUser(docSession);
const userIsOwner = await this.isOwner(docSession); const userIsOwner = await this.isOwner(docSession);
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) { if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules'); 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> { public async getUserOverride(docSession: OptDocSession): Promise<UserOverride|undefined> {
await this._getUser(docSession); await this.getUser(docSession);
return this._getUserAttributes(docSession).override; return this._getUserAttributes(docSession).override;
} }
@ -1120,7 +1217,7 @@ export class GranularAccess implements GranularAccessForBundle {
const linkParameters = docSession.authorizer?.getLinkParameters() || {}; const linkParameters = docSession.authorizer?.getLinkParameters() || {};
const baseAccess = getDocSessionAccess(docSession); const baseAccess = getDocSessionAccess(docSession);
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') { if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
const info = await this._getUser(docSession); const info = await this.getUser(docSession);
return info.Access; return info.Access;
} }
return baseAccess; 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. * Get the "View As" user specified in link parameters.
* If aclAsUserId is set, we get the user with the specified id. * 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. * 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. // Owner can modify all comments, without exceptions.
if (userIsOwner) { if (userIsOwner) {
return; return;
@ -2654,7 +2652,7 @@ export class Ruler {
} }
export interface RulerOwner { export interface RulerOwner {
getUser(docSession: OptDocSession): Promise<UserInfo>; getUser(docSession: OptDocSession): Promise<User>;
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>; inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
} }
@ -2688,36 +2686,6 @@ interface ActionCursor {
// access control state. // 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. * 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)!)]; 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 { export function validTableIdString(tableId: any): string {
if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); } if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); }
return tableId; return tableId;

View File

@ -3,8 +3,9 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet, MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions'; toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection'; 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 { PredicateFormulaInput } from 'app/common/PredicateFormula';
import { User } from 'app/common/User';
import { getSetMapValue } from 'app/common/gutil'; import { getSetMapValue } from 'app/common/gutil';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import { mapValues } from 'lodash'; import { mapValues } from 'lodash';
@ -80,8 +81,8 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
return this._mergeFullAccess(tableAccess); return this._mergeFullAccess(tableAccess);
} }
public getUser(): UserInfo { public getUser(): User {
return this._input.user!; return this._input.user! as User;
} }
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT; protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;

View File

@ -1,3 +1,4 @@
import {UserAPI} from 'app/common/UserAPI';
import {assert, driver, Key} from 'mocha-webdriver'; import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils'; import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils'; import {setupTestSuite} from 'test/nbrowser/testUtils';
@ -5,22 +6,56 @@ import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('DropdownConditionEditor', function () { describe('DropdownConditionEditor', function () {
this.timeout(20000); this.timeout(20000);
const cleanup = setupTestSuite(); const cleanup = setupTestSuite();
let api: UserAPI;
let docId: string;
before(async () => { before(async () => {
const session = await gu.session().login(); const session = await gu.session().user('user1').login();
await session.tempDoc(cleanup, 'DropdownCondition.grist'); 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(); await gu.openColumnPanel();
}); });
afterEach(() => gu.checkForErrors()); 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() { 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() { it('creates dropdown conditions', async function() {
await gu.getCell(1, 1).click(); await gu.getCell(1, 1).click();
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); await driver.find('.test-field-dropdown-condition').click();
await driver.find('.test-field-set-dropdown-condition').click();
await gu.waitAppFocus(false); await gu.waitAppFocus(false);
await gu.sendKeys('c'); await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'c');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText());
assert.deepEqual(completions, [ assert.deepEqual(completions, [
@ -28,6 +63,7 @@ describe('DropdownConditionEditor', function () {
're\nc\n.Name\n ', 're\nc\n.Name\n ',
're\nc\n.Role\n ', 're\nc\n.Role\n ',
're\nc\n.Supervisor\n ', 're\nc\n.Supervisor\n ',
'user.A\nc\ncess\n ',
]); ]);
}); });
await gu.sendKeysSlowly(['hoice not in $']); await gu.sendKeysSlowly(['hoice not in $']);
@ -141,6 +177,11 @@ describe('DropdownConditionEditor', function () {
}); });
describe(`in reference columns`, 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() { it('creates dropdown conditions', async function() {
await gu.getCell(2, 1).click(); await gu.getCell(2, 1).click();
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent());
@ -288,4 +329,44 @@ describe('DropdownConditionEditor', function () {
await gu.sendKeys(Key.ESCAPE); 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);
});
}); });

View File

@ -1,9 +1,9 @@
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {InfoView} from 'app/common/GranularAccessClause';
import {GristObjCode} from 'app/plugin/GristData'; import {GristObjCode} from 'app/plugin/GristData';
import {CompiledPredicateFormula, compilePredicateFormula} from 'app/common/PredicateFormula'; 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 {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import {User} from 'app/server/lib/GranularAccess';
import {assert} from 'chai'; import {assert} from 'chai';
import {createDocTools} from 'test/server/docTools'; import {createDocTools} from 'test/server/docTools';
import * as testUtils from 'test/server/testUtils'; import * as testUtils from 'test/server/testUtils';