mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) implement a safe mode for opening documents with rule problems
Summary: Adds an "enter safe mode" option and explanation in modal that appears when a document fails to load, if user is owner. If "enter safe mode" is selected, document is reloaded on server in a special mode. Currently, the only difference is that if the acl rules fail to load, they are replaced with a fallback that grants full access to owners and no access to anyone else. An extra tag is shown to mark the document as safe mode, with an "x" for cancelling safe mode. There are other ways a document could fail to load than just acl rules, so this is just a start. Test Plan: added test Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2686
This commit is contained in:
@@ -11,6 +11,7 @@ import {buildPagesDom} from 'app/client/ui/Pages';
|
||||
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
||||
import {tools} from 'app/client/ui/Tools';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
||||
@@ -30,6 +31,7 @@ export interface DocInfo extends Document {
|
||||
isSample: boolean;
|
||||
isPreFork: boolean;
|
||||
isFork: boolean;
|
||||
isRecoveryMode: boolean;
|
||||
isBareFork: boolean; // a document created without logging in, which is treated as a
|
||||
// fork without an original.
|
||||
idParts: UrlIdParts;
|
||||
@@ -53,6 +55,7 @@ export interface DocPageModel {
|
||||
isReadonly: Observable<boolean>;
|
||||
isPrefork: Observable<boolean>;
|
||||
isFork: Observable<boolean>;
|
||||
isRecoveryMode: Observable<boolean>;
|
||||
isBareFork: Observable<boolean>;
|
||||
isSample: Observable<boolean>;
|
||||
|
||||
@@ -89,6 +92,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);
|
||||
public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);
|
||||
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
|
||||
public readonly isRecoveryMode = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isRecoveryMode : false);
|
||||
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
|
||||
public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false);
|
||||
|
||||
@@ -198,12 +202,21 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
|
||||
// show a modal, and include a toast for the sake of the "Report error" link.
|
||||
reportError(err);
|
||||
const isOwner = this.currentDoc.get()?.access === 'owners';
|
||||
confirmModal(
|
||||
"Error opening document",
|
||||
"Reload",
|
||||
async () => window.location.reload(true),
|
||||
err.message,
|
||||
{hideCancel: true},
|
||||
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
||||
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
||||
`inaccessible to others. ` +
|
||||
`[${err.message}]` : err.message,
|
||||
{ hideCancel: true,
|
||||
extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
||||
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
||||
window.location.reload(true);
|
||||
}), testId('modal-recovery-mode')) : null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,6 +245,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
flow.onDispose(() => comm.releaseDocConnection(doc.id));
|
||||
|
||||
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
|
||||
if (openDocResponse.recoveryMode) {
|
||||
doc.isRecoveryMode = true;
|
||||
this.currentDoc.set({...doc});
|
||||
}
|
||||
const gdModule = await gristDocModulePromise;
|
||||
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
|
||||
flow.checkIfCancelled();
|
||||
@@ -311,6 +328,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
|
||||
return {
|
||||
...doc,
|
||||
isFork,
|
||||
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
||||
isSample,
|
||||
isPreFork,
|
||||
isBareFork,
|
||||
|
||||
@@ -43,9 +43,11 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
||||
docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {
|
||||
docNameSave: renameDoc,
|
||||
pageNameSave: getRenamePageFn(gristDoc),
|
||||
cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),
|
||||
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
|
||||
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
|
||||
isFork: pageModel.isFork,
|
||||
isRecoveryMode: pageModel.isRecoveryMode,
|
||||
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)),
|
||||
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
|
||||
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
|
||||
@@ -91,6 +93,13 @@ function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {
|
||||
};
|
||||
}
|
||||
|
||||
function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise<void> {
|
||||
return async () => {
|
||||
await gristDoc.app.topAppModel.api.getDocAPI(gristDoc.docPageModel.currentDocId.get()!)
|
||||
.recover(false);
|
||||
};
|
||||
}
|
||||
|
||||
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
|
||||
return cssHoverCircle(
|
||||
cssTopBarUndoBtn(iconName),
|
||||
|
||||
@@ -59,6 +59,14 @@ const cssTag = styled('span', `
|
||||
margin-left: 4px;
|
||||
`);
|
||||
|
||||
const cssAlertTag = styled(cssTag, `
|
||||
background-color: ${colors.error};
|
||||
--icon-color: white;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
`);
|
||||
|
||||
interface PartialWorkspace {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -76,10 +84,12 @@ export function docBreadcrumbs(
|
||||
options: {
|
||||
docNameSave: (val: string) => Promise<void>,
|
||||
pageNameSave: (val: string) => Promise<void>,
|
||||
cancelRecoveryMode: () => Promise<void>,
|
||||
isDocNameReadOnly?: BindableValue<boolean>,
|
||||
isPageNameReadOnly?: BindableValue<boolean>,
|
||||
isFork: Observable<boolean>,
|
||||
isFiddle: Observable<boolean>,
|
||||
isRecoveryMode: Observable<boolean>,
|
||||
isSnapshot?: Observable<boolean>,
|
||||
isPublic?: Observable<boolean>,
|
||||
}
|
||||
@@ -106,6 +116,13 @@ export function docBreadcrumbs(
|
||||
if (use(options.isFork)) {
|
||||
return cssTag('unsaved', testId('unsaved-tag'));
|
||||
}
|
||||
if (use(options.isRecoveryMode)) {
|
||||
return cssAlertTag('recovery mode',
|
||||
dom('a', dom.on('click', async () => {
|
||||
await options.cancelRecoveryMode()
|
||||
}), icon('CrossSmall')),
|
||||
testId('recovery-mode-tag'));
|
||||
}
|
||||
if (use(options.isFiddle)) {
|
||||
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {reportError} from 'app/client/models/errors';
|
||||
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {Computed, dom, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
export interface IModalControl {
|
||||
close(): void;
|
||||
@@ -90,6 +90,7 @@ export interface ISaveModalOptions {
|
||||
hideCancel?: boolean; // If set, hide the Cancel button
|
||||
width?: ModalWidth; // Set a width style for the dialog.
|
||||
modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element.
|
||||
extraButtons?: DomContents; // More buttons!
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,6 +161,7 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
|
||||
dom.on('click', save),
|
||||
testId('modal-confirm'),
|
||||
),
|
||||
options.extraButtons,
|
||||
options.hideCancel ? null : bigBasicButton('Cancel',
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('modal-cancel'),
|
||||
@@ -182,7 +184,7 @@ export function confirmModal(
|
||||
btnText: string,
|
||||
onConfirm: () => Promise<void>,
|
||||
explanation?: Element|string,
|
||||
{hideCancel}: {hideCancel?: boolean} = {},
|
||||
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
|
||||
): void {
|
||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||
title,
|
||||
@@ -191,6 +193,7 @@ export function confirmModal(
|
||||
saveFunc: onConfirm,
|
||||
hideCancel,
|
||||
width: 'normal',
|
||||
extraButtons,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user