(core) Admin Panel and InstallAdmin class to identify installation admins.

Summary:
- Add InstallAdmin class to identify users who can manage Grist installation.

  This is overridable by different Grist flavors (e.g. different in SaaS).
  It generalizes previous logic used to decide who can control Activation
  settings (e.g. enable telemetry).

- Implement a basic Admin Panel at /admin, and move items previously in the
  "Support Grist" page into the "Support Grist" section of the Admin Panel.

- Replace "Support Grist" menu items with "Admin Panel" and show only to admins.

- Add "Support Grist" links to Github sponsorship to user-account menu.

- Add "Support Grist" button to top-bar, which
  - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged)
  - for everyone else, links to Github sponsorship
  - in either case, user can dismiss it.

Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4194
test-server-reset
Dmitry S 2 months ago
parent 0c05f4cdc4
commit e380fcfa90

@ -266,7 +266,7 @@ GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the b
GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com". GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com".
GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins
GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default. GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default.
GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled. GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled.
GRIST_HOST | hostname to use when listening on a port. GRIST_HOST | hostname to use when listening on a port.
GRIST_HTTPS_PROXY | if set, use this proxy for webhook payload delivery. GRIST_HTTPS_PROXY | if set, use this proxy for webhook payload delivery.
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*. GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
@ -301,7 +301,7 @@ GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `li
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications. GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications.
GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`. GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`.
GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL. GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie

@ -496,7 +496,7 @@ export class FormView extends Disposable {
async (dontShowAgain) => { async (dontShowAgain) => {
await this._publishForm(); await this._publishForm();
if (dontShowAgain) { if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('publishForm').set(true); this.gristDoc.appModel.dismissPopup('publishForm', true);
} }
}, },
{ {
@ -588,7 +588,7 @@ export class FormView extends Disposable {
async (dontShowAgain) => { async (dontShowAgain) => {
await this._unpublishForm(); await this._unpublishForm();
if (dontShowAgain) { if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('unpublishForm').set(true); this.gristDoc.appModel.dismissPopup('unpublishForm', true);
} }
}, },
{ {

@ -21,7 +21,6 @@ import {reportWarning} from 'app/client/models/errors';
import {IAppError} from 'app/client/models/NotifyModel'; import {IAppError} from 'app/client/models/NotifyModel';
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig} from 'app/common/gristUrls';
import {timeFormat} from 'app/common/timeFormat'; import {timeFormat} from 'app/common/timeFormat';
import {ActiveSessionInfo} from 'app/common/UserAPI';
import * as version from 'app/common/version'; import * as version from 'app/common/version';
import {dom} from 'grainjs'; import {dom} from 'grainjs';
import identity = require('lodash/identity'); import identity = require('lodash/identity');
@ -249,7 +248,7 @@ function getBeaconUserObj(appModel: AppModel|null): IUserObj|null {
if (!appModel) { return null; } if (!appModel) { return null; }
// ActiveSessionInfo["user"] includes optional helpScoutSignature too. // ActiveSessionInfo["user"] includes optional helpScoutSignature too.
const user = appModel.currentValidUser as ActiveSessionInfo["user"]|null; const user = appModel.currentValidUser;
// For anon user, don't attempt to identify anything. Even the "company" field (when anon on a // For anon user, don't attempt to identify anything. Even the "company" field (when anon on a
// team doc) isn't useful, because the user may be external to the company. // team doc) isn't useful, because the user may be external to the company.

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

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

@ -25,8 +25,9 @@ import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs'; ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes'; import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {ExtendedUser} from 'app/common/UserAPI';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs'; import {getUserPrefObs, getUserPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
const t = makeT('AppModel'); const t = makeT('AppModel');
@ -40,7 +41,7 @@ export type PageType =
| "billing" | "billing"
| "welcome" | "welcome"
| "account" | "account"
| "support" | "admin"
| "activation"; | "activation";
const G = getBrowserGlobals('document', 'window'); const G = getBrowserGlobals('document', 'window');
@ -99,8 +100,8 @@ export interface AppModel {
topAppModel: TopAppModel; topAppModel: TopAppModel;
api: UserAPI; api: UserAPI;
currentUser: FullUser|null; currentUser: ExtendedUser|null;
currentValidUser: FullUser|null; // Like currentUser, but null when anonymous currentValidUser: ExtendedUser|null; // Like currentUser, but null when anonymous
currentOrg: Organization|null; // null if no access to currentSubdomain currentOrg: Organization|null; // null if no access to currentSubdomain
currentOrgName: string; // Our best guess for human-friendly name. currentOrgName: string; // Our best guess for human-friendly name.
@ -141,8 +142,8 @@ export interface AppModel {
isSupport(): boolean; // If user is a Support user isSupport(): boolean; // If user is a Support user
isOwner(): boolean; // If user is an owner of this org isOwner(): boolean; // If user is an owner of this org
isOwnerOrEditor(): boolean; // If user is an owner or editor of this org isOwnerOrEditor(): boolean; // If user is an owner or editor of this org
/** Creates an computed observable to dismiss a popup or check if it was dismissed */ isInstallAdmin(): boolean; // Is user an admin of this installation
dismissedPopup(name: DismissedPopup): Observable<boolean>; dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
switchUser(user: FullUser, org?: string): Promise<void>; switchUser(user: FullUser, org?: string): Promise<void>;
} }
@ -283,7 +284,7 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly api: UserAPI = this.topAppModel.api; public readonly api: UserAPI = this.topAppModel.api;
// Compute currentValidUser, turning anonymous into null. // Compute currentValidUser, turning anonymous into null.
public readonly currentValidUser: FullUser|null = public readonly currentValidUser: ExtendedUser|null =
this.currentUser && !this.currentUser.anonymous ? this.currentUser : null; this.currentUser && !this.currentUser.anonymous ? this.currentUser : null;
// Figure out the org name, or blank if details are unavailable. // Figure out the org name, or blank if details are unavailable.
@ -324,8 +325,8 @@ export class AppModelImpl extends Disposable implements AppModel {
return 'welcome'; return 'welcome';
} else if (state.account) { } else if (state.account) {
return 'account'; return 'account';
} else if (state.supportGrist) { } else if (state.adminPanel) {
return 'support'; return 'admin';
} else if (state.activation) { } else if (state.activation) {
return 'activation'; return 'activation';
} else { } else {
@ -340,7 +341,7 @@ export class AppModelImpl extends Disposable implements AppModel {
state.billing === 'scheduled' || state.billing === 'scheduled' ||
Boolean(state.account) || Boolean(state.account) ||
Boolean(state.activation) || Boolean(state.activation) ||
Boolean(state.supportGrist) Boolean(state.adminPanel)
); );
}); });
@ -353,7 +354,7 @@ export class AppModelImpl extends Disposable implements AppModel {
constructor( constructor(
public readonly topAppModel: TopAppModel, public readonly topAppModel: TopAppModel,
public readonly currentUser: FullUser|null, public readonly currentUser: ExtendedUser|null,
public readonly currentOrg: Organization|null, public readonly currentOrg: Organization|null,
public readonly orgError?: OrgError, public readonly orgError?: OrgError,
) { ) {
@ -419,6 +420,10 @@ export class AppModelImpl extends Disposable implements AppModel {
return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg)); return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg));
} }
public isInstallAdmin(): boolean {
return Boolean(this.currentUser?.isInstallAdmin);
}
/** /**
* Fetch and update the current org's usage. * Fetch and update the current org's usage.
*/ */
@ -435,16 +440,8 @@ export class AppModelImpl extends Disposable implements AppModel {
} }
} }
public dismissedPopup(name: DismissedPopup): Computed<boolean> { public dismissPopup(name: DismissedPopup, isSeen: boolean): void {
const computed = Computed.create(null, use => use(this.dismissedPopups).includes(name)); markAsSeen(this.dismissedPopups, name, isSeen);
computed.onWrite(value => {
if (value) {
markAsSeen(this.dismissedPopups, name);
} else {
markAsUnSeen(this.dismissedPopups, name);
}
});
return computed;
} }
public async switchUser(user: FullUser, org?: string) { public async switchUser(user: FullUser, org?: string) {

@ -89,12 +89,16 @@ export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePr
// For preferences that store a list of items (such as seen docTours), this helper updates the // For preferences that store a list of items (such as seen docTours), this helper updates the
// preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future). // preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future).
// prefKey is used only to log a more informative warning on error. // prefKey is used only to log a more informative warning on error.
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) { export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T, isSeen = true) {
const seenIds = seenIdsObs.get() || []; const seenIds = seenIdsObs.get() || [];
try { try {
if (!seenIds.includes(itemId)) { if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds); const seen = new Set(seenIds);
seen.add(itemId); if (isSeen) {
seen.add(itemId);
} else {
seen.delete(itemId);
}
seenIdsObs.set([...seen].sort()); seenIdsObs.set([...seen].sort());
} }
} catch (e) { } catch (e) {
@ -104,17 +108,3 @@ export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T
console.warn("Failed to save preference in markAsSeen", e); console.warn("Failed to save preference in markAsSeen", e);
} }
} }
export function markAsUnSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
const seenIds = seenIdsObs.get() || [];
try {
if (seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.delete(itemId);
seenIdsObs.set([...seen].sort());
}
} catch (e) {
// tslint:disable-next-line:no-console
console.warn("Failed to save preference in markAsUnSeen", e);
}
}

@ -171,7 +171,7 @@ export class UrlStateImpl {
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || 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) ? newState.adminPanel) ?
(prevState.org ? {org: prevState.org} : {}) : (prevState.org ? {org: prevState.org} : {}) :
prevState; prevState;
return {...keepState, ...newState}; return {...keepState, ...newState};
@ -205,10 +205,10 @@ export class UrlStateImpl {
const signupReload = [prevState.login, newState.login].includes('signup') const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login; && prevState.login !== newState.login;
// Reload when moving to/from the support Grist page. // Reload when moving to/from the support Grist page.
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist); const adminPanelReload = Boolean(prevState.adminPanel) !== Boolean(newState.adminPanel);
return Boolean(orgReload || accountReload || billingReload || activationReload || return Boolean(orgReload || accountReload || billingReload || activationReload ||
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload || gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
supportGristReload); adminPanelReload);
} }
/** /**

@ -1,6 +1,7 @@
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
import {manageTeamUsers} from 'app/client/ui/OpenUserManager'; import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
import {createUserImage} from 'app/client/ui/UserImage'; import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport'; import * as viewport from 'app/client/ui/viewport';
@ -146,8 +147,8 @@ export class AccountWidget extends Disposable {
this._maybeBuildBillingPageMenuItem(), this._maybeBuildBillingPageMenuItem(),
this._maybeBuildActivationPageMenuItem(), this._maybeBuildActivationPageMenuItem(),
this._maybeBuildSupportGristPageMenuItem(), this._maybeBuildAdminPanelMenuItem(),
this._maybeBuildSupportGristButton(),
mobileModeToggle, mobileModeToggle,
// TODO Add section ("Here right now") listing icons of other users currently on this doc. // TODO Add section ("Here right now") listing icons of other users currently on this doc.
@ -209,26 +210,34 @@ export class AccountWidget extends Disposable {
} }
private _maybeBuildActivationPageMenuItem() { private _maybeBuildActivationPageMenuItem() {
const {activation, deploymentType} = getGristConfig(); const {deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !activation?.isManager) { if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
return null; return null;
} }
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'})); return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
} }
private _maybeBuildSupportGristPageMenuItem() { private _maybeBuildAdminPanelMenuItem() {
const {deploymentType} = getGristConfig(); // Only show Admin Panel item to the installation admins.
if (deploymentType !== 'core') { if (this._appModel.currentUser?.isInstallAdmin) {
return null; return menuItemLink(
getAdminPanelName(),
urlState().setLinkUrl({adminPanel: 'admin'}),
testId('usermenu-admin-panel'),
);
} }
}
return menuItemLink( private _maybeBuildSupportGristButton() {
t('Support Grist'), const {deploymentType} = getGristConfig();
cssHeartIcon('💛'), const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
urlState().setLinkUrl({supportGrist: 'support'}), if (isEnabled) {
testId('usermenu-support-grist'), return menuItemLink(t('Support Grist'), ' 💛',
); {href: commonUrls.githubSponsorGristLabs, target: '_blank'},
testId('usermenu-support-grist'),
);
}
} }
} }
@ -245,10 +254,6 @@ export const cssUserIcon = styled('div', `
cursor: pointer; cursor: pointer;
`); `);
const cssHeartIcon = styled('span', `
margin-left: 8px;
`);
const cssUserInfo = styled('div', ` const cssUserInfo = styled('div', `
padding: 12px 24px 12px 16px; padding: 12px 24px 12px 16px;
min-width: 200px; min-width: 200px;

@ -0,0 +1,270 @@
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import * as version from 'app/common/version';
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 {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {transition} from 'app/client/ui/transitions';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
const t = makeT('AdminPanel');
// Translated "Admin Panel" name, made available to other modules.
export function getAdminPanelName() {
return t("Admin Panel");
}
export class AdminPanel extends Disposable {
private _supportGrist = SupportGristPage.create(this, this._appModel);
constructor(private _appModel: AppModel) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
}
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),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._buildMainHeader(),
contentTop: buildHomeBanners(this._appModel),
contentMain: dom.create(this._buildMainContent.bind(this)),
});
}
private _buildMainHeader() {
return dom.frag(
cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink(
urlState().setLinkUrl({}),
t('Home'),
),
separator(' / '),
dom('span', getAdminPanelName()),
),
createTopBarHome(this._appModel),
);
}
private _buildMainContent(owner: IDisposableOwner) {
return cssPageContainer(
dom.cls('clipboard'),
{tabIndex: "-1"},
cssSection(
cssSectionTitle(t('Support Grist')),
this._buildItem(owner, {
id: 'telemetry',
name: t('Telemetry'),
description: t('Help us make Grist better'),
value: maybeSwitchToggle(this._supportGrist.getTelemetryOptInObservable()),
expandedContent: this._supportGrist.buildTelemetrySection(),
}),
this._buildItem(owner, {
id: 'sponsor',
name: t('Sponsor'),
description: t('Support Grist Labs on GitHub'),
value: this._supportGrist.buildSponsorshipSmallButton(),
expandedContent: this._supportGrist.buildSponsorshipSection(),
}),
),
cssSection(
cssSectionTitle(t('Version')),
this._buildItem(owner, {
id: 'version',
name: t('Current'),
description: t('Current version of Grist'),
value: cssValueLabel(`Version ${version.version}`),
}),
),
testId('admin-panel'),
);
}
private _buildItem(owner: IDisposableOwner, options: {
id: string,
name: DomContents,
description: DomContents,
value: DomContents,
expandedContent?: DomContents,
}) {
const itemContent = [
cssItemName(options.name, testId(`admin-panel-item-name-${options.id}`)),
cssItemDescription(options.description),
cssItemValue(options.value,
testId(`admin-panel-item-value-${options.id}`),
dom.on('click', ev => ev.stopPropagation())),
];
if (options.expandedContent) {
const isCollapsed = Observable.create(owner, true);
return cssItem(
cssItemShort(
dom.domComputed(isCollapsed, (c) => cssCollapseIcon(c ? 'Expand' : 'Collapse')),
itemContent,
cssItemShort.cls('-expandable'),
dom.on('click', () => isCollapsed.set(!isCollapsed.get())),
),
cssExpandedContentWrap(
transition(isCollapsed, {
prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + 'px' : '0'; },
run(elem, close) { elem.style.maxHeight = close ? '0' : elem.scrollHeight + 'px'; },
finish(elem, close) { elem.style.maxHeight = close ? '0' : 'unset'; },
}),
cssExpandedContent(
options.expandedContent,
),
),
testId(`admin-panel-item-${options.id}`),
);
} else {
return cssItem(
cssItemShort(itemContent),
testId(`admin-panel-item-${options.id}`),
);
}
}
}
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
return dom('div.widget_switch',
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
dom.hide((use) => use(value) === null),
dom.cls('switch_on', (use) => use(value) || false),
dom.cls('switch_transition', true),
dom.on('click', () => value.set(!value.get())),
dom('div.switch_slider'),
dom('div.switch_circle'),
);
}
const cssPageContainer = styled('div', `
overflow: auto;
padding: 40px;
font-size: ${vars.introFontSize};
color: ${theme.text};
@media ${mediaSmall} {
& {
padding: 0px;
font-size: ${vars.mediumFontSize};
}
}
`);
const cssSection = styled('div', `
padding: 24px;
max-width: 600px;
width: 100%;
margin: 16px auto;
border: 1px solid ${theme.widgetBorder};
border-radius: 4px;
@media ${mediaSmall} {
& {
width: auto;
padding: 12px;
margin: 8px;
}
}
`);
const cssSectionTitle = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 16px;
font-size: ${vars.headerControlFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const cssItem = styled('div', `
margin-top: 8px;
`);
const cssItemShort = styled('div', `
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 8px;
margin: 0 -8px;
border-radius: 4px;
&-expandable {
cursor: pointer;
}
&-expandable:hover {
background-color: ${theme.lightHover};
}
`);
const cssItemName = styled('div', `
width: 112px;
font-weight: bold;
font-size: ${vars.largeFontSize};
&:first-child {
margin-left: 28px;
}
@media ${mediaSmall} {
& {
width: calc(100% - 28px);
}
&:first-child {
margin-left: 0;
}
}
`);
const cssItemDescription = styled('div', `
margin-right: auto;
`);
const cssItemValue = styled('div', `
flex: none;
margin: -16px;
padding: 16px;
cursor: auto;
`);
const cssCollapseIcon = styled(icon, `
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -4px;
--icon-color: ${theme.lightText};
`);
const cssExpandedContentWrap = styled('div', `
transition: max-height 0.3s ease-in-out;
overflow: hidden;
max-height: 0;
`);
const cssExpandedContent = styled('div', `
margin-left: 24px;
padding: 24px 0;
border-bottom: 1px solid ${theme.widgetBorder};
.${cssItem.className}:last-child & {
padding-bottom: 0;
border-bottom: none;
}
`);
const cssValueLabel = styled('div', `
padding: 4px 8px;
color: ${theme.text};
border: 1px solid ${theme.inputBorder};
border-radius: ${vars.controlBorderRadius};
`);

@ -167,8 +167,8 @@ export class AppHeader extends Disposable {
} }
private _maybeBuildActivationPageMenuItem() { private _maybeBuildActivationPageMenuItem() {
const {activation, deploymentType} = getGristConfig(); const {deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !activation?.isManager) { if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
return null; return null;
} }

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

@ -180,7 +180,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
// manage card popups will be needed if more are added later. // manage card popups will be needed if more are added later.
return [ return [
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')), upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
home.app.supportGristNudge.showCard(), home.app.supportGristNudge.buildNudgeCard(),
]; ];
}), }),
)); ));

@ -5,6 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel'; import {HomeModel} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton'; import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports'; import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
import { import {
@ -136,6 +137,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
), ),
), ),
createVideoTourToolsButton(), createVideoTourToolsButton(),
(home.app.isInstallAdmin() ?
cssPageEntry(
cssPageLink(cssPageIcon('Settings'), cssLinkText(getAdminPanelName()),
urlState().setLinkUrl({adminPanel: "admin"}),
testId('dm-admin-panel'),
),
) : null
),
createHelpTools(home.app), createHelpTools(home.app),
) )
) )

@ -5,15 +5,13 @@ import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons'; import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
const testId = makeTestId('test-support-grist-nudge-');
const t = makeT('SupportGristNudge'); const t = makeT('SupportGristNudge');
@ -21,115 +19,93 @@ type ButtonState =
| 'collapsed' | 'collapsed'
| 'expanded'; | 'expanded';
type CardPage =
| 'support'
| 'opted-in';
/** /**
* Nudges users to support Grist by opting in to telemetry. * Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
*
* For installation admins, this includes a card with a nudge which collapses into a "Support
* Grist" button in the top bar. When that's not applicable, it is only a "Support Grist" button
* that links to the Github sponsorship page.
* *
* This currently includes a button that opens a card with the nudge. * Users can dismiss these nudges.
* The button is hidden when the card is visible, and vice versa.
*/ */
export class SupportGristNudge extends Disposable { export class SupportGristNudge extends Disposable {
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel); private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, 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');
this._isClosed = Observable.create(this, false);
}
public showButton() { private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`;
if (!this._shouldShowCardOrButton()) { return null; } private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable<ButtonState>;
return dom.maybe( // Whether the nudge just got accepted, and we should temporarily show the "Thanks" version.
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)), private _justAccepted = Observable.create(this, false);
() => this._buildButton()
);
}
public showCard() { private _showButton: Computed<null|'link'|'expand'>;
if (!this._shouldShowCardOrButton()) { return null; } private _showNudge: Computed<null|'normal'|'accepted'>;
return dom.maybe( constructor(private _appModel: AppModel) {
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)), super();
() => this._buildCard() const {deploymentType, telemetry} = getGristConfig();
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
const isAdmin = _appModel.isInstallAdmin();
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
const generallyHide = (use: UseCB) => (
!isEnabled ||
use(_appModel.dismissedPopups).includes('supportGrist') ||
use(isNarrowScreenObs())
); );
}
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') { this._showButton = Computed.create(this, use => {
return false; if (generallyHide(use)) { return null; }
} if (!isAdminNudgeApplicable) { return 'link'; }
if (use(this._buttonState) !== 'expanded') { return 'expand'; }
return true; return null;
});
this._showNudge = Computed.create(this, use => {
if (use(this._justAccepted)) { return 'accepted'; }
if (generallyHide(use)) { return null; }
if (isAdminNudgeApplicable && use(this._buttonState) === 'expanded') { return 'normal'; }
return null;
});
} }
private _buildButton() { public buildTopBarButton(): DomContents {
return cssContributeButton( return dom.domComputed(this._showButton, (which) => {
cssButtonIconAndText( if (!which) { return null; }
icon('Fireworks'), const elemType = (which === 'link') ? basicButtonLink : basicButton;
t('Contribute'), return cssContributeButton(
), elemType(cssHeartIcon('💛 '), t('Support Grist'),
cssContributeButtonCloseButton( (which === 'link' ?
icon('CrossSmall'), {href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
dom.on('click', (ev) => { dom.on('click', () => this._buttonState.set('expanded'))
ev.stopPropagation(); ),
this._dismissAndClose();
}), cssContributeButtonCloseButton(
testId('contribute-button-close'), icon('CrossSmall'),
), dom.on('click', (ev) => {
dom.on('click', () => { this._buttonState.set('expanded'); }), ev.stopPropagation();
testId('contribute-button'), ev.preventDefault();
); this._dismissAndClose();
}),
testId('support-grist-button-dismiss'),
),
testId('support-grist-button'),
)
);
});
} }
private _buildCard() { public buildNudgeCard() {
return cssCard( return dom.domComputed(this._showNudge, nudge => {
dom.domComputed(this._currentPage, page => { if (!nudge) { return null; }
if (page === 'support') { return cssCard(
return this._buildSupportGristCardContent(); (nudge === 'normal' ?
} else { this._buildSupportGristCardContent() :
return this._buildOptedInCardContent(); this._buildOptedInCardContent()
} ),
}), testId('support-nudge'),
testId('card'), );
); });
} }
private _buildSupportGristCardContent() { private _buildSupportGristCardContent() {
@ -137,7 +113,7 @@ export class SupportGristNudge extends Disposable {
cssCloseButton( cssCloseButton(
icon('CrossBig'), icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')), dom.on('click', () => this._buttonState.set('collapsed')),
testId('card-close'), testId('support-nudge-close'),
), ),
cssLeftAlignedHeader(t('Support Grist')), cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t( cssParagraph(t(
@ -150,14 +126,14 @@ export class SupportGristNudge extends Disposable {
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.', 'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
{ {
helpCenterLink: helpCenterLink(), helpCenterLink: helpCenterLink(),
supportGristLink: supportGristLink(), supportGristLink: adminPanelLink(),
}, },
), ),
), ),
cssFullWidthButton( cssFullWidthButton(
t('Opt in to Telemetry'), t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()), dom.on('click', () => this._optInToTelemetry()),
testId('card-opt-in'), testId('support-nudge-opt-in'),
), ),
]; ];
} }
@ -166,8 +142,8 @@ export class SupportGristNudge extends Disposable {
return [ return [
cssCloseButton( cssCloseButton(
icon('CrossBig'), icon('CrossBig'),
dom.on('click', () => this._close()), dom.on('click', () => this._justAccepted.set(false)),
testId('card-close-icon-button'), testId('support-nudge-close'),
), ),
cssCenteredFlex(cssSparks()), cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')), cssCenterAlignedHeader(t('Opted In')),
@ -175,23 +151,34 @@ export class SupportGristNudge extends Disposable {
t( t(
'Thank you! Your trust and support is greatly appreciated. ' + 'Thank you! Your trust and support is greatly appreciated. ' +
'Opt out any time from the {{link}} in the user menu.', 'Opt out any time from the {{link}} in the user menu.',
{link: supportGristLink()}, {link: adminPanelLink()},
), ),
), ),
cssCenteredFlex( cssCenteredFlex(
cssPrimaryButton( cssPrimaryButton(
t('Close'), t('Close'),
dom.on('click', () => this._close()), dom.on('click', () => this._justAccepted.set(false)),
testId('card-close-button'), testId('support-nudge-close-button'),
), ),
), ),
]; ];
} }
private _markDismissed() {
this._appModel.dismissPopup('supportGrist', true);
// Also cleanup the no-longer-needed button state from localStorage.
getStorage().removeItem(this._buttonStateKey);
}
private _dismissAndClose() {
this._markDismissed();
this._justAccepted.set(false);
}
private async _optInToTelemetry() { private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'}); await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._currentPage.set('opted-in'); this._markDismissed();
this._markAsDismissed(); this._justAccepted.set(true);
} }
} }
@ -202,10 +189,10 @@ function helpCenterLink() {
); );
} }
function supportGristLink() { function adminPanelLink() {
return cssLink( return cssLink(
t('Support Grist page'), t('Admin Panel'),
{href: urlState().makeUrl({supportGrist: 'support'}), target: '_blank'}, {href: urlState().makeUrl({adminPanel: 'admin'}), target: '_blank'},
); );
} }
@ -216,26 +203,8 @@ const cssCenteredFlex = styled('div', `
`); `);
const cssContributeButton = styled('div', ` const cssContributeButton = styled('div', `
position: relative; margin-left: 8px;
background: ${theme.controlPrimaryBg}; margin-right: 8px;
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, ` const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
@ -254,10 +223,14 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton,
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
--icon-color: ${colors.light};
.${cssContributeButton.className}:hover & { .${cssContributeButton.className}:hover & {
display: flex; display: flex;
} }
&:hover {
--icon-color: ${colors.lightGreen};
}
`); `);
const cssCard = styled('div', ` const cssCard = styled('div', `
@ -325,3 +298,8 @@ const cssSparks = styled('div', `
display: inline-block; display: inline-block;
background-repeat: no-repeat; background-repeat: no-repeat;
`); `);
// This is just to avoid the emoji pushing the button to be taller.
const cssHeartIcon = styled('span', `
line-height: 1;
`);

@ -1,29 +1,20 @@
import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {AppHeader} from 'app/client/ui/AppHeader'; import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {theme} from 'app/client/ui2018/cssVars';
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 {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI'; import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {getGristConfig} from 'app/common/urlUtils'; import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
const testId = makeTestId('test-support-grist-page-'); const testId = makeTestId('test-support-grist-page-');
const t = makeT('SupportGristPage'); const t = makeT('SupportGristPage');
export class SupportGristPage extends Disposable { export class SupportGristPage extends Disposable {
private readonly _currentPage = Computed.create(this, urlState().state, (_use, s) => s.supportGrist);
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel); private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs, private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
(_use, prefs) => { (_use, prefs) => {
@ -38,62 +29,19 @@ export class SupportGristPage extends Disposable {
constructor(private _appModel: AppModel) { constructor(private _appModel: AppModel) {
super(); super();
this._setPageTitle();
this._model.fetchTelemetryPrefs().catch(reportError); this._model.fetchTelemetryPrefs().catch(reportError);
} }
public buildDom() { public buildTelemetrySection() {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, 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( return cssSection(
cssSectionTitle(t('Telemetry')),
dom.domComputed(this._model.prefs, prefs => { dom.domComputed(this._model.prefs, prefs => {
if (prefs === null) { if (prefs === null) {
return cssSpinnerBox(loadingSpinner()); return cssSpinnerBox(loadingSpinner());
} }
const {activation} = getGristConfig(); if (!this._appModel.isInstallAdmin()) {
if (!activation?.isManager) { // TODO: We are no longer serving this page to non-admin users, so this branch should no
// longer match, and this version perhaps should be removed.
if (prefs.telemetryLevel.value === 'limited') { if (prefs.telemetryLevel.value === 'limited') {
return [ return [
cssParagraph(t( cssParagraph(t(
@ -127,7 +75,9 @@ export class SupportGristPage extends Disposable {
); );
} }
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) { public getTelemetryOptInObservable() { return this._optInToTelemetry; }
public _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
const {telemetryLevel: {value, source}} = prefs; const {telemetryLevel: {value, source}} = prefs;
if (source === 'preferences') { if (source === 'preferences') {
return dom.domComputed(this._optInToTelemetry, (optedIn) => { return dom.domComputed(this._optInToTelemetry, (optedIn) => {
@ -159,9 +109,8 @@ export class SupportGristPage extends Disposable {
} }
} }
private _buildSponsorshipSection() { public buildSponsorshipSection() {
return cssSection( return cssSection(
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
cssParagraph( cssParagraph(
t( t(
'Grist software is developed by Grist Labs, which offers free and paid ' + 'Grist software is developed by Grist Labs, which offers free and paid ' +
@ -189,16 +138,9 @@ export class SupportGristPage extends Disposable {
); );
} }
private _setPageTitle() { public buildSponsorshipSmallButton() {
this.autoDispose(subscribe(this._currentPage, (_use, page): string => { return basicButtonLink('💛 ', t('Sponsor'),
const suffix = getPageTitleSuffix(getGristConfig()); {href: commonUrls.githubSponsorGristLabs, target: '_blank'});
switch (page) {
case undefined:
case 'support': {
return document.title = `Support Grist${suffix}`;
}
}
}));
} }
} }
@ -223,44 +165,7 @@ function gristCoreLink() {
); );
} }
const cssPageContainer = styled('div', ` const cssSection = 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', ` const cssParagraph = styled('div', `
color: ${theme.text}; color: ${theme.text};

@ -28,7 +28,6 @@ export function createTopBarHome(appModel: AppModel) {
return [ return [
cssFlexSpace(), cssFlexSpace(),
appModel.supportGristNudge.showButton(),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ? (appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[ [
basicButton( basicButton(
@ -41,6 +40,8 @@ export function createTopBarHome(appModel: AppModel) {
null null
), ),
appModel.supportGristNudge.buildTopBarButton(),
buildLanguageMenu(appModel), buildLanguageMenu(appModel),
isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel), isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)), dom('div', dom.create(AccountWidget, appModel)),

@ -15,13 +15,11 @@ export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
if (!isFeatureEnabled('tutorials')) { return null; } if (!isFeatureEnabled('tutorials')) { return null; }
const {app} = options; const {app} = options;
const dismissed = app.dismissedPopup('tutorialFirstCard');
owner.autoDispose(dismissed);
function onClose() { function onClose() {
dismissed.set(true); app.dismissPopup('tutorialFirstCard', true);
} }
const visible = Computed.create(owner, (use) => const visible = Computed.create(owner, (use) =>
!use(dismissed) !use(app.dismissedPopups).includes('tutorialFirstCard')
&& !use(isNarrowScreenObs()) && !use(isNarrowScreenObs())
); );
return dom.maybe(visible, () => { return dom.maybe(visible, () => {

@ -243,8 +243,13 @@ export function getUserRoleText(user: UserAccessData) {
return roleNames[user.access!] || user.access || 'no access'; return roleNames[user.access!] || user.access || 'no access';
} }
export interface ExtendedUser extends FullUser {
helpScoutSignature?: string;
isInstallAdmin?: boolean; // Set if user is allowed to manage this installation.
}
export interface ActiveSessionInfo { export interface ActiveSessionInfo {
user: FullUser & {helpScoutSignature?: string}; user: ExtendedUser;
org: Organization|null; org: Organization|null;
orgError?: OrgError; orgError?: OrgError;
} }

@ -44,8 +44,8 @@ export type ActivationPage = typeof ActivationPage.type;
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password'); export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type; export type LoginPage = typeof LoginPage.type;
export const SupportGristPage = StringUnion('support'); export const AdminPanelPage = StringUnion('admin');
export type SupportGristPage = typeof SupportGristPage.type; export type AdminPanelPage = typeof AdminPanelPage.type;
// Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience. // Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('singlePage', 'full'); export const InterfaceStyle = StringUnion('singlePage', 'full');
@ -124,7 +124,7 @@ export interface IGristUrlState {
activation?: ActivationPage; activation?: ActivationPage;
login?: LoginPage; login?: LoginPage;
welcome?: WelcomePage; welcome?: WelcomePage;
supportGrist?: SupportGristPage; adminPanel?: AdminPanelPage;
welcomeTour?: boolean; welcomeTour?: boolean;
docTour?: boolean; docTour?: boolean;
manageUsers?: boolean; manageUsers?: boolean;
@ -309,7 +309,7 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
parts.push(`welcome/${state.welcome}`); parts.push(`welcome/${state.welcome}`);
} }
if (state.supportGrist) { parts.push(state.supportGrist); } if (state.adminPanel) { parts.push(state.adminPanel); }
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string}; const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) { for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
@ -415,7 +415,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
// the minimum length of a urlId prefix is longer than the maximum length // the minimum length of a urlId prefix is longer than the maximum length
// of any of the valid keys in the url. // of any of the valid keys in the url.
for (const key of map.keys()) { for (const key of map.keys()) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) { if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
map.set('doc', key); map.set('doc', key);
map.set('slug', map.get(key)!); map.set('slug', map.get(key)!);
map.delete(key); map.delete(key);
@ -453,9 +453,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.activation = ActivationPage.parse(map.get('activation')) || 'activation'; state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
} }
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); } if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
if (map.has('support')) { if (map.has('admin')) { state.adminPanel = AdminPanelPage.parse(map.get('admin')) || 'admin'; }
state.supportGrist = SupportGristPage.parse(map.get('support')) || 'support';
}
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; } if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; } if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
if (sp.has('billingTask')) { if (sp.has('billingTask')) {
@ -740,7 +738,7 @@ export interface GristLoadConfig {
// Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups. // Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.
tagManagerId?: string; tagManagerId?: string;
activation?: Activation; activation?: ActivationState;
// List of enabled features. // List of enabled features.
features?: IFeature[]; features?: IFeature[];
@ -799,6 +797,7 @@ export const Features = StringUnion(
"multiAccounts", "multiAccounts",
"sendToDrive", "sendToDrive",
"tutorials", "tutorials",
"supportGrist",
); );
export type IFeature = typeof Features.type; export type IFeature = typeof Features.type;
@ -834,10 +833,6 @@ export interface ActivationState {
} }
} }
export interface Activation extends ActivationState {
isManager: boolean;
}
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of // Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
// non-zero length. // non-zero length.
const subdomainRegex = /^[-a-z0-9]+$/i; const subdomainRegex = /^[-a-z0-9]+$/i;

@ -527,7 +527,10 @@ export class ApiServer {
)) : null; )) : null;
const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined; const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined;
return sendOkReply(req, res, { return sendOkReply(req, res, {
user: {...fullUser, helpScoutSignature: helpScoutSign(fullUser.email)}, user: {...fullUser,
helpScoutSignature: helpScoutSign(fullUser.email),
isInstallAdmin: await this._gristServer.getInstallAdmin().isAdminReq(req) || undefined,
},
org: (org && org.data) || null, org: (org && org.data) || null,
orgError orgError
}); });

@ -48,6 +48,7 @@ import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling'; import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier'; import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin} from 'app/server/lib/InstallAdmin';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {getLoginSystem} from 'app/server/lib/logins'; import {getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit'; import {IPermitStore} from 'app/server/lib/Permit';
@ -55,7 +56,7 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint'; import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager'; import {PluginManager} from 'app/server/lib/PluginManager';
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor'; import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isDefaultUser, isParameterOn, optIntegerParam, import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam,
optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET, optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET,
trustOrigin} from 'app/server/lib/requestUtils'; trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage'; import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
@ -133,6 +134,7 @@ export class FlexServer implements GristServer {
private _servesPlugins?: boolean; private _servesPlugins?: boolean;
private _bundledWidgets?: ICustomWidget[]; private _bundledWidgets?: ICustomWidget[];
private _billing: IBilling; private _billing: IBilling;
private _installAdmin: InstallAdmin;
private _instanceRoot: string; private _instanceRoot: string;
private _docManager: DocManager; private _docManager: DocManager;
private _docWorker: DocWorker; private _docWorker: DocWorker;
@ -390,6 +392,11 @@ export class FlexServer implements GristServer {
return this._notifier; return this._notifier;
} }
public getInstallAdmin(): InstallAdmin {
if (!this._installAdmin) { throw new Error('no InstallAdmin available'); }
return this._installAdmin;
}
public getAccessTokens() { public getAccessTokens() {
if (this._accessTokens) { return this._accessTokens; } if (this._accessTokens) { return this._accessTokens; }
this.addDocWorkerMap(); this.addDocWorkerMap();
@ -725,6 +732,7 @@ export class FlexServer implements GristServer {
// If the installation appears to be new, give it an id and a creation date. // If the installation appears to be new, give it an id and a creation date.
this._activations = new Activations(this._dbManager); this._activations = new Activations(this._dbManager);
await this._activations.current(); await this._activations.current();
this._installAdmin = await this.create.createInstallAdmin(this._dbManager);
} }
public addDocWorkerMap() { public addDocWorkerMap() {
@ -864,11 +872,6 @@ export class FlexServer implements GristServer {
this._telemetry = this.create.Telemetry(this._dbManager, this); this._telemetry = this.create.Telemetry(this._dbManager, this);
this._telemetry.addEndpoints(this.app); this._telemetry.addEndpoints(this.app);
this._telemetry.addPages(this.app, [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
]);
await this._telemetry.start(); await this._telemetry.start();
// Start up a monitor for memory and cpu usage. // Start up a monitor for memory and cpu usage.
@ -1787,15 +1790,23 @@ export class FlexServer implements GristServer {
public addInstallEndpoints() { public addInstallEndpoints() {
if (this._check('install')) { return; } if (this._check('install')) { return; }
const isManager = expressWrap( const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
next(); const adminPageMiddleware = [
} this._redirectToHostMiddleware,
); this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
// In principle, it may be safe to show the Admin Panel to non-admins but let's protect it
// since it's intended for admins, and it's easier not to have to worry how it should behave
// for others.
requireInstallAdmin,
];
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => { // Restrict this endpoint to install admins too, for the same reason as the /admin page.
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
const activation = await this._activations.current(); const activation = await this._activations.current();
return sendOkReply(null, resp, { return sendOkReply(null, resp, {
@ -1803,7 +1814,7 @@ export class FlexServer implements GristServer {
}); });
})); }));
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => { this.app.patch('/api/install/prefs', requireInstallAdmin, expressWrap(async (req, resp) => {
const props = {prefs: req.body}; const props = {prefs: req.body};
const activation = await this._activations.current(); const activation = await this._activations.current();
activation.checkProperties(props); activation.checkProperties(props);

@ -16,6 +16,7 @@ import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate'; import { ICreate } from 'app/server/lib/ICreate';
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager'; import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
import { INotifier } from 'app/server/lib/INotifier'; import { INotifier } from 'app/server/lib/INotifier';
import { InstallAdmin } from 'app/server/lib/InstallAdmin';
import { IPermitStore } from 'app/server/lib/Permit'; import { IPermitStore } from 'app/server/lib/Permit';
import { ISendAppPageOptions } from 'app/server/lib/sendAppPage'; import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
import { fromCallback } from 'app/server/lib/serverUtils'; import { fromCallback } from 'app/server/lib/serverUtils';
@ -47,6 +48,7 @@ export interface GristServer {
getDeploymentType(): GristDeploymentType; getDeploymentType(): GristDeploymentType;
getHosts(): Hosts; getHosts(): Hosts;
getActivations(): Activations; getActivations(): Activations;
getInstallAdmin(): InstallAdmin;
getHomeDBManager(): HomeDBManager; getHomeDBManager(): HomeDBManager;
getStorageManager(): IDocStorageManager; getStorageManager(): IDocStorageManager;
getTelemetry(): ITelemetry; getTelemetry(): ITelemetry;
@ -135,6 +137,7 @@ export function createDummyGristServer(): GristServer {
getDeploymentType() { return 'core'; }, getDeploymentType() { return 'core'; },
getHosts() { throw new Error('no hosts'); }, getHosts() { throw new Error('no hosts'); },
getActivations() { throw new Error('no activations'); }, getActivations() { throw new Error('no activations'); },
getInstallAdmin() { throw new Error('no install admin'); },
getHomeDBManager() { throw new Error('no db'); }, getHomeDBManager() { throw new Error('no db'); },
getStorageManager() { throw new Error('no storage manager'); }, getStorageManager() { throw new Error('no storage manager'); },
getTelemetry() { return createDummyTelemetry(); }, getTelemetry() { return createDummyTelemetry(); },
@ -155,7 +158,6 @@ export function createDummyGristServer(): GristServer {
export function createDummyTelemetry(): ITelemetry { export function createDummyTelemetry(): ITelemetry {
return { return {
addEndpoints() { /* do nothing */ }, addEndpoints() { /* do nothing */ },
addPages() { /* do nothing */ },
start() { return Promise.resolve(); }, start() { return Promise.resolve(); },
logEvent() { /* do nothing */ }, logEvent() { /* do nothing */ },
logEventAsync() { return Promise.resolve(); }, logEventAsync() { return Promise.resolve(); },

@ -6,6 +6,7 @@ import {ExternalStorage} from 'app/server/lib/ExternalStorage';
import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer'; import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
import {IBilling} from 'app/server/lib/IBilling'; import {IBilling} from 'app/server/lib/IBilling';
import {INotifier} from 'app/server/lib/INotifier'; import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin';
import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox'; import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox';
import {IShell} from 'app/server/lib/IShell'; import {IShell} from 'app/server/lib/IShell';
import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox'; import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox';
@ -28,6 +29,9 @@ export interface ICreate {
NSandbox(options: ISandboxCreationOptions): ISandbox; NSandbox(options: ISandboxCreationOptions): ISandbox;
// Create the logic to determine which users are authorized to manage this Grist installation.
createInstallAdmin(dbManager: HomeDBManager): Promise<InstallAdmin>;
deploymentType(): GristDeploymentType; deploymentType(): GristDeploymentType;
sessionSecret(): string; sessionSecret(): string;
// Check configuration of the app early enough to show on startup. // Check configuration of the app early enough to show on startup.
@ -80,6 +84,7 @@ export function makeSimpleCreator(opts: {
getExtraHeadHtml?: () => string, getExtraHeadHtml?: () => string,
getSqliteVariant?: () => SqliteVariant, getSqliteVariant?: () => SqliteVariant,
getSandboxVariants?: () => Record<string, SpawnFn>, getSandboxVariants?: () => Record<string, SpawnFn>,
createInstallAdmin?: (dbManager: HomeDBManager) => Promise<InstallAdmin>,
}): ICreate { }): ICreate {
const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts; const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts;
return { return {
@ -157,5 +162,6 @@ export function makeSimpleCreator(opts: {
}, },
getSqliteVariant: opts.getSqliteVariant, getSqliteVariant: opts.getSqliteVariant,
getSandboxVariants: opts.getSandboxVariants, getSandboxVariants: opts.getSandboxVariants,
createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()),
}; };
} }

@ -0,0 +1,52 @@
import {ApiError} from 'app/common/ApiError';
import {appSettings} from 'app/server/lib/AppSettings';
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
import {User} from 'app/gen-server/entity/User';
import express from 'express';
/**
* Class implementing the logic to determine whether a user is authorized to manage the Grist
* installation.
*/
export abstract class InstallAdmin {
// Returns true if user is authorized to manage the Grist installation.
public abstract isAdminUser(user: User): Promise<boolean>;
// Returns true if req is authenticated (contains a user) and the user is authorized to manage
// the Grist installation. This should not fail, only return true or false.
public async isAdminReq(req: express.Request): Promise<boolean> {
const user = (req as RequestWithLogin).user;
return user ? this.isAdminUser(user) : false;
}
// Returns middleware that fails unless the request includes an authenticated user and this user
// is authorized to manage the Grist installation.
public getMiddlewareRequireAdmin(): express.RequestHandler {
return this._requireAdmin.bind(this);
}
private async _requireAdmin(req: express.Request, resp: express.Response, next: express.NextFunction) {
try {
// getUser() will fail with 401 if user is not present.
if (!await this.isAdminUser(getUser(req))) {
throw new ApiError('Access denied', 403);
}
next();
} catch (err) {
next(err);
}
}
}
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
// installation admin. If not given, then there is no admin.
export class SimpleInstallAdmin extends InstallAdmin {
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
envVar: 'GRIST_DEFAULT_EMAIL',
});
public override async isAdminUser(user: User): Promise<boolean> {
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
}
}

@ -58,7 +58,6 @@ export interface ITelemetry {
): Promise<void>; ): Promise<void>;
shouldLogEvent(name: TelemetryEvent): boolean; shouldLogEvent(name: TelemetryEvent): boolean;
addEndpoints(app: express.Express): void; addEndpoints(app: express.Express): void;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined; getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined;
fetchTelemetryPrefs(): Promise<void>; fetchTelemetryPrefs(): Promise<void>;
} }
@ -196,15 +195,6 @@ export class Telemetry implements ITelemetry {
})); }));
} }
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
if (this._deploymentType === 'core') {
app.get('/support', ...middleware, expressWrap(async (req, resp) => {
return this._gristServer.sendAppPage(req, resp,
{path: 'app.html', status: 200, config: {}});
}));
}
}
public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined { public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs; const prefs = this._telemetryPrefs;
if (!prefs) { if (!prefs) {

@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer'; import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
@ -377,9 +377,3 @@ 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;
}

@ -82,7 +82,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
((server?.getBundledWidgets().length || 0) > 0), ((server?.getBundledWidgets().length || 0) > 0),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID, tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined), activation: (req as RequestWithLogin|undefined)?.activation,
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS), enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
supportedLngs: readLoadedLngs(req?.i18n), supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(req?.i18n), namespaces: readLoadedNamespaces(req?.i18n),
@ -306,11 +306,3 @@ function getDocFromConfig(config: GristLoadConfig): Document | null {
return config.getDoc[config.assignmentId] ?? null; return config.getDoc[config.assignmentId] ?? null;
} }
function getActivation(mreq: RequestWithLogin|undefined) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
return {
...mreq?.activation,
isManager: Boolean(defaultEmail && defaultEmail === mreq?.user?.loginEmail),
};
}

@ -31,7 +31,8 @@ if (!process.env.GRIST_SINGLE_ORG) {
setDefaultEnv('GRIST_ORG_IN_PATH', 'true'); setDefaultEnv('GRIST_ORG_IN_PATH', 'true');
} }
setDefaultEnv('GRIST_UI_FEATURES', 'helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,createSite'); setDefaultEnv('GRIST_UI_FEATURES',
'helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,createSite,supportGrist');
setDefaultEnv('GRIST_WIDGET_LIST_URL', commonUrls.gristLabsWidgetRepository); setDefaultEnv('GRIST_WIDGET_LIST_URL', commonUrls.gristLabsWidgetRepository);
import {updateDb} from 'app/server/lib/dbUtils'; import {updateDb} from 'app/server/lib/dbUtils';
import {main as mergedServerMain, parseServerTypes} from 'app/server/mergedServerMain'; import {main as mergedServerMain, parseServerTypes} from 'app/server/mergedServerMain';

@ -0,0 +1,195 @@
import {TelemetryLevel} from 'app/common/Telemetry';
import {assert, driver, Key, WebElement} 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('AdminPanel', function() {
this.timeout(30000);
setupTestSuite();
let oldEnv: testUtils.EnvironmentSnapshot;
let session: gu.Session;
afterEach(() => gu.checkForErrors());
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(true);
});
after(async function() {
oldEnv.restore();
await server.restart(true);
});
it('should not be shown to non-managers', async function() {
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
await gu.openAccountMenu();
assert.equal(await driver.find('.test-usermenu-admin-panel').isPresent(), false);
await driver.sendKeys(Key.ESCAPE);
assert.equal(await driver.find('.test-dm-admin-panel').isPresent(), false);
// Try loading the URL directly.
await driver.get(`${server.getHost()}/admin`);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.equal(await driver.find('.test-admin-panel').isPresent(), false);
});
it('should be shown to managers', async function() {
session = await gu.session().personalSite.login();
await session.loadDocMenu('/');
assert.equal(await driver.find('.test-dm-admin-panel').isDisplayed(), true);
assert.match(await driver.find('.test-dm-admin-panel').getAttribute('href'), /\/admin$/);
await gu.openAccountMenu();
assert.equal(await driver.find('.test-usermenu-admin-panel').isDisplayed(), true);
assert.match(await driver.find('.test-usermenu-admin-panel').getAttribute('href'), /\/admin$/);
await driver.find('.test-usermenu-admin-panel').click();
assert.equal(await waitForAdminPanel().isDisplayed(), true);
});
it('should include support-grist section', async function() {
assert.match(await driver.find('.test-admin-panel-item-sponsor').getText(), /Support Grist Labs on GitHub/);
await withExpandedItem('sponsor', async () => {
const button = await driver.find('.test-support-grist-page-sponsorship-section');
assert.equal(await button.isDisplayed(), true);
assert.match(await button.getText(), /You can support Grist open-source/);
});
});
it('supports opting in to telemetry from the page', async function() {
await assertTelemetryLevel('off');
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
await withExpandedItem('telemetry', async () => {
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
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! 🙏'
);
assert.equal(await isSwitchOn(toggle), true);
});
// Check it's still on after collapsing.
assert.equal(await isSwitchOn(toggle), true);
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
await driver.navigate().refresh();
await waitForAdminPanel();
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), true);
await toggleItem('telemetry');
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());
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
// Reload the page and check that the Grist config indicates telemetry is set to "off".
await driver.navigate().refresh();
await waitForAdminPanel();
await toggleItem('telemetry');
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');
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
});
it('supports toggling telemetry from the toggle in the top line', async function() {
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
await toggle.click();
await gu.waitForServer();
assert.equal(await isSwitchOn(toggle), true);
assert.match(await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
/You have opted in/);
await toggle.click();
await gu.waitForServer();
assert.equal(await isSwitchOn(toggle), false);
await withExpandedItem('telemetry', async () => {
assert.equal(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent(), false);
});
});
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 driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), true);
await toggleItem('telemetry');
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 driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
await toggleItem('telemetry');
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());
});
it('should show version', async function() {
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true);
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
});
});
async function assertTelemetryLevel(level: TelemetryLevel) {
const telemetryLevel = await driver.executeScript('return window.gristConfig.telemetry?.telemetryLevel');
assert.equal(telemetryLevel, level);
}
async function toggleItem(itemId: string) {
const header = await driver.find(`.test-admin-panel-item-name-${itemId}`);
await header.click();
await driver.sleep(500); // Time to expand or collapse.
return header;
}
async function withExpandedItem(itemId: string, callback: () => Promise<void>) {
const header = await toggleItem(itemId);
await callback();
await header.click();
await driver.sleep(500); // Time to collapse.
}
const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]');
const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000);

@ -5,6 +5,8 @@ import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils'; import * as testUtils from 'test/server/testUtils';
const sponsorshipUrl = 'https://github.com/sponsors/gristlabs';
describe('SupportGrist', function() { describe('SupportGrist', function() {
this.timeout(30000); this.timeout(30000);
setupTestSuite(); setupTestSuite();
@ -14,6 +16,10 @@ describe('SupportGrist', function() {
afterEach(() => gu.checkForErrors()); afterEach(() => gu.checkForErrors());
after(async function() {
await server.restart();
});
describe('in grist-core', function() { describe('in grist-core', function() {
before(async function() { before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot(); oldEnv = new testUtils.EnvironmentSnapshot();
@ -24,133 +30,66 @@ describe('SupportGrist', function() {
after(async function() { after(async function() {
oldEnv.restore(); oldEnv.restore();
await server.restart();
}); });
describe('when user is not a manager', function() { describe('when user is not a manager', function() {
before(async function() { before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().user('user2').personalSite.login(); session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/'); await session.loadDocMenu('/');
}); });
after(async function() {
oldEnv.restore();
});
it('does not show a nudge on the doc menu', async function() { it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
await assertSupportButtonShown(true, {isSponsorLink: true});
}); });
it('shows a link to the Support Grist page in the user menu', async function() { it('shows a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu(); await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click(); await assertMenuHasAdminPanel(false);
assert.isTrue(await driver.findContentWait( await assertMenuHasSupportGrist(true);
'.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() { describe('when user is a manager', function() {
before(async function() { before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().personalSite.login(); session = await gu.session().personalSite.login();
await session.loadDocMenu('/'); await session.loadDocMenu('/');
}); });
after(async function() {
oldEnv.restore();
});
it('shows a nudge on the doc menu', async function() { it('shows a nudge on the doc menu', async function() {
// Check that the nudge is expanded by default. // Check that the nudge is expanded by default.
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(true); await assertNudgeCardShown(true);
// Reload the doc menu and check that it's still expanded. // Reload the doc menu and check that it's still expanded.
await session.loadDocMenu('/'); await session.loadDocMenu('/');
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(true); await assertNudgeCardShown(true);
// Close the nudge and check that it's now collapsed. // Close the nudge and check that it's now collapsed.
await driver.find('.test-support-grist-nudge-card-close').click(); await driver.find('.test-support-nudge-close').click();
await assertNudgeButtonShown(true); await assertSupportButtonShown(true, {isSponsorLink: false});
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
// Reload again, and check that it's still collapsed. // Reload again, and check that it's still collapsed.
await session.loadDocMenu('/'); await session.loadDocMenu('/');
await assertNudgeButtonShown(true); await assertSupportButtonShown(true, {isSponsorLink: false});
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
// Dismiss the contribute button and check that it's now gone, even after reloading. // 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-button').mouseMove();
await driver.find('.test-support-grist-nudge-contribute-button-close').click(); await driver.find('.test-support-grist-button-dismiss').click();
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
await session.loadDocMenu('/'); await session.loadDocMenu('/');
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
}); });
it('shows a link to the Support Grist page in the user menu', async function() { it('shows a link to Admin Panel and Support Grist in the user menu', async function() {
await gu.openAccountMenu(); await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click(); await assertMenuHasAdminPanel(true);
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000); await assertMenuHasSupportGrist(true);
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() { it('supports opting in to telemetry from the nudge', async function() {
@ -160,14 +99,14 @@ describe('SupportGrist', function() {
await session.loadDocMenu('/'); await session.loadDocMenu('/');
// Opt in to telemetry and reload the page. // Opt in to telemetry and reload the page.
await driver.find('.test-support-grist-nudge-card-opt-in').click(); await driver.find('.test-support-nudge-opt-in').click();
await driver.findWait('.test-support-grist-nudge-card-close-button', 1000).click(); await driver.findWait('.test-support-nudge-close-button', 1000).click();
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
await session.loadDocMenu('/'); await session.loadDocMenu('/');
// Check that the nudge is no longer shown and telemetry is set to "limited". // Check that the nudge is no longer shown and telemetry is set to "limited".
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
await assertTelemetryLevel('limited'); await assertTelemetryLevel('limited');
}); });
@ -179,52 +118,55 @@ describe('SupportGrist', function() {
// Reload the doc menu and check that the nudge still isn't shown. // Reload the doc menu and check that the nudge still isn't shown.
await session.loadDocMenu('/'); await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
// We still show the "Support Grist" button linking to sponsorship page.
await assertSupportButtonShown(true, {isSponsorLink: true});
// Disable telemetry from the Support Grist page. // Disable telemetry from the Support Grist page.
await gu.openAccountMenu(); await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click(); await driver.find('.test-usermenu-admin-panel').click();
await driver.findWait('.test-admin-panel', 2000);
await driver.find('.test-admin-panel-item-name-telemetry').click();
await driver.sleep(500); // Wait for section to expand.
await driver.findContentWait( await driver.findContentWait(
'.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000).click(); '.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); 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. // Reload the doc menu and check that the nudge is now shown.
await gu.loadDocMenu('/'); await gu.loadDocMenu('/');
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(true); await assertNudgeCardShown(true);
}); });
it('shows telemetry opt-in status even when set via environment variable', async function() { it('shows sponsorship link when no telemetry nudge, and allows dismissing it', async function() {
// Set the telemetry level to "limited" via environment variable and restart the server. // Reset all dismissed popups, including the telemetry nudge.
process.env.GRIST_TELEMETRY_LEVEL = 'limited'; await driver.executeScript('resetDismissedPopups();');
await server.restart(); await gu.waitForServer();
// Opt in to telemetry
const api = session.createHomeApi();
await api.testRequest(`${api.getBaseUrl()}/api/install/prefs`, {
method: 'patch',
body: JSON.stringify({telemetry: {telemetryLevel: 'limited'}}),
});
// Check that the Support Grist page reports telemetry is enabled. await session.loadDocMenu('/');
await gu.loadDocMenu('/'); await assertTelemetryLevel('limited');
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click(); // We still show the "Support Grist" button linking to sponsorship page.
assert.equal( await assertSupportButtonShown(true, {isSponsorLink: true});
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(), await assertNudgeCardShown(false);
'You have opted in to telemetry. Thank you! 🙏'
); // we can dismiss it.
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button', await driver.find('.test-support-grist-button').mouseMove();
/Opt out of Telemetry/).isPresent()); await driver.find('.test-support-grist-button-dismiss').click();
await assertSupportButtonShown(false);
// Now set the telemetry level to "off" and restart the server.
process.env.GRIST_TELEMETRY_LEVEL = 'off'; // And this will get remembered.
await server.restart(); await session.loadDocMenu('/');
await assertNudgeCardShown(false);
// Check that the Support Grist page reports telemetry is disabled. await assertSupportButtonShown(false);
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());
}); });
}); });
}); });
@ -241,17 +183,25 @@ describe('SupportGrist', function() {
after(async function() { after(async function() {
oldEnv.restore(); oldEnv.restore();
await server.restart();
}); });
it('does not show a nudge on the doc menu', async function() { it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
}); });
it('does not show a link to the Support Grist page in the user menu', async function() { it('shows Admin Panel but not Support Grist in the user menu for admin', async function() {
await gu.openAccountMenu();
await assertMenuHasAdminPanel(true);
await assertMenuHasSupportGrist(false);
});
it('does not show Admin Panel or Support Grist in the user menu for non-admin', async function() {
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
await gu.openAccountMenu(); await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent()); await assertMenuHasAdminPanel(false);
await assertMenuHasSupportGrist(false);
}); });
}); });
@ -267,38 +217,49 @@ describe('SupportGrist', function() {
after(async function() { after(async function() {
oldEnv.restore(); oldEnv.restore();
await server.restart();
}); });
it('does not show a nudge on the doc menu', async function() { it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false); await assertSupportButtonShown(false);
await assertNudgeCardShown(false); await assertNudgeCardShown(false);
}); });
it('does not show a link to the Support Grist page in the user menu', async function() { it('shows Admin Panel but not Support Grist page in the user menu', async function() {
await gu.openAccountMenu(); await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent()); await assertMenuHasAdminPanel(true);
await assertMenuHasSupportGrist(false);
}); });
}); });
}); });
async function assertNudgeButtonShown(isShown: boolean) { async function assertSupportButtonShown(isShown: false): Promise<void>;
async function assertSupportButtonShown(isShown: true, opts: {isSponsorLink: boolean}): Promise<void>;
async function assertSupportButtonShown(isShown: boolean, opts?: {isSponsorLink: boolean}) {
const button = driver.find('.test-support-grist-button');
assert.equal(await button.isPresent() && await button.isDisplayed(), isShown);
if (isShown) { if (isShown) {
assert.isTrue( assert.equal(await button.getAttribute('href'), opts?.isSponsorLink ? sponsorshipUrl : null);
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) { async function assertNudgeCardShown(isShown: boolean) {
const card = driver.find('.test-support-nudge');
assert.equal(await card.isPresent() && await card.isDisplayed(), isShown);
}
async function assertMenuHasAdminPanel(isShown: boolean) {
const elem = driver.find('.test-usermenu-admin-panel');
assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);
if (isShown) {
assert.match(await elem.getAttribute('href'), /.*\/admin$/);
}
}
async function assertMenuHasSupportGrist(isShown: boolean) {
const elem = driver.find('.test-usermenu-support-grist');
assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);
if (isShown) { if (isShown) {
assert.isTrue( assert.equal(await elem.getAttribute('href'), sponsorshipUrl);
await driver.find('.test-support-grist-nudge-card').isDisplayed()
);
} else {
assert.isFalse(await driver.find('.test-support-grist-nudge-card').isPresent());
} }
} }

@ -393,17 +393,9 @@ describe('Telemetry', function() {
sandbox.restore(); sandbox.restore();
}); });
it('GET /install/prefs returns 200 for non-default users', async function() { it('GET /install/prefs returns 403 for non-default users', async function() {
const resp = await axios.get(`${homeUrl}/api/install/prefs`, kiwi); const resp = await axios.get(`${homeUrl}/api/install/prefs`, kiwi);
assert.equal(resp.status, 200); assert.equal(resp.status, 403);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: 'off',
source: 'preferences',
},
},
});
}); });
it('GET /install/prefs returns 200 for the default user', async function() { it('GET /install/prefs returns 200 for the default user', async function() {

Loading…
Cancel
Save