mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Change user attribute from select to formula, especially to allow link keys
Test Plan: Created a user attribute under access rules, set the attribute to look up to user.LinkKey.e, confirmed that setting e_ in the URL modified access. Reviewers: dsagal, paulfitz Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2824
This commit is contained in:
parent
3a586a5f6c
commit
0890749d15
@ -9,6 +9,7 @@ export interface ACLFormulaOptions {
|
|||||||
placeholder: DomArg;
|
placeholder: DomArg;
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
getSuggestions: (prefix: string) => string[];
|
getSuggestions: (prefix: string) => string[];
|
||||||
|
customiseEditor?: (editor: ace.Editor) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aclFormulaEditor(options: ACLFormulaOptions) {
|
export function aclFormulaEditor(options: ACLFormulaOptions) {
|
||||||
@ -69,6 +70,10 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
|
|||||||
// Set the editor's initial value.
|
// Set the editor's initial value.
|
||||||
editor.setValue(options.initialValue);
|
editor.setValue(options.initialValue);
|
||||||
|
|
||||||
|
if (options.customiseEditor) {
|
||||||
|
options.customiseEditor(editor);
|
||||||
|
}
|
||||||
|
|
||||||
return cssConditionInputAce(
|
return cssConditionInputAce(
|
||||||
cssConditionInputAce.cls('-disabled', options.readOnly),
|
cssConditionInputAce.cls('-disabled', options.readOnly),
|
||||||
// ACE editor calls preventDefault on clicks into the scrollbar area, which prevents focus
|
// ACE editor calls preventDefault on clicks into the scrollbar area, which prevents focus
|
||||||
|
@ -15,21 +15,42 @@ import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
|||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||||
import {IOptionFull, menu, menuItemAsync} from 'app/client/ui2018/menus';
|
import {menu, menuItemAsync} from 'app/client/ui2018/menus';
|
||||||
import {emptyPermissionSet, MixedPermissionValue, PartialPermissionSet} from 'app/common/ACLPermissions';
|
import {
|
||||||
import {parsePermissions, permissionSetToText} from 'app/common/ACLPermissions';
|
emptyPermissionSet,
|
||||||
import {summarizePermissions, summarizePermissionSet} from 'app/common/ACLPermissions';
|
MixedPermissionValue,
|
||||||
|
parsePermissions,
|
||||||
|
PartialPermissionSet,
|
||||||
|
permissionSetToText,
|
||||||
|
summarizePermissions,
|
||||||
|
summarizePermissionSet
|
||||||
|
} from 'app/common/ACLPermissions';
|
||||||
import {ACLRuleCollection, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
|
import {ACLRuleCollection, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
|
||||||
import {BulkColValues, RowRecord, UserAction} from 'app/common/DocActions';
|
import {BulkColValues, RowRecord, UserAction} from 'app/common/DocActions';
|
||||||
import {FormulaProperties, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
|
import {
|
||||||
import {getFormulaProperties} from 'app/common/GranularAccessClause';
|
FormulaProperties,
|
||||||
|
getFormulaProperties,
|
||||||
|
RulePart,
|
||||||
|
RuleSet,
|
||||||
|
UserAttributeRule
|
||||||
|
} from 'app/common/GranularAccessClause';
|
||||||
import {isHiddenCol} from 'app/common/gristTypes';
|
import {isHiddenCol} from 'app/common/gristTypes';
|
||||||
import {isObject} from 'app/common/gutil';
|
import {isObject} from 'app/common/gutil';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {SchemaTypes} from 'app/common/schema';
|
import {SchemaTypes} from 'app/common/schema';
|
||||||
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, getRealAccess} from 'app/common/UserAPI';
|
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, getRealAccess} from 'app/common/UserAPI';
|
||||||
import {BaseObservable, Computed, Disposable, MutableObsArray, obsArray, Observable} from 'grainjs';
|
import {
|
||||||
import {dom, DomElementArg, IDisposableOwner, styled} from 'grainjs';
|
BaseObservable,
|
||||||
|
Computed,
|
||||||
|
Disposable,
|
||||||
|
dom,
|
||||||
|
DomElementArg,
|
||||||
|
IDisposableOwner,
|
||||||
|
MutableObsArray,
|
||||||
|
obsArray,
|
||||||
|
Observable,
|
||||||
|
styled
|
||||||
|
} from 'grainjs';
|
||||||
import isEqual = require('lodash/isEqual');
|
import isEqual = require('lodash/isEqual');
|
||||||
|
|
||||||
// tslint:disable:max-classes-per-file no-console
|
// tslint:disable:max-classes-per-file no-console
|
||||||
@ -49,10 +70,11 @@ enum RuleStatus {
|
|||||||
CheckPending,
|
CheckPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option for UserAttribute select() choices. RuleIndex is used to filter for only those user
|
// UserAttribute autocomplete choices. RuleIndex is used to filter for only those user
|
||||||
// attributes made available by the previous rules.
|
// attributes made available by the previous rules.
|
||||||
interface IAttrOption extends IOptionFull<string> {
|
interface IAttrOption {
|
||||||
ruleIndex: number;
|
ruleIndex: number;
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,18 +140,18 @@ export class AccessRules extends Disposable {
|
|||||||
|
|
||||||
this._userAttrChoices = Computed.create(this, this._userAttrRules, (use, rules) => {
|
this._userAttrChoices = Computed.create(this, this._userAttrRules, (use, rules) => {
|
||||||
const result: IAttrOption[] = [
|
const result: IAttrOption[] = [
|
||||||
{ruleIndex: -1, value: 'Access', label: 'user.Access'},
|
{ruleIndex: -1, value: 'user.Access'},
|
||||||
{ruleIndex: -1, value: 'Email', label: 'user.Email'},
|
{ruleIndex: -1, value: 'user.Email'},
|
||||||
{ruleIndex: -1, value: 'UserID', label: 'user.UserID'},
|
{ruleIndex: -1, value: 'user.UserID'},
|
||||||
{ruleIndex: -1, value: 'Name', label: 'user.Name'},
|
{ruleIndex: -1, value: 'user.Name'},
|
||||||
{ruleIndex: -1, value: 'LinkKey', label: 'user.LinkKey'},
|
{ruleIndex: -1, value: 'user.LinkKey.'},
|
||||||
{ruleIndex: -1, value: 'Origin', label: 'user.Origin'},
|
{ruleIndex: -1, value: 'user.Origin'},
|
||||||
];
|
];
|
||||||
for (const [i, rule] of rules.entries()) {
|
for (const [i, rule] of rules.entries()) {
|
||||||
const tableId = use(rule.tableId);
|
const tableId = use(rule.tableId);
|
||||||
const name = use(rule.name);
|
const name = use(rule.name);
|
||||||
for (const colId of this.getValidColIds(tableId) || []) {
|
for (const colId of this.getValidColIds(tableId) || []) {
|
||||||
result.push({ruleIndex: i, value: `${name}.${colId}`, label: `user.${name}.${colId}`});
|
result.push({ruleIndex: i, value: `user.${name}.${colId}`});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@ -972,30 +994,37 @@ class ObsUserAttributeRule extends Disposable {
|
|||||||
private _name = Observable.create<string>(this, this._userAttr?.name || '');
|
private _name = Observable.create<string>(this, this._userAttr?.name || '');
|
||||||
private _tableId = Observable.create<string>(this, this._userAttr?.tableId || '');
|
private _tableId = Observable.create<string>(this, this._userAttr?.tableId || '');
|
||||||
private _lookupColId = Observable.create<string>(this, this._userAttr?.lookupColId || '');
|
private _lookupColId = Observable.create<string>(this, this._userAttr?.lookupColId || '');
|
||||||
private _charId = Observable.create<string>(this, this._userAttr?.charId || '');
|
private _charId = Observable.create<string>(this, 'user.' + (this._userAttr?.charId || ''));
|
||||||
private _validColIds = Computed.create(this, this._tableId, (use, tableId) =>
|
private _validColIds = Computed.create(this, this._tableId, (use, tableId) =>
|
||||||
this._accessRules.getValidColIds(tableId) || []);
|
this._accessRules.getValidColIds(tableId) || []);
|
||||||
|
|
||||||
private _userAttrChoices: Computed<IAttrOption[]>;
|
private _userAttrChoices: Computed<IAttrOption[]>;
|
||||||
|
private _userAttrError = Observable.create(this, '');
|
||||||
|
|
||||||
constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule,
|
constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule,
|
||||||
private _options: {focus?: boolean} = {}) {
|
private _options: {focus?: boolean} = {}) {
|
||||||
super();
|
super();
|
||||||
this.formulaError = Computed.create(this, this._tableId, this._lookupColId, (use, tableId, colId) => {
|
this.formulaError = Computed.create(
|
||||||
// Don't check for errors if it's an existing rule and hasn't changed.
|
this, this._tableId, this._lookupColId, this._userAttrError,
|
||||||
if (use(this._tableId) === this._userAttr?.tableId &&
|
(use, tableId, colId, userAttrError) => {
|
||||||
use(this._lookupColId) === this._userAttr?.lookupColId) {
|
if (userAttrError.length) {
|
||||||
return '';
|
return userAttrError;
|
||||||
}
|
}
|
||||||
return _accessRules.checkTableColumns(tableId, colId ? [colId] : undefined);
|
|
||||||
});
|
// Don't check for errors if it's an existing rule and hasn't changed.
|
||||||
|
if (use(this._tableId) === this._userAttr?.tableId &&
|
||||||
|
use(this._lookupColId) === this._userAttr?.lookupColId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return _accessRules.checkTableColumns(tableId, colId ? [colId] : undefined);
|
||||||
|
});
|
||||||
this.ruleStatus = Computed.create(this, use => {
|
this.ruleStatus = Computed.create(this, use => {
|
||||||
if (use(this.formulaError)) { return RuleStatus.Invalid; }
|
if (use(this.formulaError)) { return RuleStatus.Invalid; }
|
||||||
return getChangedStatus(
|
return getChangedStatus(
|
||||||
use(this._name) !== this._userAttr?.name ||
|
use(this._name) !== this._userAttr?.name ||
|
||||||
use(this._tableId) !== this._userAttr?.tableId ||
|
use(this._tableId) !== this._userAttr?.tableId ||
|
||||||
use(this._lookupColId) !== this._userAttr?.lookupColId ||
|
use(this._lookupColId) !== this._userAttr?.lookupColId ||
|
||||||
use(this._charId) !== this._userAttr?.charId
|
use(this._charId) !== 'user.' + this._userAttr?.charId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1005,14 +1034,7 @@ class ObsUserAttributeRule extends Disposable {
|
|||||||
this._userAttrChoices = Computed.create(this, _accessRules.userAttrRules, (use, rules) => {
|
this._userAttrChoices = Computed.create(this, _accessRules.userAttrRules, (use, rules) => {
|
||||||
// Filter for only those choices created by previous rules.
|
// Filter for only those choices created by previous rules.
|
||||||
const index = rules.indexOf(this);
|
const index = rules.indexOf(this);
|
||||||
const result = use(this._accessRules.userAttrChoices).filter(c => (c.ruleIndex < index));
|
return use(this._accessRules.userAttrChoices).filter(c => (c.ruleIndex < index));
|
||||||
|
|
||||||
// If the currently-selected option isn't one of the choices, insert it too.
|
|
||||||
const charId = use(this._charId);
|
|
||||||
if (charId && !result.some(choice => (choice.value === charId))) {
|
|
||||||
result.unshift({ruleIndex: -1, value: charId, label: `user.${charId}`});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1033,8 +1055,21 @@ class ObsUserAttributeRule extends Disposable {
|
|||||||
cssCell4(cssRuleBody.cls(''),
|
cssCell4(cssRuleBody.cls(''),
|
||||||
cssColumnGroup(
|
cssColumnGroup(
|
||||||
cssCell1(
|
cssCell1(
|
||||||
aclSelect(this._charId, this._userAttrChoices,
|
aclFormulaEditor({
|
||||||
{defaultLabel: '[Select Attribute]'}),
|
initialValue: this._charId.get(),
|
||||||
|
readOnly: false,
|
||||||
|
setValue: (text) => this._setUserAttr(text),
|
||||||
|
placeholder: '',
|
||||||
|
getSuggestions: () => this._userAttrChoices.get().map(choice => choice.value),
|
||||||
|
customiseEditor: (editor => {
|
||||||
|
editor.on('focus', () => {
|
||||||
|
if (editor.getValue() == 'user.') {
|
||||||
|
// TODO this weirdly only works on the first click
|
||||||
|
(editor as any).completer?.showPopup(editor);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
testId('rule-userattr-attr'),
|
testId('rule-userattr-attr'),
|
||||||
),
|
),
|
||||||
cssCell1(
|
cssCell1(
|
||||||
@ -1059,23 +1094,53 @@ class ObsUserAttributeRule extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getRule() {
|
public getRule() {
|
||||||
|
const fullCharId = this._charId.get().trim();
|
||||||
|
const strippedCharId = fullCharId.startsWith('user.') ?
|
||||||
|
fullCharId.substring('user.'.length) : fullCharId;
|
||||||
const spec = {
|
const spec = {
|
||||||
name: this._name.get(),
|
name: this._name.get(),
|
||||||
tableId: this._tableId.get(),
|
tableId: this._tableId.get(),
|
||||||
lookupColId: this._lookupColId.get(),
|
lookupColId: this._lookupColId.get(),
|
||||||
charId: this._charId.get(),
|
charId: strippedCharId,
|
||||||
};
|
};
|
||||||
for (const [prop, value] of Object.entries(spec)) {
|
for (const [prop, value] of Object.entries(spec)) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new UserError(`Invalid user attribute rule: ${prop} must be set`);
|
throw new UserError(`Invalid user attribute rule: ${prop} must be set`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this._getUserAttrError(fullCharId)) {
|
||||||
|
throw new UserError(`Invalid user attribute to look up`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: this._userAttr?.origRecord?.id,
|
id: this._userAttr?.origRecord?.id,
|
||||||
rulePos: this._userAttr?.origRecord?.rulePos as number|undefined,
|
rulePos: this._userAttr?.origRecord?.rulePos as number|undefined,
|
||||||
userAttributes: JSON.stringify(spec),
|
userAttributes: JSON.stringify(spec),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _setUserAttr(text: string) {
|
||||||
|
if (text === this._charId.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._charId.set(text);
|
||||||
|
this._userAttrError.set(this._getUserAttrError(text) || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getUserAttrError(text: string): string | null {
|
||||||
|
text = text.trim();
|
||||||
|
if (text.startsWith('user.LinkKey')) {
|
||||||
|
if (/user\.LinkKey\.\w+$/.test(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChoice = this._userAttrChoices.get().map(choice => choice.value).includes(text);
|
||||||
|
if (!isChoice) {
|
||||||
|
return 'Not a valid user attribute';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents one line of a RuleSet, a combination of an aclFormula and permissions to apply to
|
// Represents one line of a RuleSet, a combination of an aclFormula and permissions to apply to
|
||||||
@ -1089,7 +1154,7 @@ class ObsRulePart extends Disposable {
|
|||||||
|
|
||||||
// Rule-specific completions for editing the formula, e.g. "user.Email" or "rec.City".
|
// Rule-specific completions for editing the formula, e.g. "user.Email" or "rec.City".
|
||||||
private _completions = Computed.create<string[]>(this, (use) => [
|
private _completions = Computed.create<string[]>(this, (use) => [
|
||||||
...use(this._ruleSet.accessRules.userAttrChoices).map(opt => opt.label),
|
...use(this._ruleSet.accessRules.userAttrChoices).map(opt => opt.value),
|
||||||
...this._ruleSet.getValidColIds().map(colId => `rec.${colId}`),
|
...this._ruleSet.getValidColIds().map(colId => `rec.${colId}`),
|
||||||
...this._ruleSet.getValidColIds().map(colId => `newRec.${colId}`),
|
...this._ruleSet.getValidColIds().map(colId => `newRec.${colId}`),
|
||||||
]);
|
]);
|
||||||
|
Loading…
Reference in New Issue
Block a user