(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
This commit is contained in:
George Gevoian
2023-07-04 17:21:34 -04:00
parent 051c6d52fe
commit 35237a5835
47 changed files with 1743 additions and 365 deletions

View File

@@ -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));

View File

@@ -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));

View File

@@ -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;
}
}

View File

@@ -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>;

View File

@@ -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).

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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);
}
/**

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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(),
];
}),
));
}

View File

@@ -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) {

View File

@@ -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'],
},
};

View File

@@ -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;
`);

View File

@@ -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;
`);

View File

@@ -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(

View File

@@ -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",

10
app/common/Install.ts Normal file
View File

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

51
app/common/InstallAPI.ts Normal file
View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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')) {

View File

@@ -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;
}
}
}
}

View File

@@ -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

View File

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

View File

@@ -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};

View File

@@ -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');
}
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
},
});
}

View File

@@ -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'),

View File

@@ -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(); },
};
}

View File

@@ -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;
}
metadata = filterMetadata(metadata, this._telemetryLevel);
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, 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,38 +181,51 @@ export class Telemetry implements ITelemetry {
}
}
return resp.status(200).send();
}));
}
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: {}});
}));
}
}
public getTelemetryConfig(): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs;
if (!prefs) {
this._logger.error(undefined, 'getTelemetryConfig called but telemetry preferences are undefined');
return undefined;
}
return {
telemetryLevel: prefs.telemetryLevel.value,
};
}
public async fetchTelemetryPrefs() {
this._activation = await this._gristServer.getActivations().current();
await this._fetchTelemetryPrefs();
}
private async _fetchTelemetryPrefs() {
this._telemetryPrefs = await getTelemetryPrefs(this._dbManager, this._activation);
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value);
}
private _logEvent(
event: TelemetryEvent,
metadata?: TelemetryMetadata
) {
this._telemetryLogger.rawLog('info', null, event, {
eventName: event,
eventSource: `grist-${this._deploymentType}`,
...metadata,
});
}
public getTelemetryLevel() {
return this._telemetryLevel;
}
private async _initialize() {
this._telemetryLevel = getTelemetryLevel();
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryLevel);
}
const {id} = await this._gristServer.getActivations().current();
this._installationId = id;
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));
});
}
}
private _checkTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadata) {
if (!this._checkEvent) {
throw new Error('Telemetry._checkEvent is undefined');
}
this._checkEvent(event, metadata);
}
private async _forwardEvent(
event: TelemetryEvent,
metadata?: TelemetryMetadata
@@ -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,
});
}
}
export function getTelemetryLevel(): TelemetryLevel {
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
return TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL);
} else {
return 'off';
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 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;
}

View File

@@ -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')}`;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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();
}