diff --git a/app/client/components/viewCommon.css b/app/client/components/viewCommon.css index 7cd6c1e4..eb50098b 100644 --- a/app/client/components/viewCommon.css +++ b/app/client/components/viewCommon.css @@ -78,6 +78,27 @@ background-color: unset; } +.field_clip.invalid.field-error-C { + background-color: unset; + color: var(--grist-color-dark-grey); + padding-left: 18px; +} + +.field_clip.invalid.field-error-C::before { + /* based on standard icon styles */ + content: ""; + position: absolute; + top: 4px; + left: 2px; + width: 14px; + height: 14px; + background-color: var(--grist-color-dark-grey); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-image: var(--icon-Lock); +} + .field_clip.field-error-U { color: #6363a2; background-color: unset; diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index b80f0f80..e9709667 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -152,7 +152,19 @@ export class FieldEditor extends Disposable { const column = this._field.column(); const cellCurrentValue = this._editRow.cells[this._field.colId()].peek(); - const cellValue = column.isFormula() ? column.formula() : cellCurrentValue; + let cellValue: CellValue; + if (column.isFormula()) { + cellValue = column.formula(); + } else if (Array.isArray(cellCurrentValue) && cellCurrentValue[0] === 'C') { + // This cell value is censored by access control rules + // Really the rules should also block editing, but in case they don't, show a blank value + // rather than a 'C'. However if the user tries to edit the cell and then clicks away + // without typing anything the empty string is saved, deleting what was there. + // We should probably just automatically block updates where reading is not allowed. + cellValue = ''; + } else { + cellValue = cellCurrentValue; + } // Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the // editor by typing into it (and overriding previous formula). In other cases (e.g. double-click), diff --git a/app/plugin/objtypes.ts b/app/plugin/objtypes.ts index b2a5a0f0..65150f98 100644 --- a/app/plugin/objtypes.ts +++ b/app/plugin/objtypes.ts @@ -109,6 +109,19 @@ export class SkipValue { } } +/** + * A placeholder for a value hidden by access control rules. + * Depending on the types of the columns involved, copying + * a censored value and pasting elsewhere will either use + * CensoredValue.__repr__ (python) or CensoredValue.toString (typescript) + * so they should match + */ +export class CensoredValue { + public toString() { + return 'CENSORED'; + } +} + /** * Produces a Grist-encoded version of the value, e.g. turning a Date into ['d', timestamp]. * Returns ['U', repr(value)] if it fails to encode otherwise. @@ -135,6 +148,8 @@ export function encodeObject(value: unknown): CellValue { // TODO Depending on how it's used, may want to return ['d', timestamp] for UTC midnight. return ['D', timestamp, 'UTC']; } + } else if (value instanceof CensoredValue) { + return ['C']; } else if (value instanceof RaisedException) { return ['E', value.name, value.message, value.details]; } else if (Array.isArray(value)) { @@ -172,6 +187,7 @@ export function decodeObject(value: CellValue): unknown { case 'P': return new PendingValue(); case 'R': return new Reference(String(args[0]), args[1]); case 'S': return new SkipValue(); + case 'C': return new CensoredValue(); case 'U': return new UnknownValue(args[0]); } } catch (e) { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 7a03cde8..0b3a7135 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -888,9 +888,9 @@ export class GranularAccess implements GranularAccessForBundle { if (colValues === undefined) { censorAt = () => 1; } else if (Array.isArray(action[2])) { - censorAt = (colId, idx) => (colValues as BulkColValues)[colId][idx] = 'CENSORED'; // TODO Pick a suitable value + censorAt = (colId, idx) => (colValues as BulkColValues)[colId][idx] = ['C']; // censored } else { - censorAt = (colId) => (colValues as ColValues)[colId] = 'CENSORED'; // TODO Pick a suitable value + censorAt = (colId) => (colValues as ColValues)[colId] = ['C']; // censored } // These map an index of a row in the action to its index in rowsBefore and in rowsAfter. diff --git a/sandbox/grist/objtypes.py b/sandbox/grist/objtypes.py index 5a97598e..af55c398 100644 --- a/sandbox/grist/objtypes.py +++ b/sandbox/grist/objtypes.py @@ -105,6 +105,17 @@ class UnmarshallableValue(object): # document was just migrated. _pending_sentinel = object() +# A placeholder for a value hidden by access control rules. +# Depending on the types of the columns involved, copying +# a censored value and pasting elsewhere will either use +# CensoredValue.__repr__ (python) or CensoredValue.toString (typescript) +# so they should match +class CensoredValue(object): + def __repr__(self): + return 'CENSORED' + +_censored_sentinel = CensoredValue() + _max_js_int = 1<<31 @@ -178,6 +189,8 @@ def encode_object(value): return ['O', {key: encode_object(val) for key, val in value.iteritems()}] elif value == _pending_sentinel: return ['P'] + elif value == _censored_sentinel: + return ['C'] elif isinstance(value, UnmarshallableValue): return ['U', value.value_repr] except Exception as e: @@ -218,6 +231,8 @@ def decode_object(value): return {decode_object(key): decode_object(val) for key, val in args[0].iteritems()} elif code == 'P': return _pending_sentinel + elif code == 'C': + return _censored_sentinel elif code == 'U': return UnmarshallableValue(args[0]) raise KeyError("Unknown object type code %r" % code)