mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Implement checkbox for SchemaEdit permission in Access Rules UI.
Summary: - Introduces a fictitious "*SPECIAL:SchemaEdit" resource in UI only. - Hides "S" bit for the default rule section. - Shows a checkbox UI similar to other checkboxes, with an additional dismissable warning. Test Plan: Added a browser test Reviewers: paulfitz, georgegevoian Reviewed By: paulfitz, georgegevoian Differential Revision: https://phab.getgrist.com/D3765
This commit is contained in:
@@ -26,7 +26,7 @@ import {
|
||||
summarizePermissions,
|
||||
summarizePermissionSet
|
||||
} from 'app/common/ACLPermissions';
|
||||
import {ACLRuleCollection, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
|
||||
import {ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
|
||||
import {AclRuleProblem, AclTableDescription, getTableTitle} from 'app/common/ActiveDocAPI';
|
||||
import {BulkColValues, getColValues, RowRecord, UserAction} from 'app/common/DocActions';
|
||||
import {
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Computed,
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
DomElementArg,
|
||||
IDisposableOwner,
|
||||
MutableObsArray,
|
||||
@@ -201,7 +202,7 @@ export class AccessRules extends Disposable {
|
||||
const rules = this._ruleCollection;
|
||||
|
||||
const [ , , aclResources] = await Promise.all([
|
||||
rules.update(this._gristDoc.docData, {log: console}),
|
||||
rules.update(this._gristDoc.docData, {log: console, pullOutSchemaEdit: true}),
|
||||
this._updateDocAccessData(),
|
||||
this._gristDoc.docComm.getAclResources(),
|
||||
]);
|
||||
@@ -217,7 +218,7 @@ export class AccessRules extends Disposable {
|
||||
);
|
||||
|
||||
const withDefaultRules = ['SeedRule'];
|
||||
const separateRules = ['FullCopies', 'AccessRules'];
|
||||
const separateRules = ['SchemaEdit', 'FullCopies', 'AccessRules'];
|
||||
|
||||
SpecialRules.create(
|
||||
this._specialRulesWithDefault, SPECIAL_RULES_TABLE_ID, this,
|
||||
@@ -252,12 +253,20 @@ export class AccessRules extends Disposable {
|
||||
[{tableId: '*', colIds: '*'}],
|
||||
this._specialRulesWithDefault.get()?.getResources() || [],
|
||||
this._specialRulesSeparate.get()?.getResources() || [],
|
||||
...this._tableRules.get().map(tr => tr.getResources()))
|
||||
.map(r => ({id: -1, ...r}));
|
||||
...this._tableRules.get().map(tr => tr.getResources())
|
||||
)
|
||||
// Skip the fake "*SPECIAL:SchemaEdit" resource (frontend-specific); these rules are saved to the default resource.
|
||||
.filter(resource => !isSchemaEditResource(resource))
|
||||
.map(r => ({id: -1, ...r}));
|
||||
|
||||
// Prepare userActions and a mapping of serializedResource to rowIds.
|
||||
const resourceSync = syncRecords(resourcesTable, newResources, serializeResource);
|
||||
|
||||
const defaultResourceRowId = resourceSync.rowIdMap.get(serializeResource({id: -1, tableId: '*', colIds: '*'}));
|
||||
if (!defaultResourceRowId) {
|
||||
throw new Error('Default resource missing in resource map');
|
||||
}
|
||||
|
||||
// For syncing rules, we'll go by rowId that we store with each RulePart and with the RuleSet.
|
||||
const newRules: RowRecord[] = [];
|
||||
for (const rule of this.getRules()) {
|
||||
@@ -267,10 +276,16 @@ export class AccessRules extends Disposable {
|
||||
}
|
||||
|
||||
// Look up the rowId for the resource.
|
||||
const resourceKey = serializeResource(rule.resourceRec as RowRecord);
|
||||
const resourceRowId = resourceSync.rowIdMap.get(resourceKey);
|
||||
if (!resourceRowId) {
|
||||
throw new Error(`Resource missing in resource map: ${resourceKey}`);
|
||||
let resourceRowId: number|undefined;
|
||||
// Assign the rules for the fake "*SPECIAL:SchemaEdit" resource to the default resource where they belong.
|
||||
if (isSchemaEditResource(rule.resourceRec!)) {
|
||||
resourceRowId = defaultResourceRowId;
|
||||
} else {
|
||||
const resourceKey = serializeResource(rule.resourceRec as RowRecord);
|
||||
resourceRowId = resourceSync.rowIdMap.get(resourceKey);
|
||||
if (!resourceRowId) {
|
||||
throw new Error(`Resource missing in resource map: ${resourceKey}`);
|
||||
}
|
||||
}
|
||||
newRules.push({
|
||||
id: rule.id || -1,
|
||||
@@ -283,10 +298,6 @@ export class AccessRules extends Disposable {
|
||||
}
|
||||
|
||||
// UserAttribute rules are listed in the same rulesTable.
|
||||
const defaultResourceRowId = resourceSync.rowIdMap.get(serializeResource({id: -1, tableId: '*', colIds: '*'}));
|
||||
if (!defaultResourceRowId) {
|
||||
throw new Error('Default resource missing in resource map');
|
||||
}
|
||||
for (const userAttr of this._userAttrRules.get()) {
|
||||
const rule = userAttr.getRule();
|
||||
newRules.push({
|
||||
@@ -829,7 +840,12 @@ class SpecialRules extends TableRules {
|
||||
owner: IDisposableOwner, accessRules: AccessRules, tableRules: TableRules,
|
||||
ruleSet: RuleSet|undefined, initialColIds: string[],
|
||||
): ColumnObsRuleSet {
|
||||
return SpecialObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
|
||||
if (isEqual(ruleSet?.colIds, ['SchemaEdit'])) {
|
||||
// The special rule for "schemaEdit" permissions.
|
||||
return SpecialSchemaObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
|
||||
} else {
|
||||
return SpecialObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,12 +1030,7 @@ abstract class ObsRuleSet extends Disposable {
|
||||
* Which permission bits to allow the user to set.
|
||||
*/
|
||||
public getAvailableBits(): PermissionKey[] {
|
||||
if (this._tableRules) {
|
||||
return ['read', 'update', 'create', 'delete'];
|
||||
} else {
|
||||
// For the doc-wide rule set, expose the schemaEdit bit too.
|
||||
return ['read', 'update', 'create', 'delete', 'schemaEdit'];
|
||||
}
|
||||
return ['read', 'update', 'create', 'delete'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1112,18 +1123,32 @@ class DefaultObsRuleSet extends ObsRuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
interface SpecialRuleBody {
|
||||
permissions: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties we need to know about how a special rule should function and
|
||||
* be rendered.
|
||||
*/
|
||||
interface SpecialRuleProperties {
|
||||
interface SpecialRuleProperties extends SpecialRuleBody {
|
||||
description: string;
|
||||
name: string;
|
||||
availableBits: PermissionKey[];
|
||||
permissions: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
const schemaEditRules: {[key: string]: SpecialRuleBody} = {
|
||||
allowEditors: {
|
||||
permissions: '+S',
|
||||
formula: 'user.Access == EDITOR',
|
||||
},
|
||||
denyEditors: {
|
||||
permissions: '-S',
|
||||
formula: 'user.Access != OWNER',
|
||||
},
|
||||
};
|
||||
|
||||
const specialRuleProperties: Record<string, SpecialRuleProperties> = {
|
||||
AccessRules: {
|
||||
name: t('Permission to view Access Rules'),
|
||||
@@ -1147,6 +1172,13 @@ Useful for examples and templates, but not for sensitive data.`),
|
||||
permissions: '+CRUD',
|
||||
formula: 'user.Access in [OWNER]',
|
||||
},
|
||||
SchemaEdit: {
|
||||
name: t("Permission to edit document structure"),
|
||||
description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, " +
|
||||
"layouts), and to write formulas, which give access to all data regardless of read restrictions."),
|
||||
availableBits: ['schemaEdit'],
|
||||
...schemaEditRules.denyEditors,
|
||||
},
|
||||
};
|
||||
|
||||
function getSpecialRuleProperties(name: string): SpecialRuleProperties {
|
||||
@@ -1165,32 +1197,29 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
||||
}
|
||||
|
||||
public buildRuleSetDom() {
|
||||
const isNonStandard: Observable<boolean> = Computed.create(null, this._body, (use, body) =>
|
||||
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));
|
||||
|
||||
const allowEveryone: Observable<boolean> = Computed.create(null, this._body,
|
||||
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))
|
||||
.onWrite(val => this._allowEveryone(val));
|
||||
|
||||
const isNonStandard = this._createIsNonStandardObs();
|
||||
const isChecked = this._createIsCheckedObs(isNonStandard);
|
||||
if (isNonStandard.get()) {
|
||||
this._isExpanded.set(true);
|
||||
}
|
||||
|
||||
return dom('div',
|
||||
dom.autoDispose(allowEveryone),
|
||||
dom.autoDispose(isChecked),
|
||||
dom.autoDispose(isNonStandard),
|
||||
cssRuleDescription(
|
||||
{style: 'white-space: pre-line;'}, // preserve line breaks in long descriptions
|
||||
cssIconButton(icon('Expand'),
|
||||
dom.style('transform', (use) => use(this._isExpanded) ? 'rotate(90deg)' : ''),
|
||||
dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
|
||||
testId('rule-special-expand'),
|
||||
{style: 'margin: -4px'}, // subtract padding to align better.
|
||||
),
|
||||
squareCheckbox(allowEveryone,
|
||||
cssCheckbox(isChecked,
|
||||
dom.prop('disabled', isNonStandard),
|
||||
testId('rule-special-checkbox'),
|
||||
),
|
||||
this.props.description,
|
||||
),
|
||||
this._buildDomWarning(),
|
||||
dom.maybe(this._isExpanded, () =>
|
||||
cssTableRounded(
|
||||
{style: 'margin-left: 56px'},
|
||||
@@ -1238,14 +1267,29 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
protected _buildDomWarning(): DomContents {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Observable for whether this ruleSet is "standard", i.e. checked or unchecked state, without
|
||||
// any strange rules that need to be shown expanded with the checkbox greyed out.
|
||||
protected _createIsNonStandardObs(): Observable<boolean> {
|
||||
return Computed.create(null, this._body, (use, body) =>
|
||||
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));
|
||||
}
|
||||
|
||||
// Observable for whether the checkbox should be shown as checked. Writing to it will update
|
||||
// rules so as to toggle the checkbox.
|
||||
protected _createIsCheckedObs(isNonStandard: Observable<boolean>): Observable<boolean> {
|
||||
return Computed.create(null, this._body,
|
||||
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))
|
||||
.onWrite(val => this._allowEveryone(val));
|
||||
}
|
||||
|
||||
private _allowEveryone(value: boolean) {
|
||||
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
|
||||
if (value) {
|
||||
const rulePart: RulePart = {
|
||||
aclFormula: this.props.formula,
|
||||
permissionsText: this.props.permissions,
|
||||
permissions: parsePermissions(this.props.permissions),
|
||||
};
|
||||
const rulePart = makeRulePart(this.props);
|
||||
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
|
||||
} else {
|
||||
this._body.set(builtInRules);
|
||||
@@ -1256,6 +1300,63 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
function makeRulePart({permissions, formula}: SpecialRuleBody): RulePart {
|
||||
const rulePart: RulePart = {
|
||||
aclFormula: formula,
|
||||
permissionsText: permissions,
|
||||
permissions: parsePermissions(permissions),
|
||||
};
|
||||
return rulePart;
|
||||
}
|
||||
|
||||
/**
|
||||
* SchemaEdit permissions are moved out to a special fake resource "*SPECIAL:SchemaEdit" in the
|
||||
* frontend, to be presented under their own checkbox option. Its behaviors are a bit different
|
||||
* from other checkbox options; the differences are in the overridden methods here.
|
||||
*/
|
||||
class SpecialSchemaObsRuleSet extends SpecialObsRuleSet {
|
||||
protected _buildDomWarning(): DomContents {
|
||||
return dom.maybe(
|
||||
(use) => use(this._body).every(rule => rule.isBuiltInOrEmpty(use)),
|
||||
() => cssConditionError({style: 'margin-left: 56px; margin-bottom: 8px;'},
|
||||
"This default should be changed if editors' access is to be limited. ",
|
||||
dom('a', {style: 'color: inherit; text-decoration: underline'},
|
||||
'Dismiss', dom.on('click', () => this._allowEditors('confirm'))),
|
||||
testId('rule-schema-edit-warning'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// SchemaEdit rules support an extra "standard" state, where a no-op rule exists (explicit rule
|
||||
// allowing EDITORs SchemaEdit permission), in which case we don't show a warning.
|
||||
protected _createIsNonStandardObs(): Observable<boolean> {
|
||||
return Computed.create(null, this._body, (use, body) =>
|
||||
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)
|
||||
|| rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)));
|
||||
}
|
||||
|
||||
protected _createIsCheckedObs(isNonStandard: Observable<boolean>): Observable<boolean> {
|
||||
return Computed.create(null, this._body,
|
||||
(use, body) => body.every(rule => rule.isBuiltInOrEmpty(use)
|
||||
|| rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)))
|
||||
.onWrite(val => this._allowEditors(val));
|
||||
}
|
||||
|
||||
// The third "confirm" option is used by the "Dismiss" link in the warning.
|
||||
private _allowEditors(value: boolean|'confirm') {
|
||||
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
|
||||
if (value === 'confirm') {
|
||||
const rulePart = makeRulePart(schemaEditRules.allowEditors);
|
||||
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
|
||||
} else if (!value) {
|
||||
const rulePart = makeRulePart(schemaEditRules.denyEditors);
|
||||
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
|
||||
} else {
|
||||
this._body.set(builtInRules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ObsUserAttributeRule extends Disposable {
|
||||
public ruleStatus: Computed<RuleStatus>;
|
||||
|
||||
@@ -1943,9 +2044,14 @@ const cssRuleBody = styled('div', `
|
||||
const cssRuleDescription = styled('div', `
|
||||
color: ${theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: top;
|
||||
margin: 16px 0 8px 0;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
white-space: pre-line; /* preserve line breaks in long descriptions */
|
||||
`);
|
||||
|
||||
const cssCheckbox = styled(squareCheckbox, `
|
||||
flex: none;
|
||||
`);
|
||||
|
||||
const cssCellContent = styled('div', `
|
||||
|
||||
Reference in New Issue
Block a user