mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
reconcile boot and admin pages further (#963)
This adds some remaining parts of the boot page to the admin panel, and then removes the boot page.
This commit is contained in:
parent
1690b77d81
commit
5dc4706dc7
@ -1,160 +0,0 @@
|
|||||||
import { AppModel } from 'app/client/models/AppModel';
|
|
||||||
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
|
|
||||||
import { createAppPage } from 'app/client/ui/createAppPage';
|
|
||||||
import { pagePanels } from 'app/client/ui/PagePanels';
|
|
||||||
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
|
||||||
import { getGristConfig } from 'app/common/urlUtils';
|
|
||||||
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
|
|
||||||
|
|
||||||
const cssBody = styled('div', `
|
|
||||||
padding: 20px;
|
|
||||||
overflow: auto;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssHeader = styled('div', `
|
|
||||||
padding: 20px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssResult = styled('div', `
|
|
||||||
max-width: 500px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* A "boot" page for inspecting the state of the Grist installation.
|
|
||||||
*
|
|
||||||
* TODO: deferring using any localization machinery so as not
|
|
||||||
* to have to worry about its failure modes yet, but it should be
|
|
||||||
* fine as long as assets served locally are used.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export class Boot extends Disposable {
|
|
||||||
|
|
||||||
private _checks: AdminChecks;
|
|
||||||
|
|
||||||
constructor(_appModel: AppModel) {
|
|
||||||
super();
|
|
||||||
// Setting title in constructor seems to be how we are doing this,
|
|
||||||
// based on other similar pages.
|
|
||||||
document.title = 'Booting Grist';
|
|
||||||
this._checks = new AdminChecks(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up the page. Uses the generic Grist layout with an empty
|
|
||||||
* side panel, just for convenience. Could be made a lot prettier.
|
|
||||||
*/
|
|
||||||
public buildDom() {
|
|
||||||
this._checks.fetchAvailableChecks().catch(e => reportError(e));
|
|
||||||
|
|
||||||
const config = getGristConfig();
|
|
||||||
const errMessage = config.errMessage;
|
|
||||||
const rootNode = dom('div',
|
|
||||||
dom.domComputed(
|
|
||||||
use => {
|
|
||||||
return pagePanels({
|
|
||||||
leftPanel: {
|
|
||||||
panelWidth: Observable.create(this, 240),
|
|
||||||
panelOpen: Observable.create(this, false),
|
|
||||||
hideOpener: true,
|
|
||||||
header: null,
|
|
||||||
content: null,
|
|
||||||
},
|
|
||||||
headerMain: cssHeader(dom('h1', 'Grist Boot')),
|
|
||||||
contentMain: this.buildBody(use, {errMessage}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return rootNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The body of the page is very simple right now, basically a
|
|
||||||
* placeholder. Make a section for each probe, and kick them off in
|
|
||||||
* parallel, showing results as they come in.
|
|
||||||
*/
|
|
||||||
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
|
|
||||||
if (options.errMessage) {
|
|
||||||
return cssBody(cssResult(this.buildError()));
|
|
||||||
}
|
|
||||||
return cssBody([
|
|
||||||
...use(this._checks.probes).map(probe => {
|
|
||||||
const req = this._checks.requestCheck(probe);
|
|
||||||
return cssResult(
|
|
||||||
this.buildResult(req.probe, use(req.result), req.details));
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is used when there is an attempt to access the boot page
|
|
||||||
* but something isn't right - either the page isn't enabled, or
|
|
||||||
* the key in the URL is wrong. Give the user some information about
|
|
||||||
* how to set things up.
|
|
||||||
*/
|
|
||||||
public buildError() {
|
|
||||||
return dom(
|
|
||||||
'div',
|
|
||||||
dom('p',
|
|
||||||
'A diagnostics page can be made available at:',
|
|
||||||
dom('blockquote', '/boot/GRIST_BOOT_KEY'),
|
|
||||||
'GRIST_BOOT_KEY is an environment variable ',
|
|
||||||
' set before Grist starts. It should only',
|
|
||||||
' contain characters that are valid in a URL.',
|
|
||||||
' It should be a secret, since no authentication is needed',
|
|
||||||
' to visit the diagnostics page.'),
|
|
||||||
dom('p',
|
|
||||||
'You are seeing this page because either the key is not set,',
|
|
||||||
' or it is not in the URL.'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An ugly rendering of information returned by the probe.
|
|
||||||
*/
|
|
||||||
public buildResult(info: BootProbeInfo, result: BootProbeResult,
|
|
||||||
details: ProbeDetails|undefined) {
|
|
||||||
const out: (HTMLElement|string|null)[] = [];
|
|
||||||
out.push(dom('h2', info.name));
|
|
||||||
if (details) {
|
|
||||||
out.push(dom('p', '> ', details.info));
|
|
||||||
}
|
|
||||||
if (result.verdict) {
|
|
||||||
out.push(dom('pre', result.verdict));
|
|
||||||
}
|
|
||||||
if (result.success !== undefined) {
|
|
||||||
out.push(result.success ? '✅' : '❌');
|
|
||||||
}
|
|
||||||
if (result.done === true) {
|
|
||||||
out.push(dom('p', 'no fault detected'));
|
|
||||||
}
|
|
||||||
if (result.details) {
|
|
||||||
for (const [key, val] of Object.entries(result.details)) {
|
|
||||||
out.push(dom(
|
|
||||||
'div',
|
|
||||||
cssLabel(key),
|
|
||||||
dom('input', dom.prop('value', JSON.stringify(val)))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a stripped down page to show boot information.
|
|
||||||
* Make sure the API isn't used since it may well be unreachable
|
|
||||||
* due to a misconfiguration, especially in multi-server setups.
|
|
||||||
*/
|
|
||||||
createAppPage(appModel => {
|
|
||||||
return dom.create(Boot, appModel);
|
|
||||||
}, {
|
|
||||||
useApi: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const cssLabel = styled('div', `
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 5px;
|
|
||||||
`);
|
|
@ -1,5 +1,6 @@
|
|||||||
|
import { reportError } from 'app/client/models/errors';
|
||||||
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
||||||
import { removeTrailingSlash } from 'app/common/gutil';
|
import { InstallAPI } from 'app/common/InstallAPI';
|
||||||
import { getGristConfig } from 'app/common/urlUtils';
|
import { getGristConfig } from 'app/common/urlUtils';
|
||||||
import { Disposable, Observable, UseCBOwner } from 'grainjs';
|
import { Disposable, Observable, UseCBOwner } from 'grainjs';
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ export class AdminChecks {
|
|||||||
// Keep track of probe results we have received, by probe ID.
|
// Keep track of probe results we have received, by probe ID.
|
||||||
private _results: Map<string, Observable<BootProbeResult>>;
|
private _results: Map<string, Observable<BootProbeResult>>;
|
||||||
|
|
||||||
constructor(private _parent: Disposable) {
|
constructor(private _parent: Disposable, private _installAPI: InstallAPI) {
|
||||||
this.probes = Observable.create(_parent, []);
|
this.probes = Observable.create(_parent, []);
|
||||||
this._results = new Map();
|
this._results = new Map();
|
||||||
this._requests = new Map();
|
this._requests = new Map();
|
||||||
@ -32,18 +33,16 @@ export class AdminChecks {
|
|||||||
const config = getGristConfig();
|
const config = getGristConfig();
|
||||||
const errMessage = config.errMessage;
|
const errMessage = config.errMessage;
|
||||||
if (!errMessage) {
|
if (!errMessage) {
|
||||||
// Probe tool URLs are relative to the current URL. Don't trust configuration,
|
const _probes = await this._installAPI.getChecks().catch(reportError);
|
||||||
// because it may be buggy if the user is here looking at the boot page
|
if (!this._parent.isDisposed()) {
|
||||||
// to figure out some problem.
|
// Currently, probes are forbidden if not admin.
|
||||||
//
|
// TODO: May want to relax this to allow some probes that help
|
||||||
// We have been careful to make URLs available with appropriate
|
// diagnose some initial auth problems.
|
||||||
// middleware relative to both of the admin panel and the boot page.
|
this.probes.set(_probes ? _probes.probes : []);
|
||||||
const url = new URL(removeTrailingSlash(document.location.href));
|
}
|
||||||
url.pathname += '/probe';
|
return _probes;
|
||||||
const resp = await fetch(url.href);
|
|
||||||
const _probes = await resp.json();
|
|
||||||
this.probes.set(_probes.probes);
|
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,12 +53,12 @@ export class AdminChecks {
|
|||||||
const {id} = probe;
|
const {id} = probe;
|
||||||
let result = this._results.get(id);
|
let result = this._results.get(id);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = Observable.create(this._parent, {});
|
result = Observable.create(this._parent, {status: 'none'});
|
||||||
this._results.set(id, result);
|
this._results.set(id, result);
|
||||||
}
|
}
|
||||||
let request = this._requests.get(id);
|
let request = this._requests.get(id);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
request = new AdminCheckRunner(id, this._results, this._parent);
|
request = new AdminCheckRunner(this._installAPI, id, this._results, this._parent);
|
||||||
this._requests.set(id, request);
|
this._requests.set(id, request);
|
||||||
}
|
}
|
||||||
request.start();
|
request.start();
|
||||||
@ -93,15 +92,15 @@ export interface AdminCheckRequest {
|
|||||||
* Manage a single check.
|
* Manage a single check.
|
||||||
*/
|
*/
|
||||||
export class AdminCheckRunner {
|
export class AdminCheckRunner {
|
||||||
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
|
constructor(private _installAPI: InstallAPI,
|
||||||
|
public id: string,
|
||||||
|
public results: Map<string, Observable<BootProbeResult>>,
|
||||||
public parent: Disposable) {
|
public parent: Disposable) {
|
||||||
const url = new URL(removeTrailingSlash(document.location.href));
|
this._installAPI.runCheck(id).then(result => {
|
||||||
url.pathname = url.pathname + '/probe/' + id;
|
if (parent.isDisposed()) { return; }
|
||||||
fetch(url.href).then(async resp => {
|
|
||||||
const _probes: BootProbeResult = await resp.json();
|
|
||||||
const ob = results.get(id);
|
const ob = results.get(id);
|
||||||
if (ob) {
|
if (ob) {
|
||||||
ob.set(_probes);
|
ob.set(result);
|
||||||
}
|
}
|
||||||
}).catch(e => console.error(e));
|
}).catch(e => console.error(e));
|
||||||
}
|
}
|
||||||
@ -109,7 +108,7 @@ export class AdminCheckRunner {
|
|||||||
public start() {
|
public start() {
|
||||||
let result = this.results.get(this.id);
|
let result = this.results.get(this.id);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = Observable.create(this.parent, {});
|
result = Observable.create(this.parent, {status: 'none'});
|
||||||
this.results.set(this.id, result);
|
this.results.set(this.id, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,7 +119,7 @@ export class AdminCheckRunner {
|
|||||||
* but it can be useful to show extra details and tips in the
|
* but it can be useful to show extra details and tips in the
|
||||||
* client.
|
* client.
|
||||||
*/
|
*/
|
||||||
const probeDetails: Record<string, ProbeDetails> = {
|
export const probeDetails: Record<string, ProbeDetails> = {
|
||||||
'boot-page': {
|
'boot-page': {
|
||||||
info: `
|
info: `
|
||||||
This boot page should not be too easy to access. Either turn
|
This boot page should not be too easy to access. Either turn
|
||||||
@ -144,6 +143,17 @@ is set.
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'sandboxing': {
|
||||||
|
info: `
|
||||||
|
Grist allows for very powerful formulas, using Python.
|
||||||
|
We recommend setting the environment variable
|
||||||
|
GRIST_SANDBOX_FLAVOR to gvisor if your hardware
|
||||||
|
supports it (most will), to run formulas in each document
|
||||||
|
within a sandbox isolated from other documents and isolated
|
||||||
|
from the network.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
'system-user': {
|
'system-user': {
|
||||||
info: `
|
info: `
|
||||||
It is good practice not to run Grist as the root user.
|
It is good practice not to run Grist as the root user.
|
||||||
@ -153,6 +163,15 @@ It is good practice not to run Grist as the root user.
|
|||||||
'reachable': {
|
'reachable': {
|
||||||
info: `
|
info: `
|
||||||
The main page of Grist should be available.
|
The main page of Grist should be available.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
'websockets': {
|
||||||
|
// TODO: add a link to https://support.getgrist.com/self-managed/#how-do-i-run-grist-on-a-server
|
||||||
|
info: `
|
||||||
|
Websocket connections need HTTP 1.1 and the ability to pass a few
|
||||||
|
extra headers in order to work. Sometimes a reverse proxy can
|
||||||
|
interfere with these requirements.
|
||||||
`
|
`
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -531,10 +531,52 @@ export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
|
|||||||
return getOrgName(org);
|
return getOrgName(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHomeUrl(): string {
|
/**
|
||||||
|
* If we don't know what the home URL is, the top level of the site
|
||||||
|
* we are on may work. This should always work for single-server installs
|
||||||
|
* that don't encode organization information in domains. Even for other
|
||||||
|
* cases, this should be a good enough home URL for many purposes, it
|
||||||
|
* just may still have some organization information encoded in it from
|
||||||
|
* the domain that could influence results that might be supposed to be
|
||||||
|
* organization-neutral.
|
||||||
|
*/
|
||||||
|
export function getFallbackHomeUrl(): string {
|
||||||
const {host, protocol} = window.location;
|
const {host, protocol} = window.location;
|
||||||
|
return `${protocol}//${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the official home URL sent to us from the back end.
|
||||||
|
*/
|
||||||
|
export function getConfiguredHomeUrl(): string {
|
||||||
const gristConfig: any = (window as any).gristConfig;
|
const gristConfig: any = (window as any).gristConfig;
|
||||||
return (gristConfig && gristConfig.homeUrl) || `${protocol}//${host}`;
|
return (gristConfig && gristConfig.homeUrl) || getFallbackHomeUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the home URL, using fallback if on admin page rather
|
||||||
|
* than trusting back end configuration.
|
||||||
|
*/
|
||||||
|
export function getPreferredHomeUrl(): string|undefined {
|
||||||
|
const gristUrl = urlState().state.get();
|
||||||
|
if (gristUrl.adminPanel) {
|
||||||
|
// On the admin panel, we should not trust configuration much,
|
||||||
|
// since we want the user to be able to access it to diagnose
|
||||||
|
// problems with configuration. So we access the API via the
|
||||||
|
// site we happen to be on rather than anything configured on
|
||||||
|
// the back end. Couldn't we just always do this? Maybe!
|
||||||
|
// It could require adjustments for calls that are meant
|
||||||
|
// to be site-neutral if the domain has an org encoded in it.
|
||||||
|
// But that's a small price to pay. Grist Labs uses a setup
|
||||||
|
// where api calls go to a dedicated domain distinct from all
|
||||||
|
// other sites, but there's no particular advantage to it.
|
||||||
|
return getFallbackHomeUrl();
|
||||||
|
}
|
||||||
|
return getConfiguredHomeUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHomeUrl(): string {
|
||||||
|
return getPreferredHomeUrl() || getConfiguredHomeUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newUserAPIImpl(): UserAPIImpl {
|
export function newUserAPIImpl(): UserAPIImpl {
|
||||||
|
@ -2,8 +2,8 @@ import {buildHomeBanners} from 'app/client/components/Banners';
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
||||||
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
||||||
|
import {AdminChecks, probeDetails, ProbeDetails} from 'app/client/models/AdminChecks';
|
||||||
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
||||||
import {AdminChecks} from 'app/client/models/AdminChecks';
|
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
@ -15,7 +15,7 @@ import {basicButton} from 'app/client/ui2018/buttons';
|
|||||||
import {toggle} from 'app/client/ui2018/checkbox';
|
import {toggle} from 'app/client/ui2018/checkbox';
|
||||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
||||||
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
|
import {BootProbeInfo, BootProbeResult, SandboxingBootProbeDetails} from 'app/common/BootProbe';
|
||||||
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
||||||
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
|
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
|
||||||
import {naturalCompare} from 'app/common/SortFunc';
|
import {naturalCompare} from 'app/common/SortFunc';
|
||||||
@ -41,7 +41,7 @@ export class AdminPanel extends Disposable {
|
|||||||
constructor(private _appModel: AppModel) {
|
constructor(private _appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
||||||
this._checks = new AdminChecks(this);
|
this._checks = new AdminChecks(this, this._installAPI);
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
@ -78,9 +78,42 @@ export class AdminPanel extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _buildMainContent(owner: MultiHolder) {
|
private _buildMainContent(owner: MultiHolder) {
|
||||||
|
// If probes are available, show the panel as normal.
|
||||||
|
// Otherwise say it is unavailable, and describe a fallback
|
||||||
|
// mechanism for access.
|
||||||
return cssPageContainer(
|
return cssPageContainer(
|
||||||
dom.cls('clipboard'),
|
dom.cls('clipboard'),
|
||||||
{tabIndex: "-1"},
|
{tabIndex: "-1"},
|
||||||
|
dom.maybe(this._checks.probes, probes => {
|
||||||
|
return probes.length > 0
|
||||||
|
? this._buildMainContentForAdmin(owner)
|
||||||
|
: this._buildMainContentForOthers(owner);
|
||||||
|
}),
|
||||||
|
testId('admin-panel'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show something helpful to those without access to the panel,
|
||||||
|
* which could include a legit adminstrator if auth is misconfigured.
|
||||||
|
*/
|
||||||
|
private _buildMainContentForOthers(owner: MultiHolder) {
|
||||||
|
const exampleKey = 'example-' + window.crypto.randomUUID();
|
||||||
|
return dom.create(AdminSection, t('Administrator Panel Unavailable'), [
|
||||||
|
dom('p', t(`You do not have access to the administrator panel.
|
||||||
|
Please log in as an administrator.`)),
|
||||||
|
dom(
|
||||||
|
'p',
|
||||||
|
t(`Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}`, {
|
||||||
|
bootKey: dom('pre', `GRIST_BOOT_KEY=${exampleKey}`),
|
||||||
|
url: dom('pre', `/admin?boot-key=${exampleKey}`)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildMainContentForAdmin(owner: MultiHolder) {
|
||||||
|
return [
|
||||||
dom.create(AdminSection, t('Support Grist'), [
|
dom.create(AdminSection, t('Support Grist'), [
|
||||||
dom.create(AdminSectionItem, {
|
dom.create(AdminSectionItem, {
|
||||||
id: 'telemetry',
|
id: 'telemetry',
|
||||||
@ -113,7 +146,6 @@ export class AdminPanel extends Disposable {
|
|||||||
expandedContent: this._buildAuthenticationNotice(owner),
|
expandedContent: this._buildAuthenticationNotice(owner),
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
|
|
||||||
dom.create(AdminSection, t('Version'), [
|
dom.create(AdminSection, t('Version'), [
|
||||||
dom.create(AdminSectionItem, {
|
dom.create(AdminSectionItem, {
|
||||||
id: 'version',
|
id: 'version',
|
||||||
@ -123,8 +155,23 @@ export class AdminPanel extends Disposable {
|
|||||||
}),
|
}),
|
||||||
this._buildUpdates(owner),
|
this._buildUpdates(owner),
|
||||||
]),
|
]),
|
||||||
testId('admin-panel'),
|
dom.create(AdminSection, t('Self Checks'), [
|
||||||
);
|
this._buildProbeItems(owner, {
|
||||||
|
showRedundant: false,
|
||||||
|
showNovel: true,
|
||||||
|
}),
|
||||||
|
dom.create(AdminSectionItem, {
|
||||||
|
id: 'probe-other',
|
||||||
|
name: 'more...',
|
||||||
|
description: '',
|
||||||
|
value: '',
|
||||||
|
expandedContent: this._buildProbeItems(owner, {
|
||||||
|
showRedundant: true,
|
||||||
|
showNovel: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildSandboxingDisplay(owner: IDisposableOwner) {
|
private _buildSandboxingDisplay(owner: IDisposableOwner) {
|
||||||
@ -132,7 +179,7 @@ export class AdminPanel extends Disposable {
|
|||||||
use => {
|
use => {
|
||||||
const req = this._checks.requestCheckById(use, 'sandboxing');
|
const req = this._checks.requestCheckById(use, 'sandboxing');
|
||||||
const result = req ? use(req.result) : undefined;
|
const result = req ? use(req.result) : undefined;
|
||||||
const success = result?.success;
|
const success = result?.status === 'success';
|
||||||
const details = result?.details as SandboxingBootProbeDetails|undefined;
|
const details = result?.details as SandboxingBootProbeDetails|undefined;
|
||||||
if (!details) {
|
if (!details) {
|
||||||
return cssValueLabel(t('unknown'));
|
return cssValueLabel(t('unknown'));
|
||||||
@ -150,11 +197,9 @@ export class AdminPanel extends Disposable {
|
|||||||
|
|
||||||
private _buildSandboxingNotice() {
|
private _buildSandboxingNotice() {
|
||||||
return [
|
return [
|
||||||
t('Grist allows for very powerful formulas, using Python. \
|
// Use AdminChecks text for sandboxing, in order not to
|
||||||
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
|
// duplicate.
|
||||||
if your hardware supports it (most will), \
|
probeDetails['sandboxing'].info,
|
||||||
to run formulas in each document within a sandbox \
|
|
||||||
isolated from other documents and isolated from the network.'),
|
|
||||||
dom(
|
dom(
|
||||||
'div',
|
'div',
|
||||||
{style: 'margin-top: 8px'},
|
{style: 'margin-top: 8px'},
|
||||||
@ -172,7 +217,8 @@ isolated from other documents and isolated from the network.'),
|
|||||||
return cssValueLabel(cssErrorText('unavailable'));
|
return cssValueLabel(cssErrorText('unavailable'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { success, details } = result;
|
const { status, details } = result;
|
||||||
|
const success = status === 'success';
|
||||||
const loginSystemId = details?.loginSystemId;
|
const loginSystemId = details?.loginSystemId;
|
||||||
|
|
||||||
if (!success || !loginSystemId) {
|
if (!success || !loginSystemId) {
|
||||||
@ -412,8 +458,112 @@ isolated from other documents and isolated from the network.'),
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the results of various checks. Of the checks, some are considered
|
||||||
|
* "redundant" (already covered elsewhere in the Admin Panel) and the
|
||||||
|
* remainder are "novel".
|
||||||
|
*/
|
||||||
|
private _buildProbeItems(owner: MultiHolder, options: {
|
||||||
|
showRedundant: boolean,
|
||||||
|
showNovel: boolean,
|
||||||
|
}) {
|
||||||
|
return dom.domComputed(
|
||||||
|
use => [
|
||||||
|
...use(this._checks.probes).map(probe => {
|
||||||
|
const isRedundant = probe.id === 'sandboxing';
|
||||||
|
const show = isRedundant ? options.showRedundant : options.showNovel;
|
||||||
|
if (!show) { return null; }
|
||||||
|
const req = this._checks.requestCheck(probe);
|
||||||
|
return this._buildProbeItem(owner, req.probe, use(req.result), req.details);
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the result of an individual check.
|
||||||
|
*/
|
||||||
|
private _buildProbeItem(owner: MultiHolder,
|
||||||
|
info: BootProbeInfo,
|
||||||
|
result: BootProbeResult,
|
||||||
|
details: ProbeDetails|undefined) {
|
||||||
|
|
||||||
|
const status = this._encodeSuccess(result);
|
||||||
|
return dom.create(AdminSectionItem, {
|
||||||
|
id: `probe-${info.id}`,
|
||||||
|
name: info.id,
|
||||||
|
description: info.name,
|
||||||
|
value: cssStatus(status),
|
||||||
|
expandedContent: [
|
||||||
|
cssCheckHeader(
|
||||||
|
t('Results'),
|
||||||
|
{ style: 'margin-top: 0px; padding-top: 0px;' },
|
||||||
|
),
|
||||||
|
result.verdict ? dom('pre', result.verdict) : null,
|
||||||
|
(result.status === 'none') ? null :
|
||||||
|
dom('p',
|
||||||
|
(result.status === 'success') ? t('Check succeeded.') : t('Check failed.')),
|
||||||
|
(result.status !== 'none') ? null :
|
||||||
|
dom('p', t('No fault detected.')),
|
||||||
|
(details?.info === undefined) ? null : [
|
||||||
|
cssCheckHeader(t('Notes')),
|
||||||
|
details.info,
|
||||||
|
],
|
||||||
|
(result.details === undefined) ? null : [
|
||||||
|
cssCheckHeader(t('Details')),
|
||||||
|
...Object.entries(result.details).map(([key, val]) => {
|
||||||
|
return dom(
|
||||||
|
'div',
|
||||||
|
cssLabel(key),
|
||||||
|
dom('input', dom.prop(
|
||||||
|
'value',
|
||||||
|
typeof val === 'string' ? val : JSON.stringify(val))));
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give an icon summarizing success or failure. Factor in the
|
||||||
|
* severity of the result for failures. This is crude, the
|
||||||
|
* visualization of the results can be elaborated in future.
|
||||||
|
*/
|
||||||
|
private _encodeSuccess(result: BootProbeResult) {
|
||||||
|
switch (result.status) {
|
||||||
|
case 'success':
|
||||||
|
return '✅';
|
||||||
|
case 'fault':
|
||||||
|
return '❌';
|
||||||
|
case 'warning':
|
||||||
|
return '❗';
|
||||||
|
case 'hmm':
|
||||||
|
return '?';
|
||||||
|
case 'none':
|
||||||
|
return '―';
|
||||||
|
default:
|
||||||
|
// should not arrive here
|
||||||
|
return '??';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Ugh I'm not a front end person. h5 small-caps, sure why not.
|
||||||
|
// Hopefully someone with taste will edit someday!
|
||||||
|
const cssCheckHeader = styled('h5', `
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-variant: small-caps;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssStatus = styled('div', `
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
width: 40px;
|
||||||
|
padding: 5px;
|
||||||
|
`);
|
||||||
|
|
||||||
const cssPageContainer = styled('div', `
|
const cssPageContainer = styled('div', `
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
@ -491,3 +641,10 @@ export const cssDangerText = styled('div', `
|
|||||||
const cssHappyText = styled('span', `
|
const cssHappyText = styled('span', `
|
||||||
color: ${theme.controlFg};
|
color: ${theme.controlFg};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export const cssLabel = styled('div', `
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 5px;
|
||||||
|
`);
|
||||||
|
@ -61,6 +61,18 @@ export class BaseAPI {
|
|||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
...options.headers
|
...options.headers
|
||||||
};
|
};
|
||||||
|
// If we are in the client, and have a boot key query parameter,
|
||||||
|
// pass it on as a header to make it available for authentication.
|
||||||
|
// This is a fallback mechanism if auth is broken to access the
|
||||||
|
// admin panel.
|
||||||
|
// TODO: should this be more selective?
|
||||||
|
if (typeof window !== 'undefined' && window.location &&
|
||||||
|
window.location.pathname.endsWith('/admin')) {
|
||||||
|
const bootKey = new URLSearchParams(window.location.search).get('boot-key');
|
||||||
|
if (bootKey) {
|
||||||
|
this._headers['X-Boot-Key'] = bootKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
this._extraParameters = options.extraParameters;
|
this._extraParameters = options.extraParameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,14 +7,18 @@ export type BootProbeIds =
|
|||||||
'host-header' |
|
'host-header' |
|
||||||
'sandboxing' |
|
'sandboxing' |
|
||||||
'system-user' |
|
'system-user' |
|
||||||
'authentication'
|
'authentication' |
|
||||||
|
'websockets'
|
||||||
;
|
;
|
||||||
|
|
||||||
export interface BootProbeResult {
|
export interface BootProbeResult {
|
||||||
verdict?: string;
|
verdict?: string;
|
||||||
success?: boolean;
|
// Result of check.
|
||||||
done?: boolean;
|
// "success" is a positive outcome.
|
||||||
severity?: 'fault' | 'warning' | 'hmm';
|
// "none" means no fault detected (but that the test is not exhaustive
|
||||||
|
// enough to claim "success").
|
||||||
|
// "fault" is a bad error, "warning" a ... warning, "hmm" almost a debug message.
|
||||||
|
status: 'success' | 'fault' | 'warning' | 'hmm' | 'none';
|
||||||
details?: Record<string, any>;
|
details?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||||
|
import {BootProbeInfo, BootProbeResult} from 'app/common/BootProbe';
|
||||||
import {InstallPrefs} from 'app/common/Install';
|
import {InstallPrefs} from 'app/common/Install';
|
||||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||||
@ -56,6 +57,8 @@ export interface InstallAPI {
|
|||||||
* Returns information about latest version of Grist
|
* Returns information about latest version of Grist
|
||||||
*/
|
*/
|
||||||
checkUpdates(): Promise<LatestVersion>;
|
checkUpdates(): Promise<LatestVersion>;
|
||||||
|
getChecks(): Promise<{probes: BootProbeInfo[]}>;
|
||||||
|
runCheck(id: string): Promise<BootProbeResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
||||||
@ -78,6 +81,14 @@ export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
|||||||
return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'});
|
return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getChecks(): Promise<{probes: BootProbeInfo[]}> {
|
||||||
|
return this.requestJson(`${this._url}/api/probes`, {method: 'GET'});
|
||||||
|
}
|
||||||
|
|
||||||
|
public runCheck(id: string): Promise<BootProbeResult> {
|
||||||
|
return this.requestJson(`${this._url}/api/probes/${id}`, {method: 'GET'});
|
||||||
|
}
|
||||||
|
|
||||||
private get _url(): string {
|
private get _url(): string {
|
||||||
return addCurrentOrgToPath(this._homeUrl);
|
return addCurrentOrgToPath(this._homeUrl);
|
||||||
}
|
}
|
||||||
|
@ -193,6 +193,24 @@ export async function addRequestUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we have a boot key. This is a fallback mechanism for an
|
||||||
|
// administrator to authenticate themselves by demonstrating access
|
||||||
|
// to the environment.
|
||||||
|
if (!authDone && mreq.headers && mreq.headers['x-boot-key']) {
|
||||||
|
const reqBootKey = String(mreq.headers['x-boot-key']);
|
||||||
|
const bootKey = options.gristServer.getBootKey();
|
||||||
|
if (!bootKey || bootKey !== reqBootKey) {
|
||||||
|
return res.status(401).send('Bad request: invalid Boot key');
|
||||||
|
}
|
||||||
|
const userId = dbManager.getSupportUserId();
|
||||||
|
const user = await dbManager.getUser(userId);
|
||||||
|
mreq.user = user;
|
||||||
|
mreq.userId = userId;
|
||||||
|
mreq.users = [dbManager.makeFullUser(user!)];
|
||||||
|
mreq.userIsAuthorized = true;
|
||||||
|
authDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Special permission header for internal housekeeping tasks
|
// Special permission header for internal housekeeping tasks
|
||||||
if (!authDone && mreq.headers && mreq.headers.permit) {
|
if (!authDone && mreq.headers && mreq.headers.permit) {
|
||||||
const permitKey = String(mreq.headers.permit);
|
const permitKey = String(mreq.headers.permit);
|
||||||
|
@ -4,6 +4,7 @@ import { removeTrailingSlash } from 'app/common/gutil';
|
|||||||
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
|
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
|
||||||
import { GristServer } from 'app/server/lib/GristServer';
|
import { GristServer } from 'app/server/lib/GristServer';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
|
import WS from 'ws';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,7 +26,7 @@ export class BootProbes {
|
|||||||
|
|
||||||
public addEndpoints() {
|
public addEndpoints() {
|
||||||
// Return a list of available probes.
|
// Return a list of available probes.
|
||||||
this._app.use(`${this._base}/probe$`,
|
this._app.use(`${this._base}/probes$`,
|
||||||
...this._middleware,
|
...this._middleware,
|
||||||
expressWrap(async (_, res) => {
|
expressWrap(async (_, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@ -36,7 +37,7 @@ export class BootProbes {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Return result of running an individual probe.
|
// Return result of running an individual probe.
|
||||||
this._app.use(`${this._base}/probe/:probeId`,
|
this._app.use(`${this._base}/probes/:probeId`,
|
||||||
...this._middleware,
|
...this._middleware,
|
||||||
expressWrap(async (req, res) => {
|
expressWrap(async (req, res) => {
|
||||||
const probe = this._probeById.get(req.params.probeId);
|
const probe = this._probeById.get(req.params.probeId);
|
||||||
@ -48,7 +49,7 @@ export class BootProbes {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Fall-back for errors.
|
// Fall-back for errors.
|
||||||
this._app.use(`${this._base}/probe`, jsonErrorHandler);
|
this._app.use(`${this._base}/probes`, jsonErrorHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _addProbes() {
|
private _addProbes() {
|
||||||
@ -59,6 +60,7 @@ export class BootProbes {
|
|||||||
this._probes.push(_hostHeaderProbe);
|
this._probes.push(_hostHeaderProbe);
|
||||||
this._probes.push(_sandboxingProbe);
|
this._probes.push(_sandboxingProbe);
|
||||||
this._probes.push(_authenticationProbe);
|
this._probes.push(_authenticationProbe);
|
||||||
|
this._probes.push(_webSocketsProbe);
|
||||||
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,38 +78,78 @@ export interface Probe {
|
|||||||
|
|
||||||
const _homeUrlReachableProbe: Probe = {
|
const _homeUrlReachableProbe: Probe = {
|
||||||
id: 'reachable',
|
id: 'reachable',
|
||||||
name: 'Grist is reachable',
|
name: 'Is home page available at expected URL',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const url = server.getHomeInternalUrl();
|
const url = server.getHomeInternalUrl();
|
||||||
|
const details: Record<string, any> = {
|
||||||
|
url,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
|
details.status = resp.status;
|
||||||
if (resp.status !== 200) {
|
if (resp.status !== 200) {
|
||||||
throw new ApiError(await resp.text(), resp.status);
|
throw new ApiError(await resp.text(), resp.status);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
status: 'success',
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
|
||||||
details: {
|
details: {
|
||||||
|
...details,
|
||||||
error: String(e),
|
error: String(e),
|
||||||
},
|
},
|
||||||
severity: 'fault',
|
status: 'fault',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _webSocketsProbe: Probe = {
|
||||||
|
id: 'websockets',
|
||||||
|
name: 'Can we open a websocket with the server',
|
||||||
|
apply: async (server, req) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = new URL(server.getHomeUrl(req));
|
||||||
|
url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WS.WebSocket(url.href);
|
||||||
|
const details: Record<string, any> = {
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send('Just nod if you can hear me.');
|
||||||
|
resolve({
|
||||||
|
status: 'success',
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
ws.on('error', (ev) => {
|
||||||
|
details.error = ev.message;
|
||||||
|
resolve({
|
||||||
|
status: 'fault',
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const _statusCheckProbe: Probe = {
|
const _statusCheckProbe: Probe = {
|
||||||
id: 'health-check',
|
id: 'health-check',
|
||||||
name: 'Built-in Health check',
|
name: 'Is an internal health check passing',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const baseUrl = server.getHomeInternalUrl();
|
const baseUrl = server.getHomeInternalUrl();
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
||||||
|
const details: Record<string, any> = {
|
||||||
|
url: url.href,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
|
details.status = resp.status;
|
||||||
if (resp.status !== 200) {
|
if (resp.status !== 200) {
|
||||||
throw new Error(`Failed with status ${resp.status}`);
|
throw new Error(`Failed with status ${resp.status}`);
|
||||||
}
|
}
|
||||||
@ -116,13 +158,16 @@ const _statusCheckProbe: Probe = {
|
|||||||
throw new Error(`Failed, page has unexpected content`);
|
throw new Error(`Failed, page has unexpected content`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
status: 'success',
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
details: {
|
||||||
error: String(e),
|
...details,
|
||||||
severity: 'fault',
|
error: String(e),
|
||||||
|
},
|
||||||
|
status: 'fault',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -130,17 +175,21 @@ const _statusCheckProbe: Probe = {
|
|||||||
|
|
||||||
const _userProbe: Probe = {
|
const _userProbe: Probe = {
|
||||||
id: 'system-user',
|
id: 'system-user',
|
||||||
name: 'System user is sane',
|
name: 'Is the system user following best practice',
|
||||||
apply: async () => {
|
apply: async () => {
|
||||||
|
const details = {
|
||||||
|
uid: process.getuid ? process.getuid() : 'unavailable',
|
||||||
|
};
|
||||||
if (process.getuid && process.getuid() === 0) {
|
if (process.getuid && process.getuid() === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
details,
|
||||||
verdict: 'User appears to be root (UID 0)',
|
verdict: 'User appears to be root (UID 0)',
|
||||||
severity: 'warning',
|
status: 'warning',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: true,
|
status: 'success',
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -148,15 +197,28 @@ const _userProbe: Probe = {
|
|||||||
|
|
||||||
const _bootProbe: Probe = {
|
const _bootProbe: Probe = {
|
||||||
id: 'boot-page',
|
id: 'boot-page',
|
||||||
name: 'Boot page exposure',
|
name: 'Is the boot page adequately protected',
|
||||||
apply: async (server) => {
|
apply: async (server) => {
|
||||||
if (!server.hasBoot) {
|
const bootKey = server.getBootKey() || '';
|
||||||
return { success: true };
|
const hasBoot = Boolean(bootKey);
|
||||||
|
const details: Record<string, any> = {
|
||||||
|
bootKeySet: hasBoot,
|
||||||
|
};
|
||||||
|
if (!hasBoot) {
|
||||||
|
return { status: 'success', details };
|
||||||
|
}
|
||||||
|
details.bootKeyLength = bootKey.length;
|
||||||
|
if (bootKey.length < 10) {
|
||||||
|
return {
|
||||||
|
verdict: 'Boot key length is shorter than 10.',
|
||||||
|
details,
|
||||||
|
status: 'fault',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10;
|
|
||||||
return {
|
return {
|
||||||
success: maybeSecureEnough,
|
verdict: 'Boot key ideally should be removed after installation.',
|
||||||
severity: 'hmm',
|
details,
|
||||||
|
status: 'warning',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -170,35 +232,40 @@ const _bootProbe: Probe = {
|
|||||||
*/
|
*/
|
||||||
const _hostHeaderProbe: Probe = {
|
const _hostHeaderProbe: Probe = {
|
||||||
id: 'host-header',
|
id: 'host-header',
|
||||||
name: 'Host header is sane',
|
name: 'Does the host header look correct',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const host = req.header('host');
|
const host = req.header('host');
|
||||||
const url = new URL(server.getHomeUrl(req));
|
const url = new URL(server.getHomeUrl(req));
|
||||||
|
const details = {
|
||||||
|
homeUrlHost: url.hostname,
|
||||||
|
headerHost: host,
|
||||||
|
};
|
||||||
if (url.hostname === 'localhost') {
|
if (url.hostname === 'localhost') {
|
||||||
return {
|
return {
|
||||||
done: true,
|
status: 'none',
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
|
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
details,
|
||||||
severity: 'hmm',
|
status: 'hmm',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
done: true,
|
status: 'none',
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const _sandboxingProbe: Probe = {
|
const _sandboxingProbe: Probe = {
|
||||||
id: 'sandboxing',
|
id: 'sandboxing',
|
||||||
name: 'Sandboxing is working',
|
name: 'Is document sandboxing effective',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const details = server.getSandboxInfo();
|
const details = server.getSandboxInfo();
|
||||||
return {
|
return {
|
||||||
success: details?.configured && details?.functional,
|
status: (details?.configured && details?.functional) ? 'success' : 'fault',
|
||||||
details,
|
details,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -210,7 +277,7 @@ const _authenticationProbe: Probe = {
|
|||||||
apply: async(server, req) => {
|
apply: async(server, req) => {
|
||||||
const loginSystemId = server.getInfo('loginMiddlewareComment');
|
const loginSystemId = server.getInfo('loginMiddlewareComment');
|
||||||
return {
|
return {
|
||||||
success: loginSystemId != undefined,
|
status: (loginSystemId != undefined) ? 'success' : 'fault',
|
||||||
details: {
|
details: {
|
||||||
loginSystemId,
|
loginSystemId,
|
||||||
}
|
}
|
||||||
|
@ -181,7 +181,6 @@ export class FlexServer implements GristServer {
|
|||||||
private _getLoginSystem?: () => Promise<GristLoginSystem>;
|
private _getLoginSystem?: () => Promise<GristLoginSystem>;
|
||||||
// Set once ready() is called
|
// Set once ready() is called
|
||||||
private _isReady: boolean = false;
|
private _isReady: boolean = false;
|
||||||
private _probes: BootProbes;
|
|
||||||
private _updateManager: UpdateManager;
|
private _updateManager: UpdateManager;
|
||||||
private _sandboxInfo: SandboxInfo;
|
private _sandboxInfo: SandboxInfo;
|
||||||
|
|
||||||
@ -558,27 +557,17 @@ export class FlexServer implements GristServer {
|
|||||||
*/
|
*/
|
||||||
public addBootPage() {
|
public addBootPage() {
|
||||||
if (this._check('boot')) { return; }
|
if (this._check('boot')) { return; }
|
||||||
const bootKey = appSettings.section('boot').flag('key').readString({
|
|
||||||
envVar: 'GRIST_BOOT_KEY'
|
|
||||||
});
|
|
||||||
const base = `/boot/${bootKey}`;
|
|
||||||
this._probes = new BootProbes(this.app, this, base);
|
|
||||||
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
|
|
||||||
// a helpful message even if user gets KEY wrong or omits it.
|
|
||||||
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
|
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
|
||||||
const goodKey = bootKey && req.params.bootKey === bootKey;
|
// Doing a good redirect is actually pretty subtle and we might
|
||||||
return this._sendAppPage(req, res, {
|
// get it wrong, so just say /boot got moved.
|
||||||
path: 'boot.html', status: 200, config: goodKey ? {
|
res.send('The /boot/KEY page is now /admin?boot-key=KEY');
|
||||||
} : {
|
|
||||||
errMessage: 'not-the-key',
|
|
||||||
}, tag: 'boot',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
this._probes.addEndpoints();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasBoot(): boolean {
|
public getBootKey(): string|undefined {
|
||||||
return Boolean(this._probes);
|
return appSettings.section('boot').flag('key').readString({
|
||||||
|
envVar: 'GRIST_BOOT_KEY'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public denyRequestsIfNotReady() {
|
public denyRequestsIfNotReady() {
|
||||||
@ -1879,22 +1868,21 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
|
const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
|
||||||
|
|
||||||
const adminPageMiddleware = [
|
// Admin endpoint needs to have very little middleware since each
|
||||||
this._redirectToHostMiddleware,
|
// piece of middleware creates a new way to fail and leave the admin
|
||||||
this._userIdMiddleware,
|
// panel inaccessible. Generally the admin panel should report problems
|
||||||
this._redirectToLoginWithoutExceptionsMiddleware,
|
// rather than failing entirely.
|
||||||
// In principle, it may be safe to show the Admin Panel to non-admins but let's protect it
|
this.app.get('/admin', this._userIdMiddleware, expressWrap(async (req, resp) => {
|
||||||
// since it's intended for admins, and it's easier not to have to worry how it should behave
|
|
||||||
// for others.
|
|
||||||
requireInstallAdmin,
|
|
||||||
];
|
|
||||||
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
|
|
||||||
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
||||||
}));
|
}));
|
||||||
const probes = new BootProbes(this.app, this, '/admin', adminPageMiddleware);
|
const adminMiddleware = [
|
||||||
|
this._userIdMiddleware,
|
||||||
|
requireInstallAdmin,
|
||||||
|
];
|
||||||
|
const probes = new BootProbes(this.app, this, '/api', adminMiddleware);
|
||||||
probes.addEndpoints();
|
probes.addEndpoints();
|
||||||
|
|
||||||
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
|
// Restrict this endpoint to install admins
|
||||||
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
|
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
|
||||||
const activation = await this._activations.current();
|
const activation = await this._activations.current();
|
||||||
|
|
||||||
@ -1922,7 +1910,7 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
// GET api/checkUpdates
|
// GET api/checkUpdates
|
||||||
// Retrieves the latest version of the client from Grist SAAS endpoint.
|
// Retrieves the latest version of the client from Grist SAAS endpoint.
|
||||||
this.app.get('/api/install/updates', adminPageMiddleware, expressWrap(async (req, res) => {
|
this.app.get('/api/install/updates', adminMiddleware, expressWrap(async (req, res) => {
|
||||||
// Prepare data for the telemetry that endpoint might expect.
|
// Prepare data for the telemetry that endpoint might expect.
|
||||||
const installationId = (await this.getActivations().current()).id;
|
const installationId = (await this.getActivations().current()).id;
|
||||||
const deploymentType = this.getDeploymentType();
|
const deploymentType = this.getDeploymentType();
|
||||||
|
@ -65,7 +65,7 @@ export interface GristServer {
|
|||||||
getPlugins(): LocalPlugin[];
|
getPlugins(): LocalPlugin[];
|
||||||
servesPlugins(): boolean;
|
servesPlugins(): boolean;
|
||||||
getBundledWidgets(): ICustomWidget[];
|
getBundledWidgets(): ICustomWidget[];
|
||||||
hasBoot(): boolean;
|
getBootKey(): string|undefined;
|
||||||
getSandboxInfo(): SandboxInfo|undefined;
|
getSandboxInfo(): SandboxInfo|undefined;
|
||||||
getInfo(key: string): any;
|
getInfo(key: string): any;
|
||||||
}
|
}
|
||||||
@ -158,7 +158,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
servesPlugins() { return false; },
|
servesPlugins() { return false; },
|
||||||
getPlugins() { return []; },
|
getPlugins() { return []; },
|
||||||
getBundledWidgets() { return []; },
|
getBundledWidgets() { return []; },
|
||||||
hasBoot() { return false; },
|
getBootKey() { return undefined; },
|
||||||
getSandboxInfo() { return undefined; },
|
getSandboxInfo() { return undefined; },
|
||||||
getInfo(key: string) { return undefined; }
|
getInfo(key: string) { return undefined; }
|
||||||
};
|
};
|
||||||
|
@ -158,6 +158,6 @@ export function makeSimpleCreator(opts: {
|
|||||||
},
|
},
|
||||||
getSqliteVariant: opts.getSqliteVariant,
|
getSqliteVariant: opts.getSqliteVariant,
|
||||||
getSandboxVariants: opts.getSandboxVariants,
|
getSandboxVariants: opts.getSandboxVariants,
|
||||||
createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()),
|
createInstallAdmin: opts.createInstallAdmin || (async (dbManager) => new SimpleInstallAdmin(dbManager)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {appSettings} from 'app/server/lib/AppSettings';
|
import {appSettings} from 'app/server/lib/AppSettings';
|
||||||
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
@ -40,13 +41,19 @@ export abstract class InstallAdmin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
|
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
|
||||||
// installation admin. If not given, then there is no admin.
|
// installation admin. The support user is also accepted.
|
||||||
|
// Otherwise, there is no admin.
|
||||||
export class SimpleInstallAdmin extends InstallAdmin {
|
export class SimpleInstallAdmin extends InstallAdmin {
|
||||||
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
|
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
|
||||||
envVar: 'GRIST_DEFAULT_EMAIL',
|
envVar: 'GRIST_DEFAULT_EMAIL',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public constructor(private _dbManager: HomeDBManager) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
public override async isAdminUser(user: User): Promise<boolean> {
|
public override async isAdminUser(user: User): Promise<boolean> {
|
||||||
|
if (user.id === this._dbManager.getSupportUserId()) { return true; }
|
||||||
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
|
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ module.exports = {
|
|||||||
main: "app/client/app",
|
main: "app/client/app",
|
||||||
errorPages: "app/client/errorMain",
|
errorPages: "app/client/errorMain",
|
||||||
apiconsole: "app/client/apiconsole",
|
apiconsole: "app/client/apiconsole",
|
||||||
boot: "app/client/boot",
|
|
||||||
billing: "app/client/billingMain",
|
billing: "app/client/billingMain",
|
||||||
form: "app/client/formMain",
|
form: "app/client/formMain",
|
||||||
// Include client test harness if it is present (it won't be in
|
// Include client test harness if it is present (it won't be in
|
||||||
|
@ -7,7 +7,8 @@
|
|||||||
"repository": "git://github.com/gristlabs/grist-core.git",
|
"repository": "git://github.com/gristlabs/grist-core.git",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "sandbox/watch.sh",
|
"start": "sandbox/watch.sh",
|
||||||
"start:debug": "NODE_INSPECT=1 sandbox/watch.sh",
|
"start:debug": "NODE_INSPECT=--inspect sandbox/watch.sh",
|
||||||
|
"start:debug-brk": "NODE_INSPECT=--inspect-brk sandbox/watch.sh",
|
||||||
"install:python": "buildtools/prepare_python.sh",
|
"install:python": "buildtools/prepare_python.sh",
|
||||||
"install:python2": "buildtools/prepare_python2.sh",
|
"install:python2": "buildtools/prepare_python2.sh",
|
||||||
"install:python3": "buildtools/prepare_python3.sh",
|
"install:python3": "buildtools/prepare_python3.sh",
|
||||||
|
@ -19,6 +19,6 @@ tsc --build -w --preserveWatchOutput $PROJECT &
|
|||||||
css_files="app/client/**/*.css"
|
css_files="app/client/**/*.css"
|
||||||
chokidar "${css_files}" -c "bash -O globstar -c 'cat ${css_files} > static/bundle.css'" &
|
chokidar "${css_files}" -c "bash -O globstar -c 'cat ${css_files} > static/bundle.css'" &
|
||||||
webpack --config $WEBPACK_CONFIG --mode development --watch &
|
webpack --config $WEBPACK_CONFIG --mode development --watch &
|
||||||
NODE_PATH=_build:_build/stubs:_build/ext nodemon ${NODE_INSPECT:+--inspect} --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &
|
NODE_PATH=_build:_build/stubs:_build/ext nodemon ${NODE_INSPECT} --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf8">
|
|
||||||
<!-- INSERT BASE -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
|
||||||
<link rel="stylesheet" href="icons/icons.css">
|
|
||||||
<!-- INSERT LOCALE -->
|
|
||||||
<!-- INSERT CONFIG -->
|
|
||||||
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script crossorigin="anonymous" src="boot.bundle.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -31,7 +31,7 @@ describe('AdminPanel', function() {
|
|||||||
await server.restart(true);
|
await server.restart(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not be shown to non-managers', async function() {
|
it('should show an explanation to non-managers', async function() {
|
||||||
session = await gu.session().user('user2').personalSite.login();
|
session = await gu.session().user('user2').personalSite.login();
|
||||||
await session.loadDocMenu('/');
|
await session.loadDocMenu('/');
|
||||||
|
|
||||||
@ -42,8 +42,9 @@ describe('AdminPanel', function() {
|
|||||||
|
|
||||||
// Try loading the URL directly.
|
// Try loading the URL directly.
|
||||||
await driver.get(`${server.getHost()}/admin`);
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
await waitForAdminPanel();
|
||||||
assert.equal(await driver.find('.test-admin-panel').isPresent(), false);
|
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
|
||||||
|
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be shown to managers', async function() {
|
it('should be shown to managers', async function() {
|
||||||
@ -192,6 +193,23 @@ describe('AdminPanel', function() {
|
|||||||
// useful there yet.
|
// useful there yet.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show various self checks', async function() {
|
||||||
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
await gu.waitToPass(
|
||||||
|
async () => {
|
||||||
|
assert.equal(await driver.find('.test-admin-panel-item-name-probe-reachable').isDisplayed(), true);
|
||||||
|
assert.match(await driver.find('.test-admin-panel-item-value-probe-reachable').getText(), /✅/);
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
assert.equal(await driver.find('.test-admin-panel-item-name-probe-system-user').isDisplayed(), true);
|
||||||
|
await gu.waitToPass(
|
||||||
|
async () => assert.match(await driver.find('.test-admin-panel-item-value-probe-system-user').getText(), /✅/),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
|
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
|
||||||
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
|
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
|
||||||
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
|
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
|
||||||
@ -313,6 +331,33 @@ describe('AdminPanel', function() {
|
|||||||
});
|
});
|
||||||
assert.isNotEmpty(fakeServer.payload.installationId);
|
assert.isNotEmpty(fakeServer.payload.installationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should survive APP_HOME_URL misconfiguration', async function() {
|
||||||
|
process.env.APP_HOME_URL = 'http://misconfigured.invalid';
|
||||||
|
process.env.GRIST_BOOT_KEY = 'zig';
|
||||||
|
await server.restart(true);
|
||||||
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should honor GRIST_BOOT_KEY fallback', async function() {
|
||||||
|
await gu.removeLogin();
|
||||||
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
|
||||||
|
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
|
||||||
|
|
||||||
|
process.env.GRIST_BOOT_KEY = 'zig';
|
||||||
|
await server.restart(true);
|
||||||
|
await driver.get(`${server.getHost()}/admin?boot-key=zig`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
|
||||||
|
assert.notMatch(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
|
||||||
|
await driver.get(`${server.getHost()}/admin?boot-key=zig-wrong`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
|
||||||
|
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function assertTelemetryLevel(level: TelemetryLevel) {
|
async function assertTelemetryLevel(level: TelemetryLevel) {
|
||||||
|
@ -3,6 +3,10 @@ import * as gu from 'test/nbrowser/gristUtils';
|
|||||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||||
import * as testUtils from 'test/server/testUtils';
|
import * as testUtils from 'test/server/testUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boot page functionality has been merged with the Admin Panel.
|
||||||
|
* Check that it behaves as a boot page did now.
|
||||||
|
*/
|
||||||
describe('Boot', function() {
|
describe('Boot', function() {
|
||||||
this.timeout(30000);
|
this.timeout(30000);
|
||||||
setupTestSuite();
|
setupTestSuite();
|
||||||
@ -12,13 +16,24 @@ describe('Boot', function() {
|
|||||||
afterEach(() => gu.checkForErrors());
|
afterEach(() => gu.checkForErrors());
|
||||||
|
|
||||||
async function hasPrompt() {
|
async function hasPrompt() {
|
||||||
assert.include(
|
// There is some glitchiness to when the text appears.
|
||||||
await driver.findContentWait('p', /diagnostics page/, 2000).getText(),
|
await gu.waitToPass(async () => {
|
||||||
'A diagnostics page can be made available');
|
assert.include(
|
||||||
|
await driver.findContentWait('pre', /GRIST_BOOT_KEY/, 2000).getText(),
|
||||||
|
'GRIST_BOOT_KEY=example-');
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('gives prompt about how to enable boot page', async function() {
|
it('tells user about /admin', async function() {
|
||||||
await driver.get(`${server.getHost()}/boot`);
|
await driver.get(`${server.getHost()}/boot`);
|
||||||
|
assert.match(await driver.getPageSource(), /\/admin/);
|
||||||
|
// Switch to a regular place to that gu.checkForErrors won't panic -
|
||||||
|
// it needs a Grist page.
|
||||||
|
await driver.get(`${server.getHost()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives prompt about how to enable boot page', async function() {
|
||||||
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
await hasPrompt();
|
await hasPrompt();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -35,18 +50,18 @@ describe('Boot', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('gives prompt when key is missing', async function() {
|
it('gives prompt when key is missing', async function() {
|
||||||
await driver.get(`${server.getHost()}/boot`);
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
await hasPrompt();
|
await hasPrompt();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives prompt when key is wrong', async function() {
|
it('gives prompt when key is wrong', async function() {
|
||||||
await driver.get(`${server.getHost()}/boot/bilbo`);
|
await driver.get(`${server.getHost()}/admin?boot-key=bilbo`);
|
||||||
await hasPrompt();
|
await hasPrompt();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives page when key is right', async function() {
|
it('gives page when key is right', async function() {
|
||||||
await driver.get(`${server.getHost()}/boot/lala`);
|
await driver.get(`${server.getHost()}/admin?boot-key=lala`);
|
||||||
await driver.findContentWait('h2', /Grist is reachable/, 2000);
|
await driver.findContentWait('div', /Is home page available/, 2000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user