(core) Add Support Grist page and nudge

Summary:
Adds a new Support Grist page (accessible only in grist-core), containing
options to opt in to telemetry and sponsor Grist Labs on GitHub.

A nudge is also shown in the doc menu, which can be collapsed or permanently
dismissed.

Test Plan: Browser and server tests.

Reviewers: paulfitz, dsagal

Reviewed By: paulfitz

Subscribers: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D3926
pull/563/head
George Gevoian 11 months ago
parent 051c6d52fe
commit 35237a5835

@ -1,5 +0,0 @@
import {AccountPage} from 'app/client/ui/AccountPage';
import {setupPage} from 'app/client/ui/setupPage';
import {dom} from 'grainjs';
setupPage((appModel) => dom.create(AccountPage, appModel));

@ -1,5 +0,0 @@
import {ActivationPage} from 'app/client/ui/ActivationPage';
import {setupPage} from 'app/client/ui/setupPage';
import {dom} from 'grainjs';
setupPage((appModel) => dom.create(ActivationPage, appModel));

@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable {
}
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
if (
this._isDisabled ||
// Don't show tips if surveying is disabled.
// TODO: Move this into a dedicated variable - this is only a short-term fix for hiding
// tips in grist-core.
(!getGristConfig().survey && prompt !== 'rickRow') ||
// Or if this tip shouldn't be shown on mobile.
(isNarrowScreen() && !options.showOnMobile) ||
// Or if "Don't show tips" was checked in the past.
(this._prefs.get().dontShowTips && !options.forceShow) ||
// Or if this tip has been shown and dismissed in the past.
this.hasSeenTip(prompt)
) {
return;
}
if (!this._shouldQueueTip(prompt, options)) { return; }
this._queuedTips.push({prompt, refElement, options});
if (this._queuedTips.length > 1) {
@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable {
this._prefs.set({...this._prefs.get(), dontShowTips: true});
this._queuedTips = [];
}
private _shouldQueueTip(prompt: BehavioralPrompt, options: AttachOptions) {
if (
this._isDisabled ||
(isNarrowScreen() && !options.showOnMobile) ||
(this._prefs.get().dontShowTips && !options.forceShow) ||
this.hasSeenTip(prompt)
) {
return false;
}
const {deploymentType} = getGristConfig();
const {deploymentTypes} = GristBehavioralPrompts[prompt];
if (
deploymentTypes !== '*' &&
(!deploymentType || !deploymentTypes.includes(deploymentType))
) {
return false;
}
return true;
}
}

@ -1,4 +1,7 @@
import * as AccountPageModule from 'app/client/ui/AccountPage';
import * as ActivationPageModule from 'app/client/ui/ActivationPage';
import * as BillingPageModule from 'app/client/ui/BillingPage';
import * as SupportGristPageModule from 'app/client/ui/SupportGristPage';
import * as GristDocModule from 'app/client/components/GristDoc';
import * as ViewPane from 'app/client/components/ViewPane';
import * as UserManagerModule from 'app/client/ui/UserManager';
@ -9,7 +12,10 @@ import * as plotly from 'plotly.js';
export type PlotlyType = typeof plotly;
export type MomentTimezone = typeof momentTimezone;
export function loadAccountPage(): Promise<typeof AccountPageModule>;
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadSupportGristPage(): Promise<typeof SupportGristPageModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;

@ -6,7 +6,10 @@
*
*/
exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */);
exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */);
exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */);
exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */);
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
// When importing this way, the module is under the "default" member, not sure why (maybe
// esbuild-loader's doing).

@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState';
import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {OrgUsageSummary} from 'app/common/DocUsage';
import {Features, isLegacyPlan, Product} from 'app/common/Features';
@ -31,7 +32,15 @@ const t = makeT('AppModel');
// Reexported for convenience.
export {reportError} from 'app/client/models/errors';
export type PageType = "doc" | "home" | "billing" | "welcome";
export type PageType =
| "doc"
| "home"
| "billing"
| "welcome"
| "account"
| "support-grist"
| "activation";
const G = getBrowserGlobals('document', 'window');
// TopAppModel is the part of the app model that persists across org and user switches.
@ -107,6 +116,8 @@ export interface AppModel {
behavioralPromptsManager: BehavioralPromptsManager;
supportGristNudge: SupportGristNudge;
refreshOrgUsage(): Promise<void>;
showUpgradeModal(): void;
showNewSiteModal(): void;
@ -253,7 +264,23 @@ export class AppModelImpl extends Disposable implements AppModel {
// Get the current PageType from the URL.
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
(use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home"))));
(_use, state) => {
if (state.doc) {
return 'doc';
} else if (state.billing) {
return 'billing';
} else if (state.welcome) {
return 'welcome';
} else if (state.account) {
return 'account';
} else if (state.supportGrist) {
return 'support-grist';
} else if (state.activation) {
return 'activation';
} else {
return 'home';
}
});
public readonly needsOrg: Observable<boolean> = Computed.create(
this, urlState().state, (use, state) => {
@ -265,6 +292,8 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly behavioralPromptsManager: BehavioralPromptsManager =
BehavioralPromptsManager.create(this, this);
public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this);
constructor(
public readonly topAppModel: TopAppModel,
public readonly currentUser: FullUser|null,

@ -0,0 +1,32 @@
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
import {TelemetryPrefs} from 'app/common/Install';
import {InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {bundleChanges, Disposable, Observable} from 'grainjs';
export interface TelemetryModel {
/** Telemetry preferences (e.g. the current telemetry level). */
readonly prefs: Observable<TelemetryPrefsWithSources | null>;
fetchTelemetryPrefs(): Promise<void>;
updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void>;
}
export class TelemetryModelImpl extends Disposable implements TelemetryModel {
public readonly prefs: Observable<TelemetryPrefsWithSources | null> = Observable.create(this, null);
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
constructor(_appModel: AppModel) {
super();
}
public async fetchTelemetryPrefs(): Promise<void> {
const prefs = await this._installAPI.getInstallPrefs();
bundleChanges(() => {
this.prefs.set(prefs.telemetry);
});
}
public async updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void> {
await this._installAPI.updateInstallPrefs({telemetry: prefs});
await this.fetchTelemetryPrefs();
}
}

@ -156,7 +156,8 @@ export class UrlStateImpl {
*/
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
newState.account || newState.billing || newState.activation || newState.welcome) ?
newState.account || newState.billing || newState.activation || newState.welcome ||
newState.supportGrist) ?
(prevState.org ? {org: prevState.org} : {}) :
prevState;
return {...keepState, ...newState};
@ -186,8 +187,11 @@ export class UrlStateImpl {
// Reload when moving to/from the Grist sign-up page.
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
return Boolean(orgReload || accountReload || billingReload || activationReload
|| gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload);
// Reload when moving to/from the support Grist page.
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist);
return Boolean(orgReload || accountReload || billingReload || activationReload ||
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
supportGristReload);
}
/**

@ -65,7 +65,7 @@ export class AccountWidget extends Disposable {
t("Toggle Mobile Mode"),
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
testId('usermenu-toggle-mobile'),
);
);
if (!user) {
return [
@ -100,6 +100,7 @@ export class AccountWidget extends Disposable {
this._maybeBuildBillingPageMenuItem(),
this._maybeBuildActivationPageMenuItem(),
this._maybeBuildSupportGristPageMenuItem(),
mobileModeToggle,
@ -155,10 +156,10 @@ export class AccountWidget extends Disposable {
// For links, disabling with just a class is hard; easier to just not make it a link.
// TODO weasel menus should support disabling menuItemLink.
(isBillingManager ?
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) :
menuItem(() => null, t('Billing Account'), dom.cls('disabled', true))
) :
menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan');
menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan'));
}
private _maybeBuildActivationPageMenuItem() {
@ -167,7 +168,21 @@ export class AccountWidget extends Disposable {
return null;
}
return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'}));
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
}
private _maybeBuildSupportGristPageMenuItem() {
const {deploymentType} = getGristConfig();
if (deploymentType !== 'core') {
return null;
}
return menuItemLink(
t('Support Grist'),
cssHeartIcon('💛'),
urlState().setLinkUrl({supportGrist: 'support-grist'}),
testId('usermenu-support-grist'),
);
}
}
@ -183,6 +198,10 @@ export const cssUserIcon = styled('div', `
cursor: pointer;
`);
const cssHeartIcon = styled('span', `
margin-left: 8px;
`);
const cssUserInfo = styled('div', `
padding: 12px 24px 12px 16px;
min-width: 200px;

@ -1,7 +1,7 @@
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
import {domAsync} from 'app/client/lib/domAsync';
import {loadBillingPage} from 'app/client/lib/imports';
import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports';
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
@ -75,6 +75,12 @@ function createMainPage(appModel: AppModel, appObj: App) {
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
} else if (pageType === 'welcome') {
return dom.create(WelcomePage, appModel);
} else if (pageType === 'account') {
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel)));
} else if (pageType === 'support-grist') {
return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel)));
} else if (pageType === 'activation') {
return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel)));
} else {
return dom.create(pagePanelsDoc, appModel, appObj);
}

@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
testId('doclist')
),
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
() => upgradeButton.showUpgradeCard(css.upgradeCard.cls(''))),
() => {
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
// manage card popups will be needed if more are added later.
return [
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
home.app.supportGristNudge.showCard(),
];
}),
));
}

@ -1,4 +1,5 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip';
export class DocTutorial extends FloatingPopup {
private _appModel = this._gristDoc.docPageModel.appModel;
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
private _currentFork = this._currentDoc?.forks?.[0];
private _docComm = this._gristDoc.docComm;
private _docData = this._gristDoc.docData;
private _docId = this._gristDoc.docId();
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
private _currentSlideIndex = Observable.create(this,
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup {
private async _saveCurrentSlidePosition() {
const currentOptions = this._currentDoc?.options ?? {};
const currentSlideIndex = this._currentSlideIndex.get();
const numSlides = this._slides.get()?.length;
await this._appModel.api.updateDoc(this._docId, {
options: {
...currentOptions,
tutorial: {
lastSlideIndex: this._currentSlideIndex.get(),
lastSlideIndex: currentSlideIndex,
}
}
});
let percentComplete: number | undefined = undefined;
if (numSlides !== undefined && numSlides > 0) {
percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100);
}
logTelemetryEvent('tutorialProgressChanged', {
full: {
tutorialForkIdDigest: this._currentFork?.id,
tutorialTrunkIdDigest: this._currentFork?.trunkId,
lastSlideIndex: currentSlideIndex,
numSlides,
percentComplete,
},
});
}
private async _changeSlide(slideIndex: number) {

@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization';
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {commonUrls, GristDeploymentType} from 'app/common/gristUrls';
import {BehavioralPrompt} from 'app/common/Prefs';
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
@ -104,6 +104,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
export interface BehavioralPromptContent {
title: () => string;
content: (...domArgs: DomElementArg[]) => DomContents;
deploymentTypes: GristDeploymentType[] | '*';
}
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: ['saas'],
},
referenceColumnsConfig: {
title: () => t('Reference Columns'),
@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: ['saas'],
},
rawDataPage: {
title: () => t('Raw Data page'),
@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
accessRules: {
title: () => t('Access Rules'),
@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
filterButtons: {
title: () => t('Pinning Filters'),
@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
nestedFiltering: {
title: () => t('Nested Filtering'),
@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', t('Only those rows will appear which match all of the filters.')),
...args,
),
deploymentTypes: ['saas'],
},
pageWidgetPicker: {
title: () => t('Selecting Data'),
@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
...args,
),
deploymentTypes: ['saas'],
},
pageWidgetPickerSelectBy: {
title: () => t('Linking Widgets'),
@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
editCardLayout: {
title: () => t('Editing Card Layout'),
@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
})),
...args,
),
deploymentTypes: ['saas'],
},
addNew: {
title: () => t('Add New'),
@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
+ 'or import data.')),
...args,
),
deploymentTypes: ['saas'],
},
rickRow: {
title: () => t('Anchor Links'),
@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: '*',
},
customURL: {
title: () => t('Custom Widgets'),
@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
};

@ -0,0 +1,326 @@
import {makeT} from 'app/client/lib/localization';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {getStorage} from 'app/client/lib/storage';
import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-nudge-');
const t = makeT('SupportGristNudge');
type ButtonState =
| 'collapsed'
| 'expanded';
type CardPage =
| 'support-grist'
| 'opted-in';
/**
* Nudges users to support Grist by opting in to telemetry.
*
* This currently includes a button that opens a card with the nudge.
* The button is hidden when the card is visible, and vice versa.
*/
export class SupportGristNudge extends Disposable {
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _buttonState: Observable<ButtonState>;
private readonly _currentPage: Observable<CardPage>;
private readonly _isClosed: Observable<boolean>;
constructor(private _appModel: AppModel) {
super();
if (!this._shouldShowCardOrButton()) { return; }
this._buttonState = localStorageObs(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded'
) as Observable<ButtonState>;
this._currentPage = Observable.create(null, 'support-grist');
this._isClosed = Observable.create(this, false);
}
public showButton() {
if (!this._shouldShowCardOrButton()) { return null; }
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)),
() => this._buildButton()
);
}
public showCard() {
if (!this._shouldShowCardOrButton()) { return null; }
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)),
() => this._buildCard()
);
}
private _markAsDismissed() {
this._appModel.dismissedPopup('supportGrist').set(true);
getStorage().removeItem(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`);
}
private _close() {
this._isClosed.set(true);
}
private _dismissAndClose() {
this._markAsDismissed();
this._close();
}
private _shouldShowCardOrButton() {
if (this._appModel.dismissedPopups.get().includes('supportGrist')) {
return false;
}
const {activation, deploymentType, telemetry} = getGristConfig();
if (deploymentType !== 'core' || !activation?.isManager) {
return false;
}
if (telemetry && telemetry.telemetryLevel !== 'off') {
return false;
}
return true;
}
private _buildButton() {
return cssContributeButton(
cssButtonIconAndText(
icon('Fireworks'),
t('Contribute'),
),
cssContributeButtonCloseButton(
icon('CrossSmall'),
dom.on('click', (ev) => {
ev.stopPropagation();
this._dismissAndClose();
}),
testId('contribute-button-close'),
),
dom.on('click', () => { this._buttonState.set('expanded'); }),
testId('contribute-button'),
);
}
private _buildCard() {
return cssCard(
dom.domComputed(this._currentPage, page => {
if (page === 'support-grist') {
return this._buildSupportGristCardContent();
} else {
return this._buildOptedInCardContent();
}
}),
testId('card'),
);
}
private _buildSupportGristCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')),
testId('card-close'),
),
cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t(
'Opt in to telemetry to help us understand how the product ' +
'is used, so that we can prioritize future improvements.'
)),
cssParagraph(
t(
'We only collect usage statistics, as detailed in our {{helpCenterLink}}, never ' +
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
{
helpCenterLink: helpCenterLink(),
supportGristLink: supportGristLink(),
},
),
),
cssFullWidthButton(
t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()),
testId('card-opt-in'),
),
];
}
private _buildOptedInCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._close()),
testId('card-close-icon-button'),
),
cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')),
cssParagraph(
t(
'Thank you! Your trust and support is greatly appreciated. ' +
'Opt out any time from the {{link}} in the user menu.',
{link: supportGristLink()},
),
),
cssCenteredFlex(
cssPrimaryButton(
t('Close'),
dom.on('click', () => this._close()),
testId('card-close-button'),
),
),
];
}
private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._currentPage.set('opted-in');
this._markAsDismissed();
}
}
function helpCenterLink() {
return cssLink(
t('Help Center'),
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
);
}
function supportGristLink() {
return cssLink(
t('Support Grist page'),
{href: urlState().makeUrl({supportGrist: 'support-grist'}), target: '_blank'},
);
}
const cssCenteredFlex = styled('div', `
display: flex;
justify-content: center;
align-items: center;
`);
const cssContributeButton = styled('div', `
position: relative;
background: ${theme.controlPrimaryBg};
color: ${theme.controlPrimaryFg};
border-radius: 25px;
padding: 4px 12px 4px 8px;
font-style: normal;
font-weight: medium;
font-size: 13px;
line-height: 16px;
cursor: pointer;
--icon-color: ${theme.controlPrimaryFg};
&:hover {
background: ${theme.controlPrimaryHoverBg};
}
`);
const cssButtonIconAndText = styled('div', `
display: flex;
gap: 8px;
`);
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
margin-left: 4px;
vertical-align: bottom;
line-height: 1;
position: absolute;
top: -4px;
right: -8px;
border-radius: 16px;
background-color: ${colors.dark};
width: 18px;
height: 18px;
cursor: pointer;
z-index: 1;
display: none;
align-items: center;
justify-content: center;
.${cssContributeButton.className}:hover & {
display: flex;
}
`);
const cssCard = styled('div', `
width: 297px;
padding: 24px;
background: #DCF4EB;
border-radius: 4px;
align-self: flex-start;
position: sticky;
flex-shrink: 0;
top: 0px;
`);
const cssHeader = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: 600;
margin-bottom: 16px;
`);
const cssLeftAlignedHeader = styled(cssHeader, `
text-align: left;
`);
const cssCenterAlignedHeader = styled(cssHeader, `
text-align: center;
`);
const cssParagraph = styled('div', `
font-size: 13px;
line-height: 18px;
margin-bottom: 12px;
`);
const cssPrimaryButton = styled(bigPrimaryButton, `
display: flex;
justify-content: center;
align-items: center;
margin-top: 32px;
text-align: center;
`);
const cssFullWidthButton = styled(cssPrimaryButton, `
width: 100%;
`);
const cssCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.mediumGreyOpaque};
}
`);
const cssSparks = styled('div', `
height: 48px;
width: 48px;
background-image: var(--icon-Sparks);
display: inline-block;
background-repeat: no-repeat;
`);

@ -0,0 +1,289 @@
import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {commonUrls} from 'app/common/gristUrls';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-page-');
const t = makeT('SupportGristPage');
export class SupportGristPage extends Disposable {
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
(_use, prefs) => {
if (!prefs) { return null; }
return prefs.telemetryLevel.value !== 'off';
})
.onWrite(async (optIn) => {
const telemetryLevel = optIn ? 'limited' : 'off';
await this._model.updateTelemetryPrefs({telemetryLevel});
});
constructor(private _appModel: AppModel) {
super();
this._model.fetchTelemetryPrefs().catch(reportError);
}
public buildDom() {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._buildMainHeader(),
contentTop: buildHomeBanners(this._appModel),
contentMain: this._buildMainContent(),
});
}
private _buildMainHeader() {
return dom.frag(
cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink(
urlState().setLinkUrl({}),
t('Home'),
),
separator(' / '),
dom('span', t('Support Grist')),
),
createTopBarHome(this._appModel),
);
}
private _buildMainContent() {
return cssPageContainer(
cssPage(
dom('div',
cssPageTitle(t('Support Grist')),
this._buildTelemetrySection(),
this._buildSponsorshipSection(),
),
),
);
}
private _buildTelemetrySection() {
return cssSection(
cssSectionTitle(t('Telemetry')),
dom.domComputed(this._model.prefs, prefs => {
if (prefs === null) {
return cssSpinnerBox(loadingSpinner());
}
const {activation} = getGristConfig();
if (!activation?.isManager) {
if (prefs.telemetryLevel.value === 'limited') {
return [
cssParagraph(t(
'This instance is opted in to telemetry. Only the site administrator has permission to change this.',
))
];
} else {
return [
cssParagraph(t(
'This instance is opted out of telemetry. Only the site administrator has permission to change this.',
))
];
}
} else {
return [
cssParagraph(t(
'Support Grist by opting in to telemetry, which helps us understand how the product ' +
'is used, so that we can prioritize future improvements.'
)),
cssParagraph(
t('We only collect usage statistics, as detailed in our {{link}}, never document contents.', {
link: telemetryHelpCenterLink(),
}),
),
cssParagraph(t('You can opt out of telemetry at any time from this page.')),
this._buildTelemetrySectionButtons(prefs),
];
}
}),
testId('telemetry-section'),
);
}
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
const {telemetryLevel: {value, source}} = prefs;
if (source === 'preferences') {
return dom.domComputed(this._optInToTelemetry, (optedIn) => {
if (optedIn) {
return [
cssOptInOutMessage(
t('You have opted in to telemetry. Thank you!'), ' 🙏',
testId('telemetry-section-message'),
),
cssOptOutButton(t('Opt out of Telemetry'),
dom.on('click', () => this._optInToTelemetry.set(false)),
),
];
} else {
return [
cssOptInButton(t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry.set(true)),
),
];
}
});
} else {
return cssOptInOutMessage(
value !== 'off'
? [t('You have opted in to telemetry. Thank you!'), ' 🙏']
: t('You have opted out of telemetry.'),
testId('telemetry-section-message'),
);
}
}
private _buildSponsorshipSection() {
return cssSection(
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
cssParagraph(
t(
'Grist software is developed by Grist Labs, which offers free and paid ' +
'hosted plans. We also make Grist code available under a standard free ' +
'and open OSS license (Apache 2.0) on {{link}}.',
{link: gristCoreLink()},
),
),
cssParagraph(
t(
'You can support Grist open-source development by sponsoring ' +
'us on our {{link}}.',
{link: sponsorGristLink()},
),
),
cssParagraph(t(
'We are a small and determined team. Your support matters a lot to us. ' +
'It also shows to others that there is a determined community behind this product.'
)),
cssSponsorButton(
cssButtonIconAndText(icon('Heart'), cssButtonText(t('Manage Sponsorship'))),
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
),
testId('sponsorship-section'),
);
}
}
function telemetryHelpCenterLink() {
return cssLink(
t('Help Center'),
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
);
}
function sponsorGristLink() {
return cssLink(
t('GitHub Sponsors page'),
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
);
}
function gristCoreLink() {
return cssLink(
t('GitHub'),
{href: commonUrls.githubGristCore, target: '_blank'},
);
}
const cssPageContainer = styled('div', `
overflow: auto;
padding: 64px 80px;
@media ${mediaSmall} {
& {
padding: 0px;
}
}
`);
const cssPage = styled('div', `
padding: 16px;
max-width: 600px;
width: 100%;
`);
const cssPageTitle = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 24px;
color: ${theme.text};
font-size: 24px;
font-weight: ${vars.headerControlTextWeight};
`);
const cssSectionTitle = styled('div', `
height: 24px;
line-height: 24px;
margin-bottom: 24px;
color: ${theme.text};
font-size: ${vars.xlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const cssSection = styled('div', `
margin-bottom: 60px;
`);
const cssParagraph = styled('div', `
color: ${theme.text};
font-size: 14px;
line-height: 20px;
margin-bottom: 12px;
`);
const cssOptInOutMessage = styled(cssParagraph, `
line-height: 40px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 0px;
`);
const cssOptInButton = styled(bigPrimaryButton, `
margin-top: 24px;
`);
const cssOptOutButton = styled(bigBasicButton, `
margin-top: 24px;
`);
const cssSponsorButton = styled(bigBasicButtonLink, `
margin-top: 24px;
`);
const cssButtonIconAndText = styled('div', `
display: flex;
align-items: center;
`);
const cssButtonText = styled('span', `
margin-left: 8px;
`);
const cssSpinnerBox = styled('div', `
margin-top: 24px;
text-align: center;
`);

@ -26,7 +26,7 @@ const t = makeT('TopBar');
export function createTopBarHome(appModel: AppModel) {
return [
cssFlexSpace(),
appModel.supportGristNudge.showButton(),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[
basicButton(

@ -72,6 +72,7 @@ export type IconName = "ChartArea" |
"FunctionResult" |
"GreenArrow" |
"Grow" |
"Heart" |
"Help" |
"Home" |
"Idea" |
@ -214,6 +215,7 @@ export const IconList: IconName[] = ["ChartArea",
"FunctionResult",
"GreenArrow",
"Grow",
"Heart",
"Help",
"Home",
"Idea",

@ -0,0 +1,10 @@
import {TelemetryLevel} from 'app/common/Telemetry';
export interface InstallPrefs {
telemetry?: TelemetryPrefs;
}
export interface TelemetryPrefs {
/** Defaults to "off". */
telemetryLevel?: TelemetryLevel;
}

@ -0,0 +1,51 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {InstallPrefs} from 'app/common/Install';
import {TelemetryLevel} from 'app/common/Telemetry';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
export const installPropertyKeys = ['prefs'];
export interface InstallProperties {
prefs: InstallPrefs;
}
export interface InstallPrefsWithSources {
telemetry: {
telemetryLevel: PrefWithSource<TelemetryLevel>;
},
}
export type TelemetryPrefsWithSources = InstallPrefsWithSources['telemetry'];
export interface PrefWithSource<T> {
value: T;
source: PrefSource;
}
export type PrefSource = 'environment-variable' | 'preferences';
export interface InstallAPI {
getInstallPrefs(): Promise<InstallPrefsWithSources>;
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
}
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
constructor(private _homeUrl: string, options: IOptions = {}) {
super(options);
}
public async getInstallPrefs(): Promise<InstallPrefsWithSources> {
return this.requestJson(`${this._url}/api/install/prefs`, {method: 'GET'});
}
public async updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void> {
await this.request(`${this._url}/api/install/prefs`, {
method: 'PATCH',
body: JSON.stringify({...prefs}),
});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}
}

@ -106,6 +106,7 @@ export const DismissedPopup = StringUnion(
'tutorialFirstCard', // first card of the tutorial,
'formulaHelpInfo', // formula help info shown in the popup editor,
'formulaAssistantInfo', // formula assistant info shown in the popup editor,
'supportGrist', // nudge to opt in to telemetry,
);
export type DismissedPopup = typeof DismissedPopup.type;

@ -1,5 +1,4 @@
import {StringUnion} from 'app/common/StringUnion';
import pickBy = require('lodash/pickBy');
/**
* Telemetry levels, in increasing order of data collected.
@ -720,36 +719,3 @@ export function buildTelemetryEventChecker(telemetryLevel: TelemetryLevel) {
}
export type TelemetryEventChecker = (event: TelemetryEvent, metadata?: TelemetryMetadata) => void;
/**
* Returns a new, filtered metadata object.
*
* Metadata in groups that don't meet `telemetryLevel` are removed from the
* returned object, and the returned object is flattened.
*
* Returns undefined if `metadata` is undefined.
*/
export function filterMetadata(
metadata: TelemetryMetadataByLevel | undefined,
telemetryLevel: TelemetryLevel
): TelemetryMetadata | undefined {
if (!metadata) { return; }
let filteredMetadata = {};
for (const level of ['limited', 'full'] as const) {
if (Level[telemetryLevel] < Level[level]) { break; }
filteredMetadata = {...filteredMetadata, ...metadata[level]};
}
filteredMetadata = removeNullishKeys(filteredMetadata);
return removeNullishKeys(filteredMetadata);
}
/**
* Returns a copy of `object` with all null and undefined keys removed.
*/
export function removeNullishKeys(object: Record<string, any>) {
return pickBy(object, value => value !== null && value !== undefined);
}

@ -41,6 +41,9 @@ export type ActivationPage = typeof ActivationPage.type;
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
export const SupportGristPage = StringUnion('support-grist');
export type SupportGristPage = typeof SupportGristPage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('light', 'full');
export type InterfaceStyle = typeof InterfaceStyle.type;
@ -72,6 +75,7 @@ export const commonUrls = {
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
plans: "https://www.getgrist.com/pricing",
sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact",
@ -83,6 +87,8 @@ export const commonUrls = {
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
githubGristCore: 'https://github.com/gristlabs/grist-core',
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
};
/**
@ -103,6 +109,7 @@ export interface IGristUrlState {
activation?: ActivationPage;
login?: LoginPage;
welcome?: WelcomePage;
supportGrist?: SupportGristPage;
welcomeTour?: boolean;
docTour?: boolean;
manageUsers?: boolean;
@ -258,6 +265,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
parts.push(`welcome/${state.welcome}`);
}
if (state.supportGrist) { parts.push(state.supportGrist); }
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
queryParams[`${k}_`] = v;
@ -320,7 +329,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
// the minimum length of a urlId prefix is longer than the maximum length
// of any of the valid keys in the url.
for (const key of map.keys()) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) {
map.set('doc', key);
map.set('slug', map.get(key)!);
map.delete(key);
@ -358,6 +367,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
}
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
if (map.has('support-grist')) {
state.supportGrist = SupportGristPage.parse(map.get('support-grist')) || 'support-grist';
}
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
if (sp.has('billingTask')) {

@ -1,3 +1,7 @@
import {InstallPrefs} from "app/common/Install";
import {ApiError} from "app/common/ApiError";
import {InstallProperties, installPropertyKeys} from "app/common/InstallAPI";
import {nativeValues} from "app/gen-server/lib/values";
import {BaseEntity, Column, Entity, PrimaryColumn} from "typeorm";
@Entity({name: 'activations'})
@ -9,9 +13,47 @@ export class Activation extends BaseEntity {
@Column({name: 'key', type: 'text', nullable: true})
public key: string|null;
@Column({type: nativeValues.jsonEntityType, nullable: true})
public prefs: InstallPrefs|null;
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
public createdAt: Date;
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
public updatedAt: Date;
public checkProperties(props: any): props is Partial<InstallProperties> {
for (const key of Object.keys(props)) {
if (!installPropertyKeys.includes(key)) {
throw new ApiError(`Unrecognized property ${key}`, 400);
}
}
return true;
}
public updateFromProperties(props: Partial<InstallProperties>) {
if (props.prefs === undefined) { return; }
if (props.prefs === null) {
this.prefs = null;
} else {
this.prefs = this.prefs || {};
if (props.prefs.telemetry !== undefined) {
this.prefs.telemetry = this.prefs.telemetry || {};
if (props.prefs.telemetry.telemetryLevel !== undefined) {
this.prefs.telemetry.telemetryLevel = props.prefs.telemetry.telemetryLevel;
}
}
for (const key of Object.keys(this.prefs) as Array<keyof InstallPrefs>) {
if (this.prefs[key] === null) {
delete this.prefs[key];
}
}
if (Object.keys(this.prefs).length === 0) {
this.prefs = null;
}
}
}
}

@ -1,10 +1,8 @@
import {ApiError} from 'app/common/ApiError';
import {DocumentUsage} from 'app/common/DocUsage';
import {hashId} from 'app/common/hashingUtils';
import {Role} from 'app/common/roles';
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,
NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {nativeValues} from 'app/gen-server/lib/values';
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
import {AclRuleDoc} from "./AclRule";
@ -92,7 +90,7 @@ export class Document extends Resource {
return super.checkProperties(props, documentPropertyKeys);
}
public updateFromProperties(props: Partial<DocumentProperties>, dbManager?: HomeDBManager) {
public updateFromProperties(props: Partial<DocumentProperties>) {
super.updateFromProperties(props);
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
if (props.urlId !== undefined) {
@ -135,9 +133,6 @@ export class Document extends Resource {
}
if (props.options.tutorial.lastSlideIndex !== undefined) {
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
if (dbManager && this.options.tutorial.numSlides) {
this._emitTutorialProgressChangeEvent(dbManager, this.options.tutorial);
}
}
}
}
@ -154,26 +149,6 @@ export class Document extends Resource {
}
}
}
private _emitTutorialProgressChangeEvent(
dbManager: HomeDBManager,
tutorialMetadata: TutorialMetadata
) {
const lastSlideIndex = tutorialMetadata.lastSlideIndex;
const numSlides = tutorialMetadata.numSlides;
const percentComplete = lastSlideIndex !== undefined && numSlides !== undefined
? Math.floor((lastSlideIndex / numSlides) * 100)
: undefined;
dbManager?.emit('tutorialProgressChanged', {
full: {
tutorialForkIdDigest: hashId(this.id),
tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined,
lastSlideIndex,
numSlides,
percentComplete,
},
});
}
}
// Check that icon points to an expected location. This will definitely

@ -20,6 +20,7 @@ export class Activations {
if (!activation) {
activation = manager.create(Activation);
activation.id = makeId();
activation.prefs = {};
await activation.save();
}
return activation;

@ -89,14 +89,6 @@ export const NotifierEvents = StringUnion(
export type NotifierEvent = typeof NotifierEvents.type;
export const HomeDBTelemetryEvents = StringUnion(
'tutorialProgressChanged',
);
export type HomeDBTelemetryEvent = typeof HomeDBTelemetryEvents.type;
export type Event = NotifierEvent | HomeDBTelemetryEvent;
// Nominal email address of a user who can view anything (for thumbnails).
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
@ -324,7 +316,7 @@ export class HomeDBManager extends EventEmitter {
orgOnly: true
}];
public emit(event: Event, ...args: any[]): boolean {
public emit(event: NotifierEvent, ...args: any[]): boolean {
return super.emit(event, ...args);
}
@ -1960,7 +1952,7 @@ export class HomeDBManager extends EventEmitter {
// Update the name and save.
const doc: Document = queryResult.data;
doc.checkProperties(props);
doc.updateFromProperties(props, this);
doc.updateFromProperties(props);
if (forkId) {
await manager.save(doc);
return {status: 200};

@ -0,0 +1,16 @@
import {nativeValues} from 'app/gen-server/lib/values';
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
export class ActivationPrefs1682636695021 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn('activations', new TableColumn({
name: 'prefs',
type: nativeValues.jsonType,
isNullable: true,
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn('activations', 'prefs');
}
}

@ -6,7 +6,7 @@ import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
import { getMigrations, getOrCreateConnection, getTypeORMSettings,
undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
import { getDatabaseUrl } from 'app/server/lib/serverUtils';
import { getTelemetryLevel } from 'app/server/lib/Telemetry';
import { getTelemetryPrefs } from 'app/server/lib/Telemetry';
import { Gristifier } from 'app/server/utils/gristify';
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
import * as commander from 'commander';
@ -81,12 +81,14 @@ export function addSettingsCommand(program: commander.Command,
.action(showTelemetry);
}
function showTelemetry(options: {
async function showTelemetry(options: {
json?: boolean,
all?: boolean,
}) {
const contracts = TelemetryContracts;
const levelName = getTelemetryLevel();
const db = await getHomeDBManager();
const prefs = await getTelemetryPrefs(db);
const levelName = prefs.telemetryLevel.value;
const level = Level[levelName];
if (options.json) {
console.log(JSON.stringify({

@ -68,7 +68,6 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
import {isHiddenCol} from 'app/common/gristTypes';
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval';
import * as roles from 'app/common/roles';
@ -1396,9 +1395,9 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
this.logTelemetryEvent(docSession, 'documentForked', {
limited: {
forkIdDigest: hashId(forkIds.forkId),
forkDocIdDigest: hashId(forkIds.docId),
trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined,
forkIdDigest: forkIds.forkId,
forkDocIdDigest: forkIds.docId,
trunkIdDigest: doc.trunkId,
isTemplate,
lastActivity: doc.updatedAt,
},
@ -2540,7 +2539,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
altSessionId ? {altSessionId} : {},
{
limited: {
docIdDigest: hashId(this._docName),
docIdDigest: this._docName,
},
full: {
siteId: this._doc?.workspace.org.id,

@ -9,7 +9,6 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
import {ApiError} from 'app/common/ApiError';
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
import {removeTrailingSlash} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI';
@ -304,13 +303,13 @@ export function attachAppEndpoint(options: AttachOptions): void {
}
const isPublic = ((doc as unknown) as APIDocument).public ?? false;
const isSnapshot = parseUrlId(urlId).snapshotId;
const isSnapshot = Boolean(parseUrlId(urlId).snapshotId);
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
if (isPublic || isTemplate) {
gristServer.getTelemetry().logEvent('documentOpened', {
limited: {
docIdDigest: hashId(docId),
docIdDigest: docId,
access: doc.access,
isPublic,
isSnapshot,

@ -11,7 +11,6 @@ import {
import {isRaisedException} from "app/common/gristTypes";
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {isAffirmative} from "app/common/gutil";
import {hashId} from "app/common/hashingUtils";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec';
@ -918,8 +917,8 @@ export class DocWorkerApi {
const {forkId} = parseUrlId(scope.urlId);
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
full: {
tutorialForkIdDigest: forkId ? hashId(forkId) : undefined,
tutorialTrunkIdDigest: hashId(tutorialTrunkId),
tutorialForkIdDigest: forkId,
tutorialTrunkIdDigest: tutorialTrunkId,
},
});
}

@ -5,6 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
sanitizePathTail} from 'app/common/gristUrls';
import {getOrgUrlInfo} from 'app/common/gristUrls';
import {InstallProperties} from 'app/common/InstallAPI';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {tbind} from 'app/common/tbind';
import * as version from 'app/common/version';
@ -50,14 +51,15 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, optStringParam,
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, isDefaultUser, optStringParam,
RequestWithGristInfo, sendOkReply, stringParam, TEST_HTTPS_OFFSET,
trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
import * as shutdown from 'app/server/lib/shutdown';
import {TagChecker} from 'app/server/lib/TagChecker';
import {ITelemetry} from 'app/server/lib/Telemetry';
import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry';
import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads';
@ -706,11 +708,17 @@ export class FlexServer implements GristServer {
});
}
public addTelemetry() {
public async addTelemetry() {
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
this._telemetry = this.create.Telemetry(this._dbManager, this);
this._telemetry.addEndpoints(this.app);
this._telemetry.addPages(this.app, [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
]);
await this._telemetry.start();
// Start up a monitor for memory and cpu usage.
this._processMonitorStop = ProcessMonitor.start(this._telemetry);
@ -1198,7 +1206,7 @@ export class FlexServer implements GristServer {
];
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
return this._sendAppPage(req, resp, {path: 'account.html', status: 200, config: {}});
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
}
@ -1460,6 +1468,43 @@ export class FlexServer implements GristServer {
addGoogleAuthEndpoint(this.app, messagePage);
}
public addInstallEndpoints() {
if (this._check('install')) { return; }
const isManager = expressWrap(
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
next();
}
);
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => {
const activation = await this._activations.current();
return sendOkReply(null, resp, {
telemetry: await getTelemetryPrefs(this._dbManager, activation),
});
}));
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => {
const props = {prefs: req.body};
const activation = await this._activations.current();
activation.checkProperties(props);
activation.updateFromProperties(props);
await activation.save();
if ((props as Partial<InstallProperties>).prefs?.telemetry) {
// Make sure the Telemetry singleton picks up the changes to telemetry preferences.
// TODO: if there are multiple home server instances, notify them all of changes to
// preferences (via Redis Pub/Sub).
await this._telemetry.fetchTelemetryPrefs();
}
return resp.status(200).send();
}));
}
// Get the HTML template sent for document pages.
public async getDocTemplate(): Promise<DocTemplate> {
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),

@ -138,8 +138,11 @@ export function createDummyGristServer(): GristServer {
export function createDummyTelemetry(): ITelemetry {
return {
logEvent() { return Promise.resolve(); },
addEndpoints() { /* do nothing */ },
getTelemetryLevel() { return 'off'; },
addPages() { /* do nothing */ },
start() { return Promise.resolve(); },
logEvent() { return Promise.resolve(); },
getTelemetryConfig() { return undefined; },
fetchTelemetryPrefs() { return Promise.resolve(); },
};
}

@ -1,8 +1,10 @@
import {ApiError} from 'app/common/ApiError';
import {TelemetryConfig} from 'app/common/gristUrls';
import {assertIsDefined} from 'app/common/gutil';
import {
buildTelemetryEventChecker,
filterMetadata,
removeNullishKeys,
Level,
TelemetryContracts,
TelemetryEvent,
TelemetryEventChecker,
TelemetryEvents,
@ -11,18 +13,28 @@ import {
TelemetryMetadata,
TelemetryMetadataByLevel,
} from 'app/common/Telemetry';
import {HomeDBManager, HomeDBTelemetryEvents} from 'app/gen-server/lib/HomeDBManager';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {Activation} from 'app/gen-server/entity/Activation';
import {Activations} from 'app/gen-server/lib/Activations';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {RequestWithLogin} from 'app/server/lib/Authorizer';
import {expressWrap} from 'app/server/lib/expressWrap';
import {GristServer} from 'app/server/lib/GristServer';
import {hashId} from 'app/server/lib/hashingUtils';
import {LogMethods} from 'app/server/lib/LogMethods';
import {stringParam} from 'app/server/lib/requestUtils';
import * as express from 'express';
import fetch from 'node-fetch';
import merge = require('lodash/merge');
import pickBy = require('lodash/pickBy');
export interface ITelemetry {
start(): Promise<void>;
logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise<void>;
addEndpoints(app: express.Express): void;
getTelemetryLevel(): TelemetryLevel;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
getTelemetryConfig(): TelemetryConfig | undefined;
fetchTelemetryPrefs(): Promise<void>;
}
const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
@ -31,26 +43,30 @@ const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
* Manages telemetry for Grist.
*/
export class Telemetry implements ITelemetry {
private _telemetryLevel: TelemetryLevel;
private _deploymentType = this._gristServer.getDeploymentType();
private _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
private _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
'https://telemetry.getgrist.com/api/telemetry';
private _activation: Activation | undefined;
private readonly _deploymentType = this._gristServer.getDeploymentType();
private _telemetryPrefs: TelemetryPrefsWithSources | undefined;
private readonly _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
'https://telemetry.getgrist.com/api/telemetry';
private _numPendingForwardEventRequests = 0;
private _installationId: string | undefined;
private _logger = new LogMethods('Telemetry ', () => ({}));
private _telemetryLogger = new LogMethods('Telemetry ', () => ({
private readonly _logger = new LogMethods('Telemetry ', () => ({}));
private readonly _telemetryLogger = new LogMethods('Telemetry ', () => ({
eventType: 'telemetry',
}));
private _checkEvent: TelemetryEventChecker | undefined;
private _checkTelemetryEvent: TelemetryEventChecker | undefined;
constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) {
this._initialize().catch((e) => {
this._logger.error(undefined, 'failed to initialize', e);
});
}
public async start() {
await this.fetchTelemetryPrefs();
}
/**
@ -96,19 +112,29 @@ export class Telemetry implements ITelemetry {
event: TelemetryEvent,
metadata?: TelemetryMetadataByLevel
) {
if (this._telemetryLevel === 'off') { return; }
if (!this._checkTelemetryEvent) {
this._logger.error(undefined, 'logEvent called but telemetry event checker is undefined');
return;
}
const prefs = this._telemetryPrefs;
if (!prefs) {
this._logger.error(undefined, 'logEvent called but telemetry preferences are undefined');
return;
}
const {telemetryLevel} = prefs;
if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel.value]) {
return;
}
metadata = filterMetadata(metadata, this._telemetryLevel);
metadata = filterMetadata(metadata, telemetryLevel.value);
this._checkTelemetryEvent(event, metadata);
if (this._shouldForwardTelemetryEvents) {
await this._forwardEvent(event, metadata);
} else {
this._telemetryLogger.rawLog('info', null, event, {
eventName: event,
eventSource: `grist-${this._deploymentType}`,
...metadata,
});
this._logEvent(event, metadata);
}
}
@ -125,7 +151,7 @@ export class Telemetry implements ITelemetry {
* source. Otherwise, the event will only be logged after passing various
* checks.
*/
app.post('/api/telemetry', async (req, resp) => {
app.post('/api/telemetry', expressWrap(async (req, resp) => {
const mreq = req as RequestWithLogin;
const event = stringParam(req.body.event, 'event', TelemetryEvents.values);
if ('eventSource' in req.body.metadata) {
@ -135,11 +161,12 @@ export class Telemetry implements ITelemetry {
});
} else {
try {
this._assertTelemetryIsReady();
await this.logEvent(event as TelemetryEvent, merge(
{
limited: {
eventSource: `grist-${this._deploymentType}`,
...(this._deploymentType !== 'saas' ? {installationId: this._installationId} : {}),
...(this._deploymentType !== 'saas' ? {installationId: this._activation!.id} : {}),
},
full: {
userId: mreq.userId,
@ -154,36 +181,49 @@ export class Telemetry implements ITelemetry {
}
}
return resp.status(200).send();
});
}));
}
public getTelemetryLevel() {
return this._telemetryLevel;
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
if (this._deploymentType === 'core') {
app.get('/support-grist', ...middleware, expressWrap(async (req, resp) => {
return this._gristServer.sendAppPage(req, resp,
{path: 'app.html', status: 200, config: {}});
}));
}
}
private async _initialize() {
this._telemetryLevel = getTelemetryLevel();
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryLevel);
public getTelemetryConfig(): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs;
if (!prefs) {
this._logger.error(undefined, 'getTelemetryConfig called but telemetry preferences are undefined');
return undefined;
}
const {id} = await this._gristServer.getActivations().current();
this._installationId = id;
return {
telemetryLevel: prefs.telemetryLevel.value,
};
}
for (const event of HomeDBTelemetryEvents.values) {
this._dbManager.on(event, async (metadata) => {
this.logEvent(event, metadata).catch(e =>
this._logger.error(undefined, `failed to log telemetry event ${event}`, e));
});
}
public async fetchTelemetryPrefs() {
this._activation = await this._gristServer.getActivations().current();
await this._fetchTelemetryPrefs();
}
private _checkTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadata) {
if (!this._checkEvent) {
throw new Error('Telemetry._checkEvent is undefined');
}
private async _fetchTelemetryPrefs() {
this._telemetryPrefs = await getTelemetryPrefs(this._dbManager, this._activation);
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value);
}
this._checkEvent(event, metadata);
private _logEvent(
event: TelemetryEvent,
metadata?: TelemetryMetadata
) {
this._telemetryLogger.rawLog('info', null, event, {
eventName: event,
eventSource: `grist-${this._deploymentType}`,
...metadata,
});
}
private async _forwardEvent(
@ -198,7 +238,7 @@ export class Telemetry implements ITelemetry {
try {
this._numPendingForwardEventRequests += 1;
await this._postJsonPayload(JSON.stringify({event, metadata}));
await this._doForwardEvent(JSON.stringify({event, metadata}));
} catch (e) {
this._logger.error(undefined, `failed to forward telemetry event ${event}`, e);
} finally {
@ -206,7 +246,7 @@ export class Telemetry implements ITelemetry {
}
}
private async _postJsonPayload(payload: string) {
private async _doForwardEvent(payload: string) {
await fetch(this._forwardTelemetryEventsUrl, {
method: 'POST',
headers: {
@ -215,12 +255,90 @@ export class Telemetry implements ITelemetry {
body: payload,
});
}
private _assertTelemetryIsReady() {
try {
assertIsDefined('activation', this._activation);
} catch (e) {
this._logger.error(null, 'activation is undefined', e);
throw new ApiError('Telemetry is not ready', 500);
}
}
}
export function getTelemetryLevel(): TelemetryLevel {
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
return TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL);
} else {
return 'off';
export async function getTelemetryPrefs(
db: HomeDBManager,
activation?: Activation
): Promise<TelemetryPrefsWithSources> {
const GRIST_TELEMETRY_LEVEL = process.env.GRIST_TELEMETRY_LEVEL;
if (GRIST_TELEMETRY_LEVEL !== undefined) {
const value = TelemetryLevels.check(GRIST_TELEMETRY_LEVEL);
return {
telemetryLevel: {
value,
source: 'environment-variable',
},
};
}
const {prefs} = activation ?? await new Activations(db).current();
return {
telemetryLevel: {
value: prefs?.telemetry?.telemetryLevel ?? 'off',
source: 'preferences',
}
};
}
/**
* Returns a new, filtered metadata object, or undefined if `metadata` is undefined.
*
* Filtering currently:
* - removes keys in groups that exceed `telemetryLevel`
* - removes keys with values of null or undefined
* - hashes the values of keys suffixed with "Digest" (e.g. doc ids, fork ids)
* - flattens the entire metadata object (i.e. removes the nesting of keys under
* "limited" or "full")
*/
export function filterMetadata(
metadata: TelemetryMetadataByLevel | undefined,
telemetryLevel: TelemetryLevel
): TelemetryMetadata | undefined {
if (!metadata) { return; }
let filteredMetadata: TelemetryMetadata = {};
for (const level of ['limited', 'full'] as const) {
if (Level[telemetryLevel] < Level[level]) { break; }
filteredMetadata = {...filteredMetadata, ...metadata[level]};
}
filteredMetadata = removeNullishKeys(filteredMetadata);
filteredMetadata = hashDigestKeys(filteredMetadata);
return filteredMetadata;
}
/**
* Returns a copy of `object` with all null and undefined keys removed.
*/
export function removeNullishKeys(object: Record<string, any>) {
return pickBy(object, value => value !== null && value !== undefined);
}
/**
* Returns a copy of `metadata`, replacing the values of all keys suffixed
* with "Digest" with the result of hashing the value. The hash is prefixed with
* the first 4 characters of the original value, to assist with troubleshooting.
*/
export function hashDigestKeys(metadata: TelemetryMetadata): TelemetryMetadata {
const filteredMetadata: TelemetryMetadata = {};
Object.entries(metadata).forEach(([key, value]) => {
if (key.endsWith('Digest') && typeof value === 'string') {
filteredMetadata[key] = hashId(value);
} else {
filteredMetadata[key] = value;
}
});
return filteredMetadata;
}

@ -1,11 +1,11 @@
import {createHash} from 'crypto';
/**
* Returns a hash of `id` prefixed with the first 4 characters of `id`.
* Returns a hash of `id` prefixed with the first 4 characters of `id`. The first 4
* characters are included to assist with troubleshooting.
*
* Useful for situations where potentially sensitive identifiers are logged, such as
* doc ids (like those that have public link sharing enabled). The first 4 characters
* are included to assist with troubleshooting.
* doc ids of docs that have public link sharing enabled.
*/
export function hashId(id: string): string {
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;

@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
@ -352,3 +352,9 @@ export function addAbortHandler(req: Request, res: Writable, op: () => void) {
}
});
}
export function isDefaultUser(req: Request) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
const {loginEmail} = getUser(req);
return defaultEmail && defaultEmail === loginEmail;
}

@ -76,7 +76,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
telemetry: server ? getTelemetryConfig(server) : undefined,
telemetry: server?.getTelemetry().getTelemetryConfig(),
deploymentType: server?.getDeploymentType(),
...extra,
};
@ -163,13 +163,6 @@ function getFeatures(): IFeature[] {
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
}
function getTelemetryConfig(server: GristServer) {
const telemetry = server.getTelemetry();
return {
telemetryLevel: telemetry.getTelemetryLevel(),
};
}
function configuredPageTitleSuffix() {
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
return result === "_blank" ? "" : result;

@ -119,7 +119,7 @@ export async function main(port: number, serverTypes: ServerType[],
server.addHomeApi();
server.addBillingApi();
server.addNotifier();
server.addTelemetry();
await server.addTelemetry();
await server.addHousekeeper();
await server.addLoginRoutes();
server.addAccountPage();
@ -127,11 +127,12 @@ export async function main(port: number, serverTypes: ServerType[],
server.addWelcomePaths();
server.addLogEndpoint();
server.addGoogleAuthEndpoint();
server.addInstallEndpoints();
}
if (includeDocs) {
server.addJsonSupport();
server.addTelemetry();
await server.addTelemetry();
await server.addDoc();
}

@ -13,9 +13,7 @@ module.exports = {
entry: {
main: "app/client/app",
errorPages: "app/client/errorMain",
account: "app/client/accountMain",
billing: "app/client/billingMain",
activation: "app/client/activationMain",
// Include client test harness if it is present (it won't be in
// docker image).
...(fs.existsSync("test/client-harness/client.js") ? {

@ -1,16 +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 -->
<!-- INSERT CUSTOM -->
<title>Account<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<script src="account.bundle.js"></script>
</body>
</html>

@ -1,17 +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 CUSTOM -->
<title>Activation<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<!-- INSERT ERROR -->
<!-- INSERT CONFIG -->
<script src="activation.bundle.js"></script>
</body>
</html>

@ -73,6 +73,7 @@
--icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
--icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-Heart: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMTciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzg0Nl8xNTQxOSkiPjxwYXRoIGQ9Ik03LjY4NTY0IDE1Ljk5OUM2Ljg4NTY0IDE1LjI5OSAwLjI4NTY0NSA5Ljc5OTAxIDAuMjg1NjQ1IDUuNTk5MDFDMC4yODU2NDUgMi44OTkwMSAyLjQ4NTY0IDAuNzk5MDExIDUuMDg1NjQgMC43OTkwMTFDNi4yODU2NCAwLjc5OTAxMSA3LjM4NTY0IDEuMjk5MDEgOC4yODU2NCAxLjk5OTAxQzkuMTg1NjQgMS4xOTkwMSAxMC4yODU2IDAuNzk5MDExIDExLjQ4NTYgMC43OTkwMTFDMTQuMTg1NiAwLjc5OTAxMSAxNi4yODU2IDIuOTk5MDEgMTYuMjg1NiA1LjU5OTAxQzE2LjI4NTYgOS43OTkwMSA5LjY4NTY1IDE1LjI5OSA4Ljg4NTY0IDE1Ljg5OUM4LjU4NTY0IDE2LjI5OSA3Ljk4NTY0IDE2LjI5OSA3LjY4NTY0IDE1Ljk5OVpNNS4wODU2NCAyLjc5OTAxQzMuNTg1NjQgMi43OTkwMSAyLjI4NTY0IDQuMDk5MDEgMi4yODU2NCA1LjU5OTAxQzIuMjg1NjQgNy43OTkwMSA1Ljc4NTY0IDExLjU5OSA4LjI4NTY0IDEzLjc5OUMxMC4zODU2IDExLjg5OSAxNC4yODU2IDcuOTk5MDEgMTQuMjg1NiA1LjU5OTAxQzE0LjI4NTYgMy45OTkwMSAxMi45ODU2IDIuNzk5MDEgMTEuNDg1NiAyLjc5OTAxQzEwLjU4NTYgMi43OTkwMSA5LjY4NTY0IDMuMjk5MDEgOS4xODU2NCA0LjA5OTAxQzguNzg1NjQgNC42OTkwMSA3Ljg4NTY0IDQuNjk5MDEgNy40ODU2NCA0LjA5OTAxQzYuODg1NjQgMy4yOTkwMSA2LjA4NTY0IDIuNzk5MDEgNS4wODU2NCAyLjc5OTAxWiIgZmlsbD0iIzE2QjM3OCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzg0Nl8xNTQxOSI+PHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLjI4NiAuNDk5KSIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
--icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');

@ -0,0 +1,10 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_846_15419)">
<path d="M7.68564 15.999C6.88564 15.299 0.285645 9.79901 0.285645 5.59901C0.285645 2.89901 2.48564 0.799011 5.08564 0.799011C6.28564 0.799011 7.38564 1.29901 8.28564 1.99901C9.18564 1.19901 10.2856 0.799011 11.4856 0.799011C14.1856 0.799011 16.2856 2.99901 16.2856 5.59901C16.2856 9.79901 9.68565 15.299 8.88564 15.899C8.58564 16.299 7.98564 16.299 7.68564 15.999ZM5.08564 2.79901C3.58564 2.79901 2.28564 4.09901 2.28564 5.59901C2.28564 7.79901 5.78564 11.599 8.28564 13.799C10.3856 11.899 14.2856 7.99901 14.2856 5.59901C14.2856 3.99901 12.9856 2.79901 11.4856 2.79901C10.5856 2.79901 9.68564 3.29901 9.18564 4.09901C8.78564 4.69901 7.88564 4.69901 7.48564 4.09901C6.88564 3.29901 6.08564 2.79901 5.08564 2.79901Z" fill="#16B378"/>
</g>
<defs>
<clipPath id="clip0_846_15419">
<rect width="16" height="16" fill="white" transform="translate(0.285645 0.499023)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

@ -1,4 +1,4 @@
import {buildTelemetryEventChecker, filterMetadata, TelemetryEvent} from 'app/common/Telemetry';
import {buildTelemetryEventChecker, TelemetryEvent} from 'app/common/Telemetry';
import {assert} from 'chai';
describe('Telemetry', function() {
@ -132,84 +132,4 @@ describe('Telemetry', function() {
);
});
});
describe('filterMetadata', function() {
it('returns filtered and flattened metadata when maxLevel is "full"', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'full'), {
foo: 'abc',
bar: '123',
});
});
it('returns filtered and flattened metadata when maxLevel is "limited"', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'limited'), {
foo: 'abc',
});
});
it('returns undefined when maxLevel is "off"', function() {
assert.isUndefined(filterMetadata(undefined, 'off'));
});
it('returns an empty object when metadata is empty', function() {
assert.isEmpty(filterMetadata({}, 'full'));
});
it('returns undefined when metadata is undefined', function() {
assert.isUndefined(filterMetadata(undefined, 'full'));
});
it('does not mutate metadata', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
filterMetadata(metadata, 'limited');
assert.deepEqual(metadata, {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
});
});
it('excludes keys with nullish values', function() {
const metadata = {
limited: {
foo1: null,
foo2: 'abc',
},
full: {
bar1: undefined,
bar2: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'full'), {
foo2: 'abc',
bar2: '123',
});
});
});
});

@ -0,0 +1,308 @@
import {GristLoadConfig} from 'app/common/gristUrls';
import {TelemetryLevel} from 'app/common/Telemetry';
import {assert, driver} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils';
describe('SupportGrist', function() {
this.timeout(30000);
setupTestSuite();
let oldEnv: testUtils.EnvironmentSnapshot;
let session: gu.Session;
afterEach(() => gu.checkForErrors());
describe('in grist-core', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
await server.restart();
});
after(async function() {
oldEnv.restore();
await server.restart();
});
describe('when user is not a manager', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
});
it('shows a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-sponsorship-section',
/Sponsor Grist Labs on GitHub/,
4000
).isDisplayed());
});
it('shows a message that telemetry is managed by the site administrator', async function() {
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-telemetry-section',
/This instance is opted out of telemetry\. Only the site administrator has permission to change this\./,
4000
).isDisplayed());
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
await server.restart();
await driver.navigate().refresh();
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-telemetry-section',
/This instance is opted in to telemetry\. Only the site administrator has permission to change this\./,
4000
).isDisplayed());
});
});
describe('when user is a manager', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
});
it('shows a nudge on the doc menu', async function() {
// Check that the nudge is expanded by default.
await assertNudgeButtonShown(false);
await assertNudgeCardShown(true);
// Reload the doc menu and check that it's still expanded.
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(true);
// Close the nudge and check that it's now collapsed.
await driver.find('.test-support-grist-nudge-card-close').click();
await assertNudgeButtonShown(true);
await assertNudgeCardShown(false);
// Reload again, and check that it's still collapsed.
await session.loadDocMenu('/');
await assertNudgeButtonShown(true);
await assertNudgeCardShown(false);
// Dismiss the contribute button and check that it's now gone, even after reloading.
await driver.find('.test-support-grist-nudge-contribute-button').mouseMove();
await driver.find('.test-support-grist-nudge-contribute-button-close').click();
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
});
it('shows a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
});
it('supports opting in to telemetry from the page', async function() {
await assertTelemetryLevel('off');
await driver.findContentWait(
'.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
assert.equal(
await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
await driver.navigate().refresh();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
await assertTelemetryLevel('limited');
});
it('supports opting out of telemetry from the page', async function() {
await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
// Reload the page and check that the Grist config indicates telemetry is set to "off".
await driver.navigate().refresh();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
await assertTelemetryLevel('off');
});
it('supports opting in to telemetry from the nudge', async function() {
// Reset all dismissed popups, including the telemetry nudge.
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
await session.loadDocMenu('/');
// Opt in to telemetry and reload the page.
await driver.find('.test-support-grist-nudge-card-opt-in').click();
await driver.findWait('.test-support-grist-nudge-card-close-button', 1000).click();
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
await session.loadDocMenu('/');
// Check that the nudge is no longer shown and telemetry is set to "limited".
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
await assertTelemetryLevel('limited');
});
it('does not show the nudge if telemetry is enabled', async function() {
// Reset all dismissed popups, including the telemetry nudge.
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
// Reload the doc menu and check that the nudge still isn't shown.
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
// Disable telemetry from the Support Grist page.
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
await driver.findContentWait(
'.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
// Reload the doc menu and check that the nudge is now shown.
await gu.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(true);
});
it('shows telemetry opt-in status even when set via environment variable', async function() {
// Set the telemetry level to "limited" via environment variable and restart the server.
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
await server.restart();
// Check that the Support Grist page reports telemetry is enabled.
await gu.loadDocMenu('/');
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
/Opt out of Telemetry/).isPresent());
// Now set the telemetry level to "off" and restart the server.
process.env.GRIST_TELEMETRY_LEVEL = 'off';
await server.restart();
// Check that the Support Grist page reports telemetry is disabled.
await gu.loadDocMenu('/');
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted out of telemetry.'
);
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
/Opt in to Telemetry/).isPresent());
});
});
});
describe('in grist-saas', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'saas';
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
await server.restart();
session = await gu.session().personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
await server.restart();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
});
it('does not show a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
});
});
describe('in grist-enterprise', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'enterprise';
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
await server.restart();
session = await gu.session().personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
await server.restart();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
});
it('does not show a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
});
});
});
async function assertNudgeButtonShown(isShown: boolean) {
if (isShown) {
assert.isTrue(
await driver.find('.test-support-grist-nudge-contribute-button').isDisplayed()
);
} else {
assert.isFalse(await driver.find('.test-support-grist-nudge-contribute-button').isPresent());
}
}
async function assertNudgeCardShown(isShown: boolean) {
if (isShown) {
assert.isTrue(
await driver.find('.test-support-grist-nudge-card').isDisplayed()
);
} else {
assert.isFalse(await driver.find('.test-support-grist-nudge-card').isPresent());
}
}
async function assertTelemetryLevel(level: TelemetryLevel) {
const {telemetry}: GristLoadConfig = await driver.executeScript('return window.gristConfig');
assert.equal(telemetry?.telemetryLevel, level);
}

@ -34,7 +34,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) {
home.addJsonSupport();
await home.addLandingPages();
home.addHomeApi();
home.addTelemetry();
await home.addTelemetry();
await home.addDoc();
home.addApiErrorHandlers();
serverUrl = home.getOwnUrl();

@ -1,7 +1,8 @@
import {GristDeploymentType} from 'app/common/gristUrls';
import {PrefSource} from 'app/common/InstallAPI';
import {TelemetryEvent, TelemetryLevel} from 'app/common/Telemetry';
import {ILogMeta, LogMethods} from 'app/server/lib/LogMethods';
import {ITelemetry, Telemetry} from 'app/server/lib/Telemetry';
import {filterMetadata, ITelemetry, Telemetry} from 'app/server/lib/Telemetry';
import axios from 'axios';
import {assert} from 'chai';
import * as sinon from 'sinon';
@ -9,36 +10,50 @@ import {TestServer} from 'test/gen-server/apiUtils';
import {configForUser} from 'test/gen-server/testUtils';
const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi');
const anon = configForUser('Anonymous');
describe('Telemetry', function() {
const deploymentTypesAndTelemetryLevels: [GristDeploymentType, TelemetryLevel][] = [
['saas', 'off'],
['saas', 'limited'],
['saas', 'full'],
['core', 'off'],
['core', 'limited'],
['core', 'full'],
const variants: [GristDeploymentType, TelemetryLevel, PrefSource][] = [
['saas', 'off', 'environment-variable'],
['saas', 'limited', 'environment-variable'],
['saas', 'full', 'environment-variable'],
['core', 'off', 'environment-variable'],
['core', 'limited', 'environment-variable'],
['core', 'full', 'environment-variable'],
['core', 'off', 'preferences'],
['core', 'limited', 'preferences'],
['core', 'full', 'preferences'],
];
for (const [deploymentType, telemetryLevel] of deploymentTypesAndTelemetryLevels) {
describe(`in grist-${deploymentType} with a telemetry level of "${telemetryLevel}"`, function() {
for (const [deploymentType, telemetryLevel, settingSource] of variants) {
describe(`in grist-${deploymentType} with level "${telemetryLevel}" set via ${settingSource}`, function() {
let server: TestServer;
let homeUrl: string;
let installationId: string;
let server: TestServer;
let telemetry: ITelemetry;
let forwardEventSpy: sinon.SinonSpy;
let postJsonPayloadStub: sinon.SinonStub;
let doForwardEventStub: sinon.SinonStub;
const sandbox = sinon.createSandbox();
const loggedEvents: [TelemetryEvent, ILogMeta][] = [];
before(async function() {
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = deploymentType;
process.env.GRIST_TELEMETRY_LEVEL = telemetryLevel;
if (settingSource === 'environment-variable') {
process.env.GRIST_TELEMETRY_LEVEL = telemetryLevel;
}
process.env.GRIST_DEFAULT_EMAIL = 'chimpy@getgrist.com';
server = new TestServer(this);
homeUrl = await server.start();
if (settingSource ==='preferences') {
await axios.patch(`${homeUrl}/api/install/prefs`, {
telemetry: {telemetryLevel},
}, chimpy);
}
installationId = (await server.server.getActivations().current()).id;
telemetry = server.server.getTelemetry();
sandbox
.stub(LogMethods.prototype, 'rawLog')
.callsFake((_level: string, _info: unknown, name: string, meta: ILogMeta) => {
@ -46,22 +61,39 @@ describe('Telemetry', function() {
});
forwardEventSpy = sandbox
.spy(Telemetry.prototype as any, '_forwardEvent');
postJsonPayloadStub = sandbox
.stub(Telemetry.prototype as any, '_postJsonPayload');
telemetry = server.server.getTelemetry();
doForwardEventStub = sandbox
.stub(Telemetry.prototype as any, '_doForwardEvent');
});
after(async function() {
await server.stop();
sandbox.restore();
delete process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE;
delete process.env.GRIST_TELEMETRY_LEVEL;
delete process.env.GRIST_DEFAULT_EMAIL;
await server.stop();
sandbox.restore();
});
it('returns the current telemetry level', async function() {
assert.equal(telemetry.getTelemetryLevel(), telemetryLevel);
it('returns the current telemetry config', async function() {
assert.deepEqual(telemetry.getTelemetryConfig(), {
telemetryLevel,
});
});
if (deploymentType === 'core') {
it('returns the current telemetry status', async function() {
const resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: telemetryLevel,
source: settingSource,
},
},
});
});
}
if (telemetryLevel !== 'off') {
if (deploymentType === 'saas') {
it('logs telemetry events', async function() {
@ -77,7 +109,7 @@ describe('Telemetry', function() {
{
eventName: 'documentOpened',
eventSource: `grist-${deploymentType}`,
docIdDigest: 'digest',
docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=',
isPublic: false,
}
]);
@ -98,7 +130,7 @@ describe('Telemetry', function() {
{
eventName: 'documentOpened',
eventSource: `grist-${deploymentType}`,
docIdDigest: 'digest',
docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=',
isPublic: false,
userId: 1,
}
@ -120,10 +152,11 @@ describe('Telemetry', function() {
assert.deepEqual(forwardEventSpy.lastCall.args, [
'documentOpened',
{
docIdDigest: 'digest',
docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=',
isPublic: false,
}
]);
assert.equal(forwardEventSpy.callCount, 1);
}
if (telemetryLevel === 'full') {
@ -139,14 +172,15 @@ describe('Telemetry', function() {
assert.deepEqual(forwardEventSpy.lastCall.args, [
'documentOpened',
{
docIdDigest: 'digest',
docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=',
isPublic: false,
userId: 1,
}
]);
// An earlier test triggered an apiUsage event.
assert.equal(forwardEventSpy.callCount, 2);
}
assert.equal(forwardEventSpy.callCount, 1);
assert.isEmpty(loggedEvents);
});
}
@ -179,13 +213,6 @@ describe('Telemetry', function() {
});
if (telemetryLevel === 'limited') {
it('throws an error when an event requires an elevated telemetry level', async function() {
await assert.isRejected(
telemetry.logEvent('signupVerified', {}),
/Telemetry event signupVerified requires a minimum telemetry level of 2 but the current level is 1/
);
});
it("throws an error when an event's metadata requires an elevated telemetry level", async function() {
await assert.isRejected(
telemetry.logEvent('documentOpened', {limited: {userId: 1}}),
@ -290,16 +317,16 @@ describe('Telemetry', function() {
if (telemetryLevel === 'limited') {
assert.equal(forwardEventSpy.callCount, 2);
} else {
// The POST above also triggers an "apiUsage" event.
assert.equal(forwardEventSpy.callCount, 3);
assert.equal(forwardEventSpy.secondCall.args[0], 'apiUsage');
// The count below includes 2 apiUsage events triggered as side effects.
assert.equal(forwardEventSpy.callCount, 4);
assert.equal(forwardEventSpy.thirdCall.args[0], 'apiUsage');
}
assert.isEmpty(loggedEvents);
});
it('skips forwarding events if too many requests are pending', async function() {
let numRequestsMade = 0;
postJsonPayloadStub.callsFake(async () => {
doForwardEventStub.callsFake(async () => {
numRequestsMade += 1;
await new Promise(resolve => setTimeout(resolve, 1000));
});
@ -329,4 +356,169 @@ describe('Telemetry', function() {
}
});
}
describe('api', function() {
let server: TestServer;
let homeUrl: string;
const sandbox = sinon.createSandbox();
before(async function() {
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
process.env.GRIST_DEFAULT_EMAIL = 'chimpy@getgrist.com';
server = new TestServer(this);
homeUrl = await server.start();
sandbox.stub(Telemetry.prototype as any, '_doForwardEvent');
});
after(async function() {
delete process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE;
delete process.env.GRIST_DEFAULT_EMAIL;
await server.stop();
sandbox.restore();
});
it('GET /install/prefs returns 200 for non-default users', async function() {
const resp = await axios.get(`${homeUrl}/api/install/prefs`, kiwi);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: 'off',
source: 'preferences',
},
},
});
});
it('GET /install/prefs returns 200 for the default user', async function() {
const resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: 'off',
source: 'preferences',
},
},
});
});
it('PATCH /install/prefs returns 403 for non-default users', async function() {
const resp = await axios.patch(`${homeUrl}/api/install/prefs`, {
telemetry: {telemetryLevel: 'limited'},
}, kiwi);
assert.equal(resp.status, 403);
});
it('PATCH /install/prefs returns 200 for the default user', async function() {
let resp = await axios.patch(`${homeUrl}/api/install/prefs`, {
telemetry: {telemetryLevel: 'limited'},
}, chimpy);
assert.equal(resp.status, 200);
resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: 'limited',
source: 'preferences',
},
},
});
});
});
describe('filterMetadata', function() {
it('returns filtered and flattened metadata when maxLevel is "full"', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'full'), {
foo: 'abc',
bar: '123',
});
});
it('returns filtered and flattened metadata when maxLevel is "limited"', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'limited'), {
foo: 'abc',
});
});
it('returns undefined when maxLevel is "off"', function() {
assert.isUndefined(filterMetadata(undefined, 'off'));
});
it('returns an empty object when metadata is empty', function() {
assert.isEmpty(filterMetadata({}, 'full'));
});
it('returns undefined when metadata is undefined', function() {
assert.isUndefined(filterMetadata(undefined, 'full'));
});
it('does not mutate metadata', function() {
const metadata = {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
};
filterMetadata(metadata, 'limited');
assert.deepEqual(metadata, {
limited: {
foo: 'abc',
},
full: {
bar: '123',
},
});
});
it('excludes keys with nullish values', function() {
const metadata = {
limited: {
foo1: null,
foo2: 'abc',
},
full: {
bar1: undefined,
bar2: '123',
},
};
assert.deepEqual(filterMetadata(metadata, 'full'), {
foo2: 'abc',
bar2: '123',
});
});
it('hashes keys suffixed with "Digest"', function() {
const metadata = {
limited: {
docIdDigest: 'FGWGX4S6TB6',
docId: '3WH3D68J28',
},
};
assert.deepEqual(filterMetadata(metadata, 'limited'), {
docIdDigest: 'FGWG:omhYAysWiM7coZK+FLK/tIOPW4BaowXjU7J/P9ynYcU=',
docId: '3WH3D68J28',
});
});
});
});

Loading…
Cancel
Save