diff --git a/app/client/aclui/ACLFormulaEditor.ts b/app/client/aclui/ACLFormulaEditor.ts index 330e8c9e..b72d5c5d 100644 --- a/app/client/aclui/ACLFormulaEditor.ts +++ b/app/client/aclui/ACLFormulaEditor.ts @@ -71,6 +71,10 @@ export function aclFormulaEditor(options: ACLFormulaOptions) { return cssConditionInputAce( cssConditionInputAce.cls('-disabled', options.readOnly), + // ACE editor calls preventDefault on clicks into the scrollbar area, which prevents focus + // being set when the click happens to be into there. To ensure we can focus on such clicks + // anyway, listen to the mousedown event in the capture phase. + dom.on('mousedown', () => { editor.focus(); }, {useCapture: true}), dom.onDispose(() => editor.destroy()), editorElem, ); diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index bb8ba1b1..e4c88cb1 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -1118,6 +1118,7 @@ class ObsRulePart extends Disposable { } this._error = Computed.create(this, (use) => { return use(this._formulaError) || + this._warnInvalidColIds(use(this._formulaProperties).usedColIds) || ( !this._ruleSet.isLastCondition(use, this) && use(this._aclFormula) === '' && permissionSetToText(use(this._permissions)) !== '' ? @@ -1231,6 +1232,7 @@ class ObsRulePart extends Disposable { if (text === this._aclFormula.get()) { return; } this._aclFormula.set(text); this._checkPending.set(true); + this._formulaProperties.set({}); this._formulaError.set(''); try { this._formulaProperties.set(await this._ruleSet.accessRules.checkAclFormula(text)); @@ -1241,6 +1243,15 @@ class ObsRulePart extends Disposable { this._checkPending.set(false); } } + + private _warnInvalidColIds(colIds?: string[]) { + if (!colIds || !colIds.length) { return false; } + const allValid = new Set(this._ruleSet.getValidColIds()); + const invalid = colIds.filter(c => !allValid.has(c)); + if (invalid.length > 0) { + return `Invalid columns: ${invalid.join(', ')}`; + } + } } /** diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index cb4e1f87..60a9b550 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -55,13 +55,15 @@ export type AclMatchFunc = (input: AclMatchInput) => boolean; /** * Representation of a parsed ACL formula. */ -export type ParsedAclFormula = [string, ...Array]; +type PrimitiveCellValue = number|string|boolean|null; +export type ParsedAclFormula = [string, ...Array]; /** * Observations about a formula. */ export interface FormulaProperties { hasRecOrNewRec?: boolean; + usedColIds?: string[]; } export interface UserAttributeRule { @@ -78,6 +80,9 @@ export interface UserAttributeRule { export function getFormulaProperties(formula: ParsedAclFormula) { const result: FormulaProperties = {} if (usesRec(formula)) { result.hasRecOrNewRec = true; } + const colIds = new Set(); + collectRecColIds(formula, colIds); + result.usedColIds = Array.from(colIds); return result; } @@ -86,11 +91,27 @@ export function getFormulaProperties(formula: ParsedAclFormula) { */ export function usesRec(formula: ParsedAclFormula): boolean { if (!Array.isArray(formula)) { throw new Error('expected a list'); } - if (formula[0] === 'Name' && (formula[1] === 'rec' || formula[1] === 'newRec')) { + if (isRecOrNewRec(formula)) { return true; } return formula.some(el => { if (!Array.isArray(el)) { return false; } - return usesRec(el as any); + return usesRec(el); }); } + +function isRecOrNewRec(formula: ParsedAclFormula|PrimitiveCellValue): boolean { + return Array.isArray(formula) && + formula[0] === 'Name' && + (formula[1] === 'rec' || formula[1] === 'newRec'); +} + +function collectRecColIds(formula: ParsedAclFormula, colIds: Set): void { + if (!Array.isArray(formula)) { throw new Error('expected a list'); } + if (formula[0] === 'Attr' && isRecOrNewRec(formula[1])) { + const colId = formula[2]; + colIds.add(String(colId)); + return; + } + formula.forEach(el => Array.isArray(el) && collectRecColIds(el, colIds)); +}