gristlabs_grist-core/app/client/models/NotifyModel.ts
Paul Fitzpatrick 6af811f7ab (core) give more detailed reasons for access denied when memos are present
Summary:
With this change, if a comment is added to an ACL formula, then that comment will be offered to the user if access is denied and that rule could potentially have granted access.

The code is factored so that when access is permitted, or when partially visible tables are being filtered, there is little overhead. Comments are gathered only when an explicit denial of access.

Test Plan: added tests, updated tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2730
2021-02-15 17:02:24 -05:00

374 lines
12 KiB
TypeScript

import * as log from 'app/client/lib/log';
import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState';
import {delay} from 'app/common/delay';
import {isLongerThan} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {timeFormat} from 'app/common/timeFormat';
import {bundleChanges, Disposable, Holder, IDisposable, IDisposableOwner } from 'grainjs';
import {Computed, dom, DomElementArg, MutableObsArray, obsArray, Observable} from 'grainjs';
import clamp = require('lodash/clamp');
import defaults = require('lodash/defaults');
// When rendering app errors, we'll only show the last few.
const maxAppErrors = 5;
interface INotifier {
// If you are looking to report errors, please do that via reportError rather
// than these methods so that we have a chance to send the error to our logs.
createUserError(message: string, options?: INotifyOptions): INotification;
createAppError(error: Error): void;
createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress;
createNotification(options: INotifyOptions): INotification;
setConnectState(isConnected: boolean): void;
slowNotification<T>(promise: Promise<T>, optTimeout?: number): Promise<T>;
getFullAppErrors(): IAppError[];
}
interface INotification extends Expirable {
expire(): Promise<void>;
}
export interface IProgress extends Expirable {
setProgress(percent: number): void;
}
// Identifies supported actions. These are implemented in NotifyUI.
export type NotifyAction = 'upgrade' | 'renew' | 'report-problem' | 'ask-for-help';
export interface INotifyOptions {
message: string | (() => DomElementArg); // A string, or a function that builds dom.
timestamp?: number;
title?: string;
canUserClose?: boolean;
inToast?: boolean;
inDropdown?: boolean;
expireSec?: number;
badgeCounter?: boolean;
memos?: string[]; // A list of relevant notes.
// cssToastAction class from NotifyUI will be applied automatically to action elements.
actions?: NotifyAction[];
// When set, the notification will replace any previous notification with the same key.
// This way, we can avoid accumulating many of substantially identical notifications.
key?: string|null;
}
type Status = 'active' | 'expiring';
export class Expirable extends Disposable {
public static readonly fadeDelay = 250;
public readonly status = Observable.create<Status>(this, 'active');
constructor() {
super();
}
/**
* Sets status to 'expiring', then calls dispose after a short delay.
*/
public async expire(): Promise<void> {
this.status.set('expiring');
await delay(Expirable.fadeDelay);
if (!this.isDisposed()) {
this.dispose();
}
}
}
export class Notification extends Expirable implements INotification {
public options: Required<INotifyOptions> = {
title: '',
message: '',
timestamp: Date.now(),
inDropdown: false,
badgeCounter: false,
inToast: true,
expireSec: 0,
canUserClose: false,
actions: [],
memos: [],
key: null,
};
constructor(_opts: INotifyOptions) {
super();
this.options = defaults({}, _opts, this.options)
if (this.options.expireSec > 0) {
const expireTimer = setTimeout(() => this.expire(), 1000 * this.options.expireSec);
this.onDispose(() => clearTimeout(expireTimer));
}
}
}
interface IProgressOptions {
name: string;
size: string;
expireOnComplete?: boolean;
}
export class Progress extends Expirable implements IProgress {
public readonly progress = Observable.create(this, 0);
constructor(public options: IProgressOptions) {
super();
if (options.expireOnComplete) {
this.autoDispose(this.progress.addListener(async progress => {
if (progress >= 100) {
await this.expire();
}
}));
}
}
/**
* progress should be between 0 and 100.
*/
public setProgress(progress: number) {
this.progress.set(clamp(progress, 0, 100));
}
}
/**
* Similar to grainjs MultiHolder, but knows when items are disposed externally and releases them
* (avoiding the "already disposed" warnings in that case). This is probably how grainjs's
* MultiHolder should actually work, and maybe how `Disposable.autoDispose` should generally work.
*/
export class BetterMultiHolder implements IDisposableOwner {
private _items = new Set<IDisposable>();
public autoDispose<T extends IDisposable>(obj: T): T {
this._items.add(obj);
if (obj instanceof Disposable) {
obj.onDispose(() => this._items.delete(obj));
}
return obj;
}
public dispose() {
for (const item of this._items) {
item.dispose();
}
this._items.clear();
}
}
export interface IAppError {
error: Error;
timestamp: number;
seen?: boolean; // If seen, this will be hidden from the "app errors" toast
}
export class Notifier extends Disposable implements INotifier {
private _itemsHolder = this.autoDispose(new BetterMultiHolder());
private _toasts = this.autoDispose(obsArray<Notification>());
private _dropdownItems = this.autoDispose(obsArray<Notification>());
private _progressItems = this.autoDispose(obsArray<Progress>([]));
private _keyedItems = new Map<string, Notification>();
private _connectStateManager = ConnectStateManager.create(this);
private _connectState = this._connectStateManager.connectState;
private _disconnectMsg = Computed.create(this, (use) => getDisconnectMessage(use(this._connectState)));
// Holds recent application errors, which the user may report to us.
private _appErrorList = this.autoDispose(obsArray<IAppError>());
// The dropdown will show all recent errors; the toast only the "new" ones, i.e. those since the
// last toast was closed.
private _appErrorDropdownItem = Holder.create<INotification>(this);
private _appErrorToast = Holder.create<INotification>(this);
private _slowNotificationToast = Holder.create<INotification>(this);
private _slowNotificationInactivityTimer = new InactivityTimer(() => this._slowNotificationToast.clear(), 0);
constructor() {
super();
Computed.create(this, this._disconnectMsg, (use, msg) =>
msg ? use.owner.autoDispose(this.createNotification({
message: msg.message,
title: msg.title,
canUserClose: true,
inToast: true,
})) : null);
}
/**
* Exposes all the state needed for building UI. This is simply to clarify the intended usage:
* these members aren't intended to be exposed, except to the UI-building code.
*/
public getStateForUI() {
return {
toasts: this._toasts,
dropdownItems: this._dropdownItems,
progressItems: this._progressItems,
connectState: this._connectState,
disconnectMsg: this._disconnectMsg,
};
}
/**
* Creates a basic toast user error. By default, expires in 5 seconds.
* Takes an options objects to configure `expireSec` and `canUserClose`.
* Set `expireSec` to 0 to prevent expiration.
*
* If you are looking to report errors, please do that via reportError so
* that we have a chance to send the error to our logs.
*/
public createUserError(message: string, options: Partial<INotifyOptions> = {}): INotification {
const timestamp = Date.now();
if (options.actions && options.actions.includes('ask-for-help')) {
// If user should be able to ask for help, add this error to the notifier dropdown too for a
// good while, so the user can find it after the toast disappears.
this.createNotification({
timestamp,
message,
inToast: false,
expireSec: 300,
canUserClose: true,
inDropdown: true,
...options,
key: options.key && ("dropdown:" + options.key),
});
}
return this.createNotification({
timestamp,
message,
inToast: true,
expireSec: 10,
canUserClose: true,
inDropdown: false,
...options,
});
}
/**
* If you are looking to report errors, please do that via reportError so
* that we have a chance to send the error to our logs.
*/
public createAppError(error: Error): void {
bundleChanges(() => {
// Remove old messages, to keep a max of maxAppErrors.
if (this._appErrorList.get().length >= maxAppErrors) {
this._appErrorList.splice(0, this._appErrorList.get().length - maxAppErrors + 1);
}
this._appErrorList.push({error, timestamp: Date.now()});
});
// Create a dropdown item for errors if we don't have one yet.
if (this._appErrorDropdownItem.isEmpty()) {
this._appErrorDropdownItem.autoDispose(this._createAppErrorItem('dropdown'));
}
// Create a toast for errors if we don't have one yet. When it's closed, mark the items as
// "seen" (i.e. not to be shown when the toast pops up again).
if (this._appErrorToast.isEmpty()) {
const n = this._appErrorToast.autoDispose(this._createAppErrorItem('toast'));
n.onDispose(() => this._appErrorList.get().forEach((appErr) => { appErr.seen = true; }));
}
}
public createNotification(opts: INotifyOptions): INotification {
const n = Notification.create(this._itemsHolder, opts);
this._addNotification(n).catch((e) => { log.warn('_addNotification failed', e); });
return n;
}
public createProgressIndicator(name: string, size: string, expireOnComplete = false): IProgress {
// Progress objects normally dispose themselves; constructor disposes any leftover items.
const p = Progress.create(this._itemsHolder, {name, size, expireOnComplete});
this._progressItems.push(p);
p.onDispose(() => this.isDisposed() || arrayRemove(this._progressItems, p));
return p;
}
public setConnectState(isConnected: boolean): void {
this._connectStateManager.setConnected(isConnected);
}
public getFullAppErrors() {
return this._appErrorList.get();
}
// This is exposed primarily for tests.
public clearAppErrors() {
this._appErrorList.splice(0);
}
/**
* Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in
* promise.
*/
public async slowNotification<T>(promise: Promise<T>, optTimeout: number = 1000): Promise<T> {
if (await isLongerThan(promise, optTimeout)) {
if (this._slowNotificationToast.isEmpty()) {
this._slowNotificationToast.autoDispose(this.createNotification({
message: "Still working...",
canUserClose: false,
inToast: true,
}));
}
await this._slowNotificationInactivityTimer.disableUntilFinish(promise);
}
return promise;
}
private async _addNotification(n: Notification): Promise<void> {
const key = n.options.key;
if (key) {
const prev = this._keyedItems.get(key);
if (prev) {
await prev.expire();
}
this._keyedItems.set(key, n);
n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));
}
if (n.options.inToast) {
this._toasts.push(n);
n.onDispose(() => this.isDisposed() || arrayRemove(this._toasts, n));
}
if (n.options.inDropdown) {
this._dropdownItems.push(n);
n.onDispose(() => this.isDisposed() || arrayRemove(this._dropdownItems, n));
}
}
private _createAppErrorItem(where: 'toast' | 'dropdown') {
return this.createNotification({
// Building DOM here in NotifyModel seems wrong, but I haven't come up with a better way.
message: () => dom.forEach(this._appErrorList, (appErr: IAppError) =>
(where === 'toast' && appErr.seen ? null :
dom('div', timeFormat('T', new Date(appErr.timestamp)), ' ', appErr.error.message)
)
),
title: 'Unexpected error',
canUserClose: true,
inToast: where === 'toast',
expireSec: where === 'toast' ? 10 : 0,
inDropdown: where === 'dropdown',
actions: ['report-problem'],
});
}
}
function arrayRemove<T>(arr: MutableObsArray<T>, elem: T) {
const removeIdx = arr.get().findIndex(e => e === elem);
if (removeIdx !== -1) {
arr.splice(removeIdx, 1);
}
}
function getDisconnectMessage(state: ConnectState): {title: string, message: string}|undefined {
switch (state) {
case ConnectState.RecentlyDisconnected:
return {title: 'Connection is lost', message: 'Attempting to reconnect...'};
case ConnectState.ReallyDisconnected:
return {title: 'Not connected', message: 'The document is in read-only mode until you are back online.'};
}
}