mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add a checkbox for owner "boss mode"
Summary: Implement a checkbox that grants owners full access to tables by default, when creating new table/column rules. * Checkbox appears above default rules. * When set, a rule giving owners full access will be inserted in any new rule set started for tables or columns. * The checkbox can be expanded to allow customization of the rules. https://gristlabs.getgrist.com/doc/check-ins/p/3#a1.s7.r2251.c19 Test Plan: added tests Reviewers: jarek Reviewed By: jarek Subscribers: anaisconce Differential Revision: https://phab.getgrist.com/D3756
This commit is contained in:
parent
b59829d57e
commit
e6692c2793
@ -37,7 +37,7 @@ import {
|
|||||||
UserAttributeRule
|
UserAttributeRule
|
||||||
} from 'app/common/GranularAccessClause';
|
} from 'app/common/GranularAccessClause';
|
||||||
import {isHiddenCol} from 'app/common/gristTypes';
|
import {isHiddenCol} from 'app/common/gristTypes';
|
||||||
import {isNonNullish} from 'app/common/gutil';
|
import {isNonNullish, unwrap} from 'app/common/gutil';
|
||||||
import {SchemaTypes} from 'app/common/schema';
|
import {SchemaTypes} from 'app/common/schema';
|
||||||
import {MetaRowRecord} from 'app/common/TableData';
|
import {MetaRowRecord} from 'app/common/TableData';
|
||||||
import {
|
import {
|
||||||
@ -98,7 +98,10 @@ export class AccessRules extends Disposable {
|
|||||||
private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
|
private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
|
||||||
|
|
||||||
// Special document-level rules, for resources of the form ("*SPECIAL:<RuleType>").
|
// Special document-level rules, for resources of the form ("*SPECIAL:<RuleType>").
|
||||||
private _specialRules = Observable.create<SpecialRules|null>(this, null);
|
// These rules are shown in different places - currently most are shown as a separate
|
||||||
|
// section, and one is folded into the default rule section (for SeedRule).
|
||||||
|
private _specialRulesWithDefault = Observable.create<SpecialRules|null>(this, null);
|
||||||
|
private _specialRulesSeparate = Observable.create<SpecialRules|null>(this, null);
|
||||||
|
|
||||||
// Array of all UserAttribute rules.
|
// Array of all UserAttribute rules.
|
||||||
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
|
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
|
||||||
@ -126,7 +129,8 @@ export class AccessRules extends Disposable {
|
|||||||
this._ruleStatus = Computed.create(this, (use) => {
|
this._ruleStatus = Computed.create(this, (use) => {
|
||||||
const defRuleSet = use(this._docDefaultRuleSet);
|
const defRuleSet = use(this._docDefaultRuleSet);
|
||||||
const tableRules = use(this._tableRules);
|
const tableRules = use(this._tableRules);
|
||||||
const specialRules = use(this._specialRules);
|
const specialRulesWithDefault = use(this._specialRulesWithDefault);
|
||||||
|
const specialRulesSeparate = use(this._specialRulesSeparate);
|
||||||
const userAttr = use(this._userAttrRules);
|
const userAttr = use(this._userAttrRules);
|
||||||
return Math.max(
|
return Math.max(
|
||||||
defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged,
|
defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged,
|
||||||
@ -136,7 +140,8 @@ export class AccessRules extends Disposable {
|
|||||||
getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),
|
getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),
|
||||||
...tableRules.map(tr => use(tr.ruleStatus)),
|
...tableRules.map(tr => use(tr.ruleStatus)),
|
||||||
...userAttr.map(u => use(u.ruleStatus)),
|
...userAttr.map(u => use(u.ruleStatus)),
|
||||||
specialRules ? use(specialRules.ruleStatus) : RuleStatus.Unchanged,
|
specialRulesWithDefault ? use(specialRulesWithDefault.ruleStatus) : RuleStatus.Unchanged,
|
||||||
|
specialRulesSeparate ? use(specialRulesSeparate.ruleStatus) : RuleStatus.Unchanged,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -211,10 +216,17 @@ export class AccessRules extends Disposable {
|
|||||||
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
|
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
|
||||||
);
|
);
|
||||||
|
|
||||||
SpecialRules.create(this._specialRules, SPECIAL_RULES_TABLE_ID, this,
|
const withDefaultRules = ['SeedRule'];
|
||||||
rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID),
|
const separateRules = ['FullCopies', 'AccessRules'];
|
||||||
rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID));
|
|
||||||
|
|
||||||
|
SpecialRules.create(
|
||||||
|
this._specialRulesWithDefault, SPECIAL_RULES_TABLE_ID, this,
|
||||||
|
filterRuleSets(withDefaultRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),
|
||||||
|
filterRuleSet(withDefaultRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));
|
||||||
|
SpecialRules.create(
|
||||||
|
this._specialRulesSeparate, SPECIAL_RULES_TABLE_ID, this,
|
||||||
|
filterRuleSets(separateRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),
|
||||||
|
filterRuleSet(separateRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));
|
||||||
DefaultObsRuleSet.create(this._docDefaultRuleSet, this, null, undefined, rules.getDocDefaultRuleSet());
|
DefaultObsRuleSet.create(this._docDefaultRuleSet, this, null, undefined, rules.getDocDefaultRuleSet());
|
||||||
this._userAttrRules.set(
|
this._userAttrRules.set(
|
||||||
Array.from(rules.getUserAttributeRules().values(), userAttr =>
|
Array.from(rules.getUserAttributeRules().values(), userAttr =>
|
||||||
@ -238,7 +250,8 @@ export class AccessRules extends Disposable {
|
|||||||
// Add/remove resources to have just the ones we need.
|
// Add/remove resources to have just the ones we need.
|
||||||
const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten(
|
const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten(
|
||||||
[{tableId: '*', colIds: '*'}],
|
[{tableId: '*', colIds: '*'}],
|
||||||
this._specialRules.get()?.getResources() || [],
|
this._specialRulesWithDefault.get()?.getResources() || [],
|
||||||
|
this._specialRulesSeparate.get()?.getResources() || [],
|
||||||
...this._tableRules.get().map(tr => tr.getResources()))
|
...this._tableRules.get().map(tr => tr.getResources()))
|
||||||
.map(r => ({id: -1, ...r}));
|
.map(r => ({id: -1, ...r}));
|
||||||
|
|
||||||
@ -395,6 +408,8 @@ export class AccessRules extends Disposable {
|
|||||||
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
|
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
|
||||||
cssSection(
|
cssSection(
|
||||||
cssSectionHeading(t("Default Rules"), testId('rule-table-header')),
|
cssSectionHeading(t("Default Rules"), testId('rule-table-header')),
|
||||||
|
dom.maybe(this._specialRulesWithDefault, tableRules => cssSeedRule(
|
||||||
|
tableRules.buildCheckBoxes())),
|
||||||
cssTableRounded(
|
cssTableRounded(
|
||||||
cssTableHeaderRow(
|
cssTableHeaderRow(
|
||||||
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
|
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
|
||||||
@ -412,7 +427,7 @@ export class AccessRules extends Disposable {
|
|||||||
),
|
),
|
||||||
testId('rule-table'),
|
testId('rule-table'),
|
||||||
),
|
),
|
||||||
dom.maybe(this._specialRules, tableRules => tableRules.buildDom()),
|
dom.maybe(this._specialRulesSeparate, tableRules => tableRules.buildDom()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -443,7 +458,8 @@ export class AccessRules extends Disposable {
|
|||||||
public getRules(): RuleRec[] {
|
public getRules(): RuleRec[] {
|
||||||
return flatten(
|
return flatten(
|
||||||
...this._tableRules.get().map(tr => tr.getRules()),
|
...this._tableRules.get().map(tr => tr.getRules()),
|
||||||
this._specialRules.get()?.getRules() || [],
|
this._specialRulesWithDefault.get()?.getRules() || [],
|
||||||
|
this._specialRulesSeparate.get()?.getRules() || [],
|
||||||
this._docDefaultRuleSet.get()?.getRules('*') || []
|
this._docDefaultRuleSet.get()?.getRules('*') || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -483,12 +499,20 @@ export class AccessRules extends Disposable {
|
|||||||
return this._aclResources.get(tableId)?.colIds.filter(id => !isHiddenCol(id)).sort();
|
return this._aclResources.get(tableId)?.colIds.filter(id => !isHiddenCol(id)).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get rules to use for seeding any new set of table/column rules, e.g. to give owners
|
||||||
|
// broad rights over the table/column contents.
|
||||||
|
public getSeedRules(): ObsRulePart[] {
|
||||||
|
return this._specialRulesWithDefault.get()?.getCustomRules('SeedRule') || [];
|
||||||
|
}
|
||||||
|
|
||||||
private _addTableRules(tableId: string) {
|
private _addTableRules(tableId: string) {
|
||||||
if (this._tableRules.get().some(tr => tr.tableId === tableId)) {
|
if (this._tableRules.get().some(tr => tr.tableId === tableId)) {
|
||||||
throw new Error(`Trying to add TableRules for existing table ${tableId}`);
|
throw new Error(`Trying to add TableRules for existing table ${tableId}`);
|
||||||
}
|
}
|
||||||
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
|
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
|
||||||
this._tableRules.push(TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet));
|
const tableRules = TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet);
|
||||||
|
this._tableRules.push(tableRules);
|
||||||
|
tableRules.addDefaultRules(this.getSeedRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _addUserAttributes() {
|
private _addUserAttributes() {
|
||||||
@ -617,6 +641,27 @@ class TableRules extends Disposable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom rules for the specific column. Used to gather the current
|
||||||
|
* setting of a special rule. Returns an empty list for unknown columns.
|
||||||
|
*/
|
||||||
|
public getCustomRules(colId: string): ObsRulePart[] {
|
||||||
|
for (const ruleSet of this._columnRuleSets.get()) {
|
||||||
|
if (ruleSet.getColIds() === colId) {
|
||||||
|
return ruleSet.getCustomRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the provided rules, copying their formula, permissions, and memo.
|
||||||
|
*/
|
||||||
|
public addDefaultRules(rules: ObsRulePart[]) {
|
||||||
|
const ruleSet = this._defaultRuleSet.get();
|
||||||
|
ruleSet?.addRuleParts(rules, {foldEveryoneRule: true});
|
||||||
|
}
|
||||||
|
|
||||||
public remove() {
|
public remove() {
|
||||||
this._accessRules.removeTableRules(this);
|
this._accessRules.removeTableRules(this);
|
||||||
}
|
}
|
||||||
@ -749,12 +794,15 @@ class TableRules extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _addColumnRuleSet() {
|
private _addColumnRuleSet() {
|
||||||
this._columnRuleSets.push(ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, []));
|
const ruleSet = ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, []);
|
||||||
|
this._columnRuleSets.push(ruleSet);
|
||||||
|
ruleSet.addRuleParts(this._accessRules.getSeedRules(), {foldEveryoneRule: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _addDefaultRuleSet() {
|
private _addDefaultRuleSet() {
|
||||||
if (!this._defaultRuleSet.get()) {
|
if (!this._defaultRuleSet.get()) {
|
||||||
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);
|
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);
|
||||||
|
this.addDefaultRules(this._accessRules.getSeedRules());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -762,13 +810,21 @@ class TableRules extends Disposable {
|
|||||||
class SpecialRules extends TableRules {
|
class SpecialRules extends TableRules {
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
return cssSection(
|
return cssSection(
|
||||||
cssSectionHeading(t("Special Rules"), testId('rule-table-header')),
|
cssSectionHeading(t('Special Rules'), testId('rule-table-header')),
|
||||||
this.buildColumnRuleSets(),
|
this.buildCheckBoxes(),
|
||||||
this.buildErrors(),
|
|
||||||
testId('rule-table'),
|
testId('rule-table'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build dom with checkboxes, without a section wrapping it.
|
||||||
|
// Used for folding a special rule into another section.
|
||||||
|
public buildCheckBoxes() {
|
||||||
|
return [
|
||||||
|
this.buildColumnRuleSets(),
|
||||||
|
this.buildErrors(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public getResources(): ResourceRec[] {
|
public getResources(): ResourceRec[] {
|
||||||
return this._columnRuleSets.get()
|
return this._columnRuleSets.get()
|
||||||
.filter(rs => !rs.hasOnlyBuiltInRules())
|
.filter(rs => !rs.hasOnlyBuiltInRules())
|
||||||
@ -796,18 +852,20 @@ abstract class ObsRuleSet extends Disposable {
|
|||||||
// ruleSet is omitted for a new ObsRuleSet added by the user.
|
// ruleSet is omitted for a new ObsRuleSet added by the user.
|
||||||
constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
|
constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
|
||||||
super();
|
super();
|
||||||
if (this._ruleSet) {
|
const parts = this._ruleSet?.body.map(part => ObsRulePart.create(this._body, this, part)) || [];
|
||||||
this._body.set(this._ruleSet.body.map(part => ObsRulePart.create(this._body, this, part)));
|
if (parts.length === 0) {
|
||||||
} else {
|
// If creating a new RuleSet, or if there are no rules,
|
||||||
// If creating a new RuleSet, start with just a default permission part.
|
// start with just a default permission part.
|
||||||
this._body.set([ObsRulePart.create(this._body, this, undefined)]);
|
parts.push(ObsRulePart.create(this._body, this, undefined));
|
||||||
}
|
}
|
||||||
|
this._body.set(parts);
|
||||||
|
|
||||||
this.ruleStatus = Computed.create(this, this._body, (use, body) => {
|
this.ruleStatus = Computed.create(this, this._body, (use, body) => {
|
||||||
// If anything was changed or added, some part.ruleStatus will be other than Unchanged. If
|
// If anything was changed or added, some part.ruleStatus will be other than Unchanged. If
|
||||||
// there were only removals, then body.length will have changed.
|
// there were only removals, then body.length will have changed.
|
||||||
|
// Ignore empty rules.
|
||||||
return Math.max(
|
return Math.max(
|
||||||
getChangedStatus(body.length < (this._ruleSet?.body?.length || 0)),
|
getChangedStatus(body.filter(part => !part.isEmpty(use)).length < (this._ruleSet?.body?.length || 0)),
|
||||||
...body.map(part => use(part.ruleStatus)));
|
...body.map(part => use(part.ruleStatus)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -873,10 +931,54 @@ abstract class ObsRuleSet extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public addRulePart(beforeRule: ObsRulePart|null) {
|
public addRulePart(beforeRule: ObsRulePart|null,
|
||||||
|
content?: RulePart,
|
||||||
|
isNew: boolean = false): ObsRulePart {
|
||||||
const body = this._body.get();
|
const body = this._body.get();
|
||||||
const i = beforeRule ? body.indexOf(beforeRule) : body.length;
|
const i = beforeRule ? body.indexOf(beforeRule) : body.length;
|
||||||
this._body.splice(i, 0, ObsRulePart.create(this._body, this, undefined));
|
const part = ObsRulePart.create(this._body, this, content, isNew);
|
||||||
|
this._body.splice(i, 0, part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a sequence of rules, taking priority over existing rules.
|
||||||
|
* optionally, if lowest-priority rule being added applies to
|
||||||
|
* everyone, and the existing rule also applies to everyone,
|
||||||
|
* fold those rules into one.
|
||||||
|
* This method is currently only called on newly created rule
|
||||||
|
* sets, so there's no need to check permissions and memos.
|
||||||
|
*/
|
||||||
|
public addRuleParts(newParts: ObsRulePart[], options: {foldEveryoneRule?: boolean}) {
|
||||||
|
// Check if we need to consider folding rules that apply to everyone.
|
||||||
|
if (options.foldEveryoneRule) {
|
||||||
|
const oldParts = this._body.get();
|
||||||
|
const myEveryonePart = (oldParts.length === 1 && !oldParts[0].getRulePart().aclFormula) ? oldParts[0] : null;
|
||||||
|
const newEveryonePart = newParts[newParts.length - 1]?.getRulePart().aclFormula ? null :
|
||||||
|
newParts[newParts.length - 1];
|
||||||
|
if (myEveryonePart && newEveryonePart) {
|
||||||
|
// It suffices to remove the existing rule that applies to everyone,
|
||||||
|
// which is just an empty default from rule set creation.
|
||||||
|
removeItem(this._body, myEveryonePart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const part of [...newParts].reverse()) {
|
||||||
|
const {permissionsText, aclFormula, memo} = part.getRulePart();
|
||||||
|
if (permissionsText === undefined || aclFormula === undefined) {
|
||||||
|
// Should not happen.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.addRulePart(
|
||||||
|
this.getFirst() || null,
|
||||||
|
{
|
||||||
|
aclFormula,
|
||||||
|
permissionsText,
|
||||||
|
permissions: parsePermissions(permissionsText),
|
||||||
|
memo,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -887,6 +989,11 @@ abstract class ObsRuleSet extends Disposable {
|
|||||||
return this._body.get().find(p => p.isBuiltIn());
|
return this._body.get().find(p => p.isBuiltIn());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get first rule part, built-in or not.
|
||||||
|
public getFirst(): ObsRulePart|undefined {
|
||||||
|
return this._body.get()[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When an empty-condition RulePart is the only part of a RuleSet, we can say it applies to
|
* When an empty-condition RulePart is the only part of a RuleSet, we can say it applies to
|
||||||
* "Everyone".
|
* "Everyone".
|
||||||
@ -939,6 +1046,11 @@ abstract class ObsRuleSet extends Disposable {
|
|||||||
public hasOnlyBuiltInRules() {
|
public hasOnlyBuiltInRules() {
|
||||||
return this._body.get().every(rule => rule.isBuiltIn());
|
return this._body.get().every(rule => rule.isBuiltIn());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get rule parts that are neither built-in nor empty.
|
||||||
|
public getCustomRules(): ObsRulePart[] {
|
||||||
|
return this._body.get().filter(rule => !rule.isBuiltInOrEmpty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColumnObsRuleSet extends ObsRuleSet {
|
class ColumnObsRuleSet extends ObsRuleSet {
|
||||||
@ -1006,58 +1118,91 @@ class DefaultObsRuleSet extends ObsRuleSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpecialRuleDescription(type: string): string {
|
/**
|
||||||
switch (type) {
|
* Properties we need to know about how a special rule should function and
|
||||||
case 'AccessRules':
|
* be rendered.
|
||||||
return t("Allow everyone to view Access Rules.");
|
*/
|
||||||
case 'FullCopies':
|
interface SpecialRuleProperties {
|
||||||
return t(`Allow everyone to copy the entire document, or view it in full in fiddle mode.
|
description: string;
|
||||||
Useful for examples and templates, but not for sensitive data.`);
|
name: string;
|
||||||
default: return type;
|
availableBits: PermissionKey[];
|
||||||
}
|
permissions: string;
|
||||||
|
formula: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpecialRuleName(type: string): string {
|
const specialRuleProperties: Record<string, SpecialRuleProperties> = {
|
||||||
switch (type) {
|
AccessRules: {
|
||||||
case 'AccessRules': return t("Permission to view Access Rules");
|
name: t('Permission to view Access Rules'),
|
||||||
case 'FullCopies': return t("Permission to access the document in full when needed");
|
description: t('Allow everyone to view Access Rules.'),
|
||||||
default: return type;
|
availableBits: ['read'],
|
||||||
}
|
permissions: '+R',
|
||||||
|
formula: 'True',
|
||||||
|
},
|
||||||
|
FullCopies: {
|
||||||
|
name: t('Permission to access the document in full when needed'),
|
||||||
|
description: t(`Allow everyone to copy the entire document, or view it in full in fiddle mode.
|
||||||
|
Useful for examples and templates, but not for sensitive data.`),
|
||||||
|
availableBits: ['read'],
|
||||||
|
permissions: '+R',
|
||||||
|
formula: 'True',
|
||||||
|
},
|
||||||
|
SeedRule: {
|
||||||
|
name: t('Seed rules'),
|
||||||
|
description: t('When adding table rules, automatically add a rule to grant OWNER full access.'),
|
||||||
|
availableBits: ['read', 'create', 'update', 'delete'],
|
||||||
|
permissions: '+CRUD',
|
||||||
|
formula: 'user.Access in [OWNER]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSpecialRuleProperties(name: string): SpecialRuleProperties {
|
||||||
|
return specialRuleProperties[name] || {
|
||||||
|
...specialRuleProperties.AccessRules,
|
||||||
|
name,
|
||||||
|
description: name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class SpecialObsRuleSet extends ColumnObsRuleSet {
|
class SpecialObsRuleSet extends ColumnObsRuleSet {
|
||||||
|
private _isExpanded = Observable.create<boolean>(this, false);
|
||||||
|
|
||||||
|
public get props() {
|
||||||
|
return getSpecialRuleProperties(this.getColIds());
|
||||||
|
}
|
||||||
|
|
||||||
public buildRuleSetDom() {
|
public buildRuleSetDom() {
|
||||||
const isNonStandard: Observable<boolean> = Computed.create(null, this._body, (use, body) =>
|
const isNonStandard: Observable<boolean> = Computed.create(null, this._body, (use, body) =>
|
||||||
!body.every(rule => rule.isBuiltIn() || rule.matches(use, 'True', '+R')));
|
!body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));
|
||||||
|
|
||||||
const allowEveryone: Observable<boolean> = Computed.create(null, this._body,
|
const allowEveryone: Observable<boolean> = Computed.create(null, this._body,
|
||||||
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltIn()))
|
(use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))
|
||||||
.onWrite(val => this._allowEveryone(val));
|
.onWrite(val => this._allowEveryone(val));
|
||||||
|
|
||||||
const isExpanded = Observable.create<boolean>(null, isNonStandard.get());
|
if (isNonStandard.get()) {
|
||||||
|
this._isExpanded.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
return dom('div',
|
return dom('div',
|
||||||
dom.autoDispose(isExpanded),
|
|
||||||
dom.autoDispose(allowEveryone),
|
dom.autoDispose(allowEveryone),
|
||||||
cssRuleDescription(
|
cssRuleDescription(
|
||||||
{style: 'white-space: pre-line;'}, // preserve line breaks in long descriptions
|
{style: 'white-space: pre-line;'}, // preserve line breaks in long descriptions
|
||||||
cssIconButton(icon('Expand'),
|
cssIconButton(icon('Expand'),
|
||||||
dom.style('transform', (use) => use(isExpanded) ? 'rotate(90deg)' : ''),
|
dom.style('transform', (use) => use(this._isExpanded) ? 'rotate(90deg)' : ''),
|
||||||
dom.on('click', () => isExpanded.set(!isExpanded.get())),
|
dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
|
||||||
testId('rule-special-expand'),
|
testId('rule-special-expand'),
|
||||||
),
|
),
|
||||||
squareCheckbox(allowEveryone,
|
squareCheckbox(allowEveryone,
|
||||||
dom.prop('disabled', isNonStandard),
|
dom.prop('disabled', isNonStandard),
|
||||||
testId('rule-special-checkbox'),
|
testId('rule-special-checkbox'),
|
||||||
),
|
),
|
||||||
getSpecialRuleDescription(this.getColIds()),
|
this.props.description,
|
||||||
),
|
),
|
||||||
dom.maybe(isExpanded, () =>
|
dom.maybe(this._isExpanded, () =>
|
||||||
cssTableRounded(
|
cssTableRounded(
|
||||||
{style: 'margin-left: 56px'},
|
{style: 'margin-left: 56px'},
|
||||||
cssTableHeaderRow(
|
cssTableHeaderRow(
|
||||||
cssCellIcon(),
|
cssCellIcon(),
|
||||||
cssCell4(cssColHeaderCell(getSpecialRuleName(this.getColIds()))),
|
cssCell4(cssColHeaderCell(this.props.name)),
|
||||||
cssCell1(cssColHeaderCell('Permissions')),
|
cssCell1(cssColHeaderCell('Permissions')),
|
||||||
cssCellIconWithMargins(),
|
cssCellIconWithMargins(),
|
||||||
cssCellIcon(),
|
cssCellIcon(),
|
||||||
@ -1065,6 +1210,19 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
|||||||
cssTableRow(
|
cssTableRow(
|
||||||
cssRuleBody.cls(''),
|
cssRuleBody.cls(''),
|
||||||
dom.forEach(this._body, part => part.buildRulePartDom(true)),
|
dom.forEach(this._body, part => part.buildRulePartDom(true)),
|
||||||
|
dom.maybe(use => !this.hasDefaultCondition(use), () =>
|
||||||
|
cssColumnGroup(
|
||||||
|
{style: 'min-height: 28px'},
|
||||||
|
cssCellIcon(
|
||||||
|
cssIconButton(
|
||||||
|
icon('Plus'),
|
||||||
|
dom.on('click', () => this.addRulePart(null)),
|
||||||
|
testId('rule-add'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
testId('rule-extra-add'),
|
||||||
|
)
|
||||||
|
),
|
||||||
),
|
),
|
||||||
testId('rule-set'),
|
testId('rule-set'),
|
||||||
)
|
)
|
||||||
@ -1075,20 +1233,31 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getAvailableBits(): PermissionKey[] {
|
public getAvailableBits(): PermissionKey[] {
|
||||||
return ['read'];
|
return this.props.availableBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeRulePart(rulePart: ObsRulePart) {
|
||||||
|
removeItem(this._body, rulePart);
|
||||||
|
if (this._body.get().length === 0) {
|
||||||
|
this._isExpanded.set(false);
|
||||||
|
this._allowEveryone(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _allowEveryone(value: boolean) {
|
private _allowEveryone(value: boolean) {
|
||||||
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
|
const builtInRules = this._body.get().filter(r => r.isBuiltIn());
|
||||||
if (value === true) {
|
if (value) {
|
||||||
const rulePart: RulePart = {
|
const rulePart: RulePart = {
|
||||||
aclFormula: 'True',
|
aclFormula: this.props.formula,
|
||||||
permissionsText: '+R',
|
permissionsText: this.props.permissions,
|
||||||
permissions: parsePermissions('+R'),
|
permissions: parsePermissions(this.props.permissions),
|
||||||
};
|
};
|
||||||
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
|
this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);
|
||||||
} else if (value === false) {
|
} else {
|
||||||
this._body.set(builtInRules);
|
this._body.set(builtInRules);
|
||||||
|
if (builtInRules.length === 0) {
|
||||||
|
this._body.push(ObsRulePart.create(this._body, this, undefined));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1307,16 +1476,15 @@ class ObsRulePart extends Disposable {
|
|||||||
super();
|
super();
|
||||||
this._memo = Observable.create(this, _rulePart?.memo ?? '');
|
this._memo = Observable.create(this, _rulePart?.memo ?? '');
|
||||||
|
|
||||||
// If this rule has a blank memo, don't show the editor.
|
|
||||||
this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== '');
|
|
||||||
|
|
||||||
|
|
||||||
if (_rulePart && isNew) {
|
if (_rulePart && isNew) {
|
||||||
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to
|
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to
|
||||||
// treat the rule as new and only use the rulePart for its initialization.
|
// treat the rule as new and only use the rulePart for its initialization.
|
||||||
this._rulePart = undefined;
|
this._rulePart = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this rule has a blank memo, don't show the editor.
|
||||||
|
this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== '');
|
||||||
|
|
||||||
this._error = Computed.create(this, (use) => {
|
this._error = Computed.create(this, (use) => {
|
||||||
return use(this._formulaError) ||
|
return use(this._formulaError) ||
|
||||||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
|
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
|
||||||
@ -1327,13 +1495,14 @@ class ObsRulePart extends Disposable {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emptyPerms = emptyPermissionSet();
|
||||||
this.ruleStatus = Computed.create(this, (use) => {
|
this.ruleStatus = Computed.create(this, (use) => {
|
||||||
if (use(this._error)) { return RuleStatus.Invalid; }
|
if (use(this._error)) { return RuleStatus.Invalid; }
|
||||||
if (use(this._checkPending)) { return RuleStatus.CheckPending; }
|
if (use(this._checkPending)) { return RuleStatus.CheckPending; }
|
||||||
return getChangedStatus(
|
return getChangedStatus(
|
||||||
use(this._aclFormula) !== this._rulePart?.aclFormula ||
|
use(this._aclFormula) !== (this._rulePart?.aclFormula ?? '') ||
|
||||||
use(this._memo) !== (this._rulePart?.memo ?? '') ||
|
use(this._memo) !== (this._rulePart?.memo ?? '') ||
|
||||||
!isEqual(use(this._permissions), this._rulePart?.permissions)
|
!isEqual(use(this._permissions), this._rulePart?.permissions ?? emptyPerms)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1397,7 +1566,7 @@ class ObsRulePart extends Disposable {
|
|||||||
return (
|
return (
|
||||||
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
|
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
|
||||||
this._ruleSet.isLastCondition(use, this) ? t('Everyone Else') :
|
this._ruleSet.isLastCondition(use, this) ? t('Everyone Else') :
|
||||||
t('EnterCondition')
|
t('Enter Condition')
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
getSuggestions: (prefix) => this._completions.get(),
|
getSuggestions: (prefix) => this._completions.get(),
|
||||||
@ -1475,6 +1644,17 @@ class ObsRulePart extends Disposable {
|
|||||||
return this._rulePart ? !this._rulePart.origRecord?.id : false;
|
return this._rulePart ? !this._rulePart.origRecord?.id : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return true if formula, permissions, and memo are all empty.
|
||||||
|
public isEmpty(use: UseCB = unwrap): boolean {
|
||||||
|
return use(this._aclFormula) === '' &&
|
||||||
|
isEqual(use(this._permissions), emptyPermissionSet()) &&
|
||||||
|
use(this._memo) === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public isBuiltInOrEmpty(use: UseCB = unwrap): boolean {
|
||||||
|
return this.isBuiltIn() || this.isEmpty(use);
|
||||||
|
}
|
||||||
|
|
||||||
private _isNonFirstBuiltIn(): boolean {
|
private _isNonFirstBuiltIn(): boolean {
|
||||||
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
|
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
|
||||||
}
|
}
|
||||||
@ -1605,6 +1785,22 @@ function getAclFormulaProperties(part?: RulePart): FormulaProperties {
|
|||||||
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
|
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return a rule set if it applies to one of the specified columns.
|
||||||
|
function filterRuleSet(colIds: string[], ruleSet?: RuleSet): RuleSet|undefined {
|
||||||
|
if (!ruleSet) { return undefined; }
|
||||||
|
if (ruleSet.colIds === '*') { return ruleSet; }
|
||||||
|
for (const colId of ruleSet.colIds) {
|
||||||
|
if (colIds.includes(colId)) { return ruleSet; }
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter an array of rule sets for just those that apply to one of the specified
|
||||||
|
// columns.
|
||||||
|
function filterRuleSets(colIds: string[], ruleSets: RuleSet[]): RuleSet[] {
|
||||||
|
return ruleSets.map(ruleSet => filterRuleSet(colIds, ruleSet)).filter(rs => rs) as RuleSet[];
|
||||||
|
}
|
||||||
|
|
||||||
const cssOuter = styled('div', `
|
const cssOuter = styled('div', `
|
||||||
flex: auto;
|
flex: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -1798,3 +1994,7 @@ const cssMemoIcon = styled(icon, `
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssSeedRule = styled('div', `
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`);
|
||||||
|
@ -15,6 +15,9 @@ import {makeT} from 'app/client/lib/localization';
|
|||||||
// One of the strings 'read', 'update', etc.
|
// One of the strings 'read', 'update', etc.
|
||||||
export type PermissionKey = keyof PartialPermissionSet;
|
export type PermissionKey = keyof PartialPermissionSet;
|
||||||
|
|
||||||
|
// Canonical order of permission bits when rendered in a permissionsWidget.
|
||||||
|
const PERMISSION_BIT_ORDER = 'RUCDS';
|
||||||
|
|
||||||
const t = makeT('PermissionsWidget');
|
const t = makeT('PermissionsWidget');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,6 +29,7 @@ export function permissionsWidget(
|
|||||||
options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void},
|
options: {disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void},
|
||||||
...args: DomElementArg[]
|
...args: DomElementArg[]
|
||||||
) {
|
) {
|
||||||
|
availableBits = sortBits(availableBits);
|
||||||
// These are the permission sets available to set via the dropdown.
|
// These are the permission sets available to set via the dropdown.
|
||||||
const empty: PartialPermissionSet = emptyPermissionSet();
|
const empty: PartialPermissionSet = emptyPermissionSet();
|
||||||
const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow');
|
const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow');
|
||||||
@ -125,6 +129,20 @@ function psetDescription(permissionSet: PartialPermissionSet): string {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort the bits in a standard way for viewing, since they could be in any order
|
||||||
|
* in the underlying rule store. And in fact ACLPermissions.permissionSetToText
|
||||||
|
* uses an order (CRUDS) that is different from how things have been historically
|
||||||
|
* rendered in the UI (RUCDS).
|
||||||
|
*/
|
||||||
|
function sortBits(bits: PermissionKey[]) {
|
||||||
|
return bits.sort((a, b) => {
|
||||||
|
const aIndex = PERMISSION_BIT_ORDER.indexOf(a.slice(0, 1).toUpperCase());
|
||||||
|
const bIndex = PERMISSION_BIT_ORDER.indexOf(b.slice(0, 1).toUpperCase());
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cssPermissions = styled('div', `
|
const cssPermissions = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
@ -20,7 +20,7 @@ import {UserAction} from 'app/common/DocActions';
|
|||||||
import {Computed, dom, fromKo, Observable} from 'grainjs';
|
import {Computed, dom, fromKo, Observable} from 'grainjs';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
|
||||||
const t = makeT('TypeTransformation');
|
const t = makeT('TypeTransform');
|
||||||
|
|
||||||
// To simplify diff (avoid rearranging methods to satisfy private/public order).
|
// To simplify diff (avoid rearranging methods to satisfy private/public order).
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
@ -64,6 +64,11 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
|
|||||||
permissions: parsePermissions('-R'),
|
permissions: parsePermissions('-R'),
|
||||||
permissionsText: '-R',
|
permissionsText: '-R',
|
||||||
}],
|
}],
|
||||||
|
},
|
||||||
|
SeedRule: {
|
||||||
|
tableId: SPECIAL_RULES_TABLE_ID,
|
||||||
|
colIds: ['SeedRule'],
|
||||||
|
body: [],
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -179,9 +184,13 @@ export class ACLRuleCollection {
|
|||||||
const specialType = String(ruleSet.colIds);
|
const specialType = String(ruleSet.colIds);
|
||||||
const specialDefault = specialRuleSets.get(specialType);
|
const specialDefault = specialRuleSets.get(specialType);
|
||||||
if (!specialDefault) {
|
if (!specialDefault) {
|
||||||
throw new Error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);
|
// Log that we are seeing an invalid rule, but don't fail.
|
||||||
|
// (Historically, older versions of the Grist app will attempt to
|
||||||
|
// open newer documents).
|
||||||
|
options.log.error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);
|
||||||
|
} else {
|
||||||
|
specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]});
|
||||||
}
|
}
|
||||||
specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,7 +134,13 @@ export class MinIOExternalStorage implements ExternalStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isFatalError(err: any) {
|
public isFatalError(err: any) {
|
||||||
return err.code !== 'NotFound' && err.code !== 'NoSuchKey';
|
// ECONNRESET should not count as fatal:
|
||||||
|
// https://github.com/aws/aws-sdk-js/pull/3739
|
||||||
|
// Likewise for "We encountered an internal error. Please try again."
|
||||||
|
// These are errors associated with the AWS S3 backend, and which
|
||||||
|
// the AWS S3 SDK would typically handle.
|
||||||
|
return err.code !== 'NotFound' && err.code !== 'NoSuchKey' &&
|
||||||
|
err.code !== 'ECONNRESET' && err.code !== 'InternalError';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async close() {
|
public async close() {
|
||||||
|
@ -18,11 +18,13 @@ const parser = new Parser({
|
|||||||
nsSeparator: null,
|
nsSeparator: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function* walk(dir) {
|
async function* walk(dirs) {
|
||||||
for await (const d of await fs.promises.opendir(dir)) {
|
for (const dir of dirs) {
|
||||||
const entry = path.join(dir, d.name);
|
for await (const d of await fs.promises.opendir(dir)) {
|
||||||
if (d.isDirectory()) yield* walk(entry);
|
const entry = path.join(dir, d.name);
|
||||||
else if (d.isFile()) yield entry;
|
if (d.isDirectory()) yield* walk([entry]);
|
||||||
|
else if (d.isFile()) yield entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +40,15 @@ const customHandler = (fileName) => (key, options) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sort(obj) {
|
||||||
|
if (typeof obj !== "object" || Array.isArray(obj))
|
||||||
|
return obj;
|
||||||
|
const sortedObject = {};
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
keys.forEach(key => sortedObject[key] = sort(obj[key]));
|
||||||
|
return sortedObject;
|
||||||
|
}
|
||||||
|
|
||||||
const getKeysFromFile = (filePath, fileName) => {
|
const getKeysFromFile = (filePath, fileName) => {
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
parser.parseFuncFromString(
|
parser.parseFuncFromString(
|
||||||
@ -54,19 +65,20 @@ const getKeysFromFile = (filePath, fileName) => {
|
|||||||
return keys;
|
return keys;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function walkTranslation(dirPath) {
|
async function walkTranslation(dirs) {
|
||||||
for await (const p of walk(dirPath)) {
|
for await (const p of walk(dirs)) {
|
||||||
const { name } = path.parse(p);
|
const { name } = path.parse(p);
|
||||||
|
if (p.endsWith('.map')) { continue; }
|
||||||
getKeysFromFile(p, name);
|
getKeysFromFile(p, name);
|
||||||
}
|
}
|
||||||
const keys = parser.get({ sort: true });
|
const keys = parser.get({ sort: true });
|
||||||
const newTranslations = _.merge(keys.en.translation, englishKeys);
|
const newTranslations = _.merge(keys.en.translation, englishKeys);
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
"static/locales/en.client.json",
|
"static/locales/en.client.json",
|
||||||
JSON.stringify(newTranslations, null, 2),
|
JSON.stringify(sort(newTranslations), null, 2),
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
walkTranslation("app/client");
|
walkTranslation(["_build/app/client", ...process.argv.slice(2)]);
|
||||||
|
Loading…
Reference in New Issue
Block a user