From e380fcfa903da82f68c519df3c9c1e1d97fbb934 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Sat, 23 Mar 2024 13:11:06 -0400 Subject: [PATCH] (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 --- README.md | 4 +- app/client/components/Forms/FormView.ts | 4 +- app/client/lib/helpScout.ts | 3 +- app/client/lib/imports.d.ts | 4 +- app/client/lib/imports.js | 2 +- app/client/models/AppModel.ts | 39 ++-- app/client/models/UserPrefs.ts | 22 +- app/client/models/gristUrlState.ts | 6 +- app/client/ui/AccountWidget.ts | 41 ++-- app/client/ui/AdminPanel.ts | 270 ++++++++++++++++++++++++ app/client/ui/AppHeader.ts | 4 +- app/client/ui/AppUI.ts | 6 +- app/client/ui/DocMenu.ts | 2 +- app/client/ui/HomeLeftPane.ts | 9 + app/client/ui/SupportGristNudge.ts | 246 ++++++++++----------- app/client/ui/SupportGristPage.ts | 127 ++--------- app/client/ui/TopBar.ts | 3 +- app/client/ui/TutorialCard.ts | 6 +- app/common/UserAPI.ts | 7 +- app/common/gristUrls.ts | 21 +- app/gen-server/ApiServer.ts | 5 +- app/server/lib/FlexServer.ts | 39 ++-- app/server/lib/GristServer.ts | 4 +- app/server/lib/ICreate.ts | 6 + app/server/lib/InstallAdmin.ts | 52 +++++ app/server/lib/Telemetry.ts | 10 - app/server/lib/requestUtils.ts | 8 +- app/server/lib/sendAppPage.ts | 10 +- stubs/app/server/server.ts | 3 +- test/nbrowser/AdminPanel.ts | 195 +++++++++++++++++ test/nbrowser/SupportGrist.ts | 235 +++++++++------------ test/server/lib/Telemetry.ts | 12 +- 32 files changed, 878 insertions(+), 527 deletions(-) create mode 100644 app/client/ui/AdminPanel.ts create mode 100644 app/server/lib/InstallAdmin.ts create mode 100644 test/nbrowser/AdminPanel.ts diff --git a/README.md b/README.md index 2af173f6..9427d4e0 100644 --- a/README.md +++ b/README.md @@ -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_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_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_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}*. @@ -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_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_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_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 diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index 47931d2c..e53b21c5 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -496,7 +496,7 @@ export class FormView extends Disposable { async (dontShowAgain) => { await this._publishForm(); 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) => { await this._unpublishForm(); if (dontShowAgain) { - this.gristDoc.appModel.dismissedPopup('unpublishForm').set(true); + this.gristDoc.appModel.dismissPopup('unpublishForm', true); } }, { diff --git a/app/client/lib/helpScout.ts b/app/client/lib/helpScout.ts index 137b4dfe..ab8f1782 100644 --- a/app/client/lib/helpScout.ts +++ b/app/client/lib/helpScout.ts @@ -21,7 +21,6 @@ import {reportWarning} from 'app/client/models/errors'; import {IAppError} from 'app/client/models/NotifyModel'; import {GristLoadConfig} from 'app/common/gristUrls'; import {timeFormat} from 'app/common/timeFormat'; -import {ActiveSessionInfo} from 'app/common/UserAPI'; import * as version from 'app/common/version'; import {dom} from 'grainjs'; import identity = require('lodash/identity'); @@ -249,7 +248,7 @@ function getBeaconUserObj(appModel: AppModel|null): IUserObj|null { if (!appModel) { return null; } // 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 // team doc) isn't useful, because the user may be external to the company. diff --git a/app/client/lib/imports.d.ts b/app/client/lib/imports.d.ts index 4691bef6..7dd719d8 100644 --- a/app/client/lib/imports.d.ts +++ b/app/client/lib/imports.d.ts @@ -1,7 +1,7 @@ import * as AccountPageModule from 'app/client/ui/AccountPage'; import * as ActivationPageModule from 'app/client/ui/ActivationPage'; import * as BillingPageModule from 'app/client/ui/BillingPage'; -import * as SupportGristPageModule from 'app/client/ui/SupportGristPage'; +import * as AdminPanelModule from 'app/client/ui/AdminPanel'; import * as GristDocModule from 'app/client/components/GristDoc'; import * as ViewPane from 'app/client/components/ViewPane'; import * as UserManagerModule from 'app/client/ui/UserManager'; @@ -15,7 +15,7 @@ export type MomentTimezone = typeof momentTimezone; export function loadAccountPage(): Promise; export function loadActivationPage(): Promise; export function loadBillingPage(): Promise; -export function loadSupportGristPage(): Promise; +export function loadAdminPanel(): Promise; export function loadGristDoc(): Promise; export function loadMomentTimezone(): Promise; export function loadPlotly(): Promise; diff --git a/app/client/lib/imports.js b/app/client/lib/imports.js index b5d6f428..7747d77d 100644 --- a/app/client/lib/imports.js +++ b/app/client/lib/imports.js @@ -9,7 +9,7 @@ exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */); exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */); exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */); -exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */); +exports.loadAdminPanel = () => import('app/client/ui/AdminPanel' /* webpackChunkName: "AdminPanel" */); exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */); // When importing this way, the module is under the "default" member, not sure why (maybe // esbuild-loader's doing). diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 555dd66d..a2c12811 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -25,8 +25,9 @@ import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs'; import {getThemeColors} from 'app/common/Themes'; import {getGristConfig} from 'app/common/urlUtils'; +import {ExtendedUser} 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'; const t = makeT('AppModel'); @@ -40,7 +41,7 @@ export type PageType = | "billing" | "welcome" | "account" - | "support" + | "admin" | "activation"; const G = getBrowserGlobals('document', 'window'); @@ -99,8 +100,8 @@ export interface AppModel { topAppModel: TopAppModel; api: UserAPI; - currentUser: FullUser|null; - currentValidUser: FullUser|null; // Like currentUser, but null when anonymous + currentUser: ExtendedUser|null; + currentValidUser: ExtendedUser|null; // Like currentUser, but null when anonymous currentOrg: Organization|null; // null if no access to currentSubdomain currentOrgName: string; // Our best guess for human-friendly name. @@ -141,8 +142,8 @@ export interface AppModel { isSupport(): boolean; // If user is a Support user isOwner(): boolean; // If user is an owner 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 */ - dismissedPopup(name: DismissedPopup): Observable; + isInstallAdmin(): boolean; // Is user an admin of this installation + dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not. switchUser(user: FullUser, org?: string): Promise; } @@ -283,7 +284,7 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly api: UserAPI = this.topAppModel.api; // Compute currentValidUser, turning anonymous into null. - public readonly currentValidUser: FullUser|null = + public readonly currentValidUser: ExtendedUser|null = this.currentUser && !this.currentUser.anonymous ? this.currentUser : null; // Figure out the org name, or blank if details are unavailable. @@ -324,8 +325,8 @@ export class AppModelImpl extends Disposable implements AppModel { return 'welcome'; } else if (state.account) { return 'account'; - } else if (state.supportGrist) { - return 'support'; + } else if (state.adminPanel) { + return 'admin'; } else if (state.activation) { return 'activation'; } else { @@ -340,7 +341,7 @@ export class AppModelImpl extends Disposable implements AppModel { state.billing === 'scheduled' || Boolean(state.account) || Boolean(state.activation) || - Boolean(state.supportGrist) + Boolean(state.adminPanel) ); }); @@ -353,7 +354,7 @@ export class AppModelImpl extends Disposable implements AppModel { constructor( public readonly topAppModel: TopAppModel, - public readonly currentUser: FullUser|null, + public readonly currentUser: ExtendedUser|null, public readonly currentOrg: Organization|null, public readonly orgError?: OrgError, ) { @@ -419,6 +420,10 @@ export class AppModelImpl extends Disposable implements AppModel { return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg)); } + public isInstallAdmin(): boolean { + return Boolean(this.currentUser?.isInstallAdmin); + } + /** * Fetch and update the current org's usage. */ @@ -435,16 +440,8 @@ export class AppModelImpl extends Disposable implements AppModel { } } - public dismissedPopup(name: DismissedPopup): Computed { - const computed = Computed.create(null, use => use(this.dismissedPopups).includes(name)); - computed.onWrite(value => { - if (value) { - markAsSeen(this.dismissedPopups, name); - } else { - markAsUnSeen(this.dismissedPopups, name); - } - }); - return computed; + public dismissPopup(name: DismissedPopup, isSeen: boolean): void { + markAsSeen(this.dismissedPopups, name, isSeen); } public async switchUser(user: FullUser, org?: string) { diff --git a/app/client/models/UserPrefs.ts b/app/client/models/UserPrefs.ts index 3150ba33..9b64a8a6 100644 --- a/app/client/models/UserPrefs.ts +++ b/app/client/models/UserPrefs.ts @@ -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 // 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. -export function markAsSeen(seenIdsObs: Observable, itemId: T) { +export function markAsSeen(seenIdsObs: Observable, itemId: T, isSeen = true) { const seenIds = seenIdsObs.get() || []; try { if (!seenIds.includes(itemId)) { const seen = new Set(seenIds); - seen.add(itemId); + if (isSeen) { + seen.add(itemId); + } else { + seen.delete(itemId); + } seenIdsObs.set([...seen].sort()); } } catch (e) { @@ -104,17 +108,3 @@ export function markAsSeen(seenIdsObs: Observable, itemId: T console.warn("Failed to save preference in markAsSeen", e); } } - -export function markAsUnSeen(seenIdsObs: Observable, 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); - } -} diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 11ae3c83..1d9fa247 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -171,7 +171,7 @@ export class UrlStateImpl { public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || newState.account || newState.billing || newState.activation || newState.welcome || - newState.supportGrist) ? + newState.adminPanel) ? (prevState.org ? {org: prevState.org} : {}) : prevState; return {...keepState, ...newState}; @@ -205,10 +205,10 @@ export class UrlStateImpl { const signupReload = [prevState.login, newState.login].includes('signup') && prevState.login !== newState.login; // 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 || gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload || - supportGristReload); + adminPanelReload); } /** diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 56c8d11d..0062e4d6 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -1,6 +1,7 @@ import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; 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 {createUserImage} from 'app/client/ui/UserImage'; import * as viewport from 'app/client/ui/viewport'; @@ -146,8 +147,8 @@ export class AccountWidget extends Disposable { this._maybeBuildBillingPageMenuItem(), this._maybeBuildActivationPageMenuItem(), - this._maybeBuildSupportGristPageMenuItem(), - + this._maybeBuildAdminPanelMenuItem(), + this._maybeBuildSupportGristButton(), mobileModeToggle, // 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() { - const {activation, deploymentType} = getGristConfig(); - if (deploymentType !== 'enterprise' || !activation?.isManager) { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) { return null; } return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'})); } - private _maybeBuildSupportGristPageMenuItem() { - const {deploymentType} = getGristConfig(); - if (deploymentType !== 'core') { - return null; + private _maybeBuildAdminPanelMenuItem() { + // Only show Admin Panel item to the installation admins. + if (this._appModel.currentUser?.isInstallAdmin) { + return menuItemLink( + getAdminPanelName(), + urlState().setLinkUrl({adminPanel: 'admin'}), + testId('usermenu-admin-panel'), + ); } + } - return menuItemLink( - t('Support Grist'), - cssHeartIcon('💛'), - urlState().setLinkUrl({supportGrist: 'support'}), - testId('usermenu-support-grist'), - ); + private _maybeBuildSupportGristButton() { + const {deploymentType} = getGristConfig(); + const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist"); + if (isEnabled) { + 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; `); -const cssHeartIcon = styled('span', ` - margin-left: 8px; -`); - const cssUserInfo = styled('div', ` padding: 12px 24px 12px 16px; min-width: 200px; diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts new file mode 100644 index 00000000..a8774233 --- /dev/null +++ b/app/client/ui/AdminPanel.ts @@ -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): 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}; +`); diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 43448789..a467e607 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -167,8 +167,8 @@ export class AppHeader extends Disposable { } private _maybeBuildActivationPageMenuItem() { - const {activation, deploymentType} = getGristConfig(); - if (deploymentType !== 'enterprise' || !activation?.isManager) { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) { return null; } diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 5a659fca..ccbe272b 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,7 +1,7 @@ import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; import {ViewAsBanner} from 'app/client/components/ViewAsBanner'; import {domAsync} from 'app/client/lib/domAsync'; -import {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 {AppModel, TopAppModel} from 'app/client/models/AppModel'; import {DocPageModelImpl} from 'app/client/models/DocPageModel'; @@ -81,8 +81,8 @@ function createMainPage(appModel: AppModel, appObj: App) { return dom.create(WelcomePage, appModel); } else if (pageType === 'account') { return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel))); - } else if (pageType === 'support') { - return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel))); + } else if (pageType === 'admin') { + return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel))); } else if (pageType === 'activation') { return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel))); } else { diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index d8082572..90d5b26f 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -180,7 +180,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { // manage card popups will be needed if more are added later. return [ upgradeButton.showUpgradeCard(css.upgradeCard.cls('')), - home.app.supportGristNudge.showCard(), + home.app.supportGristNudge.buildNudgeCard(), ]; }), )); diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index c2858d77..e695345d 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -5,6 +5,7 @@ import {reportError} from 'app/client/models/AppModel'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; +import {getAdminPanelName} from 'app/client/ui/AdminPanel'; import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton'; import {docImport, importFromPlugin} from 'app/client/ui/HomeImports'; import { @@ -136,6 +137,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom ), ), createVideoTourToolsButton(), + (home.app.isInstallAdmin() ? + cssPageEntry( + cssPageLink(cssPageIcon('Settings'), cssLinkText(getAdminPanelName()), + urlState().setLinkUrl({adminPanel: "admin"}), + testId('dm-admin-panel'), + ), + ) : null + ), createHelpTools(home.app), ) ) diff --git a/app/client/ui/SupportGristNudge.ts b/app/client/ui/SupportGristNudge.ts index 3a5de64d..ae13a0b5 100644 --- a/app/client/ui/SupportGristNudge.ts +++ b/app/client/ui/SupportGristNudge.ts @@ -5,15 +5,13 @@ import {tokenFieldStyles} from 'app/client/lib/TokenField'; import {AppModel} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; -import {bigPrimaryButton} from 'app/client/ui2018/buttons'; -import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; +import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; -import {commonUrls} from 'app/common/gristUrls'; +import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; -import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs'; - -const testId = makeTestId('test-support-grist-nudge-'); +import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs'; const t = makeT('SupportGristNudge'); @@ -21,115 +19,93 @@ type ButtonState = | 'collapsed' | '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. - * The button is hidden when the card is visible, and vice versa. + * Users can dismiss these nudges. */ export class SupportGristNudge extends Disposable { - private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel); - - private readonly _buttonState: Observable; - private readonly _currentPage: Observable; - private readonly _isClosed: Observable; - - constructor(private _appModel: AppModel) { - super(); - if (!this._shouldShowCardOrButton()) { return; } - - this._buttonState = localStorageObs( - `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded' - ) as Observable; - this._currentPage = Observable.create(null, 'support'); - this._isClosed = Observable.create(this, false); - } + private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel); - public showButton() { - if (!this._shouldShowCardOrButton()) { return null; } + private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`; + private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable; - return dom.maybe( - use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)), - () => this._buildButton() - ); - } + // Whether the nudge just got accepted, and we should temporarily show the "Thanks" version. + private _justAccepted = Observable.create(this, false); - public showCard() { - if (!this._shouldShowCardOrButton()) { return null; } + private _showButton: Computed; + private _showNudge: Computed; - return dom.maybe( - use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)), - () => this._buildCard() + constructor(private _appModel: AppModel) { + super(); + 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') { - return false; - } - - return true; + this._showButton = Computed.create(this, use => { + if (generallyHide(use)) { return null; } + if (!isAdminNudgeApplicable) { return 'link'; } + if (use(this._buttonState) !== 'expanded') { return 'expand'; } + 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() { - return cssContributeButton( - cssButtonIconAndText( - icon('Fireworks'), - t('Contribute'), - ), - cssContributeButtonCloseButton( - icon('CrossSmall'), - dom.on('click', (ev) => { - ev.stopPropagation(); - this._dismissAndClose(); - }), - testId('contribute-button-close'), - ), - dom.on('click', () => { this._buttonState.set('expanded'); }), - testId('contribute-button'), - ); + public buildTopBarButton(): DomContents { + return dom.domComputed(this._showButton, (which) => { + if (!which) { return null; } + const elemType = (which === 'link') ? basicButtonLink : basicButton; + return cssContributeButton( + elemType(cssHeartIcon('💛 '), t('Support Grist'), + (which === 'link' ? + {href: commonUrls.githubSponsorGristLabs, target: '_blank'} : + dom.on('click', () => this._buttonState.set('expanded')) + ), + + cssContributeButtonCloseButton( + icon('CrossSmall'), + dom.on('click', (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this._dismissAndClose(); + }), + testId('support-grist-button-dismiss'), + ), + testId('support-grist-button'), + ) + ); + }); } - private _buildCard() { - return cssCard( - dom.domComputed(this._currentPage, page => { - if (page === 'support') { - return this._buildSupportGristCardContent(); - } else { - return this._buildOptedInCardContent(); - } - }), - testId('card'), - ); + public buildNudgeCard() { + return dom.domComputed(this._showNudge, nudge => { + if (!nudge) { return null; } + return cssCard( + (nudge === 'normal' ? + this._buildSupportGristCardContent() : + this._buildOptedInCardContent() + ), + testId('support-nudge'), + ); + }); } private _buildSupportGristCardContent() { @@ -137,7 +113,7 @@ export class SupportGristNudge extends Disposable { cssCloseButton( icon('CrossBig'), dom.on('click', () => this._buttonState.set('collapsed')), - testId('card-close'), + testId('support-nudge-close'), ), cssLeftAlignedHeader(t('Support Grist')), cssParagraph(t( @@ -150,14 +126,14 @@ export class SupportGristNudge extends Disposable { 'document contents. Opt out any time from the {{supportGristLink}} in the user menu.', { helpCenterLink: helpCenterLink(), - supportGristLink: supportGristLink(), + supportGristLink: adminPanelLink(), }, ), ), cssFullWidthButton( t('Opt in to Telemetry'), dom.on('click', () => this._optInToTelemetry()), - testId('card-opt-in'), + testId('support-nudge-opt-in'), ), ]; } @@ -166,8 +142,8 @@ export class SupportGristNudge extends Disposable { return [ cssCloseButton( icon('CrossBig'), - dom.on('click', () => this._close()), - testId('card-close-icon-button'), + dom.on('click', () => this._justAccepted.set(false)), + testId('support-nudge-close'), ), cssCenteredFlex(cssSparks()), cssCenterAlignedHeader(t('Opted In')), @@ -175,23 +151,34 @@ export class SupportGristNudge extends Disposable { t( 'Thank you! Your trust and support is greatly appreciated. ' + 'Opt out any time from the {{link}} in the user menu.', - {link: supportGristLink()}, + {link: adminPanelLink()}, ), ), cssCenteredFlex( cssPrimaryButton( t('Close'), - dom.on('click', () => this._close()), - testId('card-close-button'), + dom.on('click', () => this._justAccepted.set(false)), + 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() { await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'}); - this._currentPage.set('opted-in'); - this._markAsDismissed(); + this._markDismissed(); + this._justAccepted.set(true); } } @@ -202,10 +189,10 @@ function helpCenterLink() { ); } -function supportGristLink() { +function adminPanelLink() { return cssLink( - t('Support Grist page'), - {href: urlState().makeUrl({supportGrist: 'support'}), target: '_blank'}, + t('Admin Panel'), + {href: urlState().makeUrl({adminPanel: 'admin'}), target: '_blank'}, ); } @@ -216,26 +203,8 @@ const cssCenteredFlex = styled('div', ` `); const cssContributeButton = styled('div', ` - position: relative; - background: ${theme.controlPrimaryBg}; - color: ${theme.controlPrimaryFg}; - border-radius: 25px; - padding: 4px 12px 4px 8px; - font-style: normal; - font-weight: medium; - font-size: 13px; - line-height: 16px; - cursor: pointer; - --icon-color: ${theme.controlPrimaryFg}; - - &:hover { - background: ${theme.controlPrimaryHoverBg}; - } -`); - -const cssButtonIconAndText = styled('div', ` - display: flex; - gap: 8px; + margin-left: 8px; + margin-right: 8px; `); const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, ` @@ -254,10 +223,14 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, display: none; align-items: center; justify-content: center; + --icon-color: ${colors.light}; .${cssContributeButton.className}:hover & { display: flex; } + &:hover { + --icon-color: ${colors.lightGreen}; + } `); const cssCard = styled('div', ` @@ -325,3 +298,8 @@ const cssSparks = styled('div', ` display: inline-block; background-repeat: no-repeat; `); + +// This is just to avoid the emoji pushing the button to be taller. +const cssHeartIcon = styled('span', ` + line-height: 1; +`); diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts index 72153cd6..306ef858 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -1,29 +1,20 @@ -import {buildHomeBanners} from 'app/client/components/Banners'; import {makeT} from 'app/client/lib/localization'; import {AppModel} from 'app/client/models/AppModel'; -import {urlState} from 'app/client/models/gristUrlState'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; -import {AppHeader} from 'app/client/ui/AppHeader'; -import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; -import {pagePanels} from 'app/client/ui/PagePanels'; -import {createTopBarHome} from 'app/client/ui/TopBar'; -import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; -import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; -import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; +import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; +import {commonUrls} from 'app/common/gristUrls'; import {TelemetryPrefsWithSources} from 'app/common/InstallAPI'; -import {getGristConfig} from 'app/common/urlUtils'; -import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs'; +import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs'; const testId = makeTestId('test-support-grist-page-'); const t = makeT('SupportGristPage'); 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 _optInToTelemetry = Computed.create(this, this._model.prefs, (_use, prefs) => { @@ -38,62 +29,19 @@ export class SupportGristPage extends Disposable { constructor(private _appModel: AppModel) { super(); - this._setPageTitle(); this._model.fetchTelemetryPrefs().catch(reportError); } - public buildDom() { - const panelOpen = Observable.create(this, false); - return pagePanels({ - leftPanel: { - panelWidth: Observable.create(this, 240), - panelOpen, - hideOpener: true, - header: dom.create(AppHeader, this._appModel), - 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() { + public buildTelemetrySection() { return cssSection( - cssSectionTitle(t('Telemetry')), dom.domComputed(this._model.prefs, prefs => { if (prefs === null) { return cssSpinnerBox(loadingSpinner()); } - const {activation} = getGristConfig(); - if (!activation?.isManager) { + if (!this._appModel.isInstallAdmin()) { + // 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') { return [ 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; if (source === 'preferences') { return dom.domComputed(this._optInToTelemetry, (optedIn) => { @@ -159,9 +109,8 @@ export class SupportGristPage extends Disposable { } } - private _buildSponsorshipSection() { + public buildSponsorshipSection() { return cssSection( - cssSectionTitle(t('Sponsor Grist Labs on GitHub')), cssParagraph( t( 'Grist software is developed by Grist Labs, which offers free and paid ' + @@ -189,16 +138,9 @@ export class SupportGristPage extends Disposable { ); } - private _setPageTitle() { - this.autoDispose(subscribe(this._currentPage, (_use, page): string => { - const suffix = getPageTitleSuffix(getGristConfig()); - switch (page) { - case undefined: - case 'support': { - return document.title = `Support Grist${suffix}`; - } - } - })); + public buildSponsorshipSmallButton() { + return basicButtonLink('💛 ', t('Sponsor'), + {href: commonUrls.githubSponsorGristLabs, target: '_blank'}); } } @@ -223,44 +165,7 @@ function gristCoreLink() { ); } -const cssPageContainer = styled('div', ` - overflow: auto; - padding: 64px 80px; - - @media ${mediaSmall} { - & { - padding: 0px; - } - } -`); - -const cssPage = styled('div', ` - padding: 16px; - max-width: 600px; - width: 100%; -`); - -const cssPageTitle = styled('div', ` - height: 32px; - line-height: 32px; - margin-bottom: 24px; - color: ${theme.text}; - font-size: 24px; - font-weight: ${vars.headerControlTextWeight}; -`); - -const cssSectionTitle = styled('div', ` - height: 24px; - line-height: 24px; - margin-bottom: 24px; - color: ${theme.text}; - font-size: ${vars.xlargeFontSize}; - font-weight: ${vars.headerControlTextWeight}; -`); - -const cssSection = styled('div', ` - margin-bottom: 60px; -`); +const cssSection = styled('div', ``); const cssParagraph = styled('div', ` color: ${theme.text}; diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 9fa60f70..ee357ef0 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -28,7 +28,6 @@ export function createTopBarHome(appModel: AppModel) { return [ cssFlexSpace(), - appModel.supportGristNudge.showButton(), (appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ? [ basicButton( @@ -41,6 +40,8 @@ export function createTopBarHome(appModel: AppModel) { null ), + appModel.supportGristNudge.buildTopBarButton(), + buildLanguageMenu(appModel), isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel), dom('div', dom.create(AccountWidget, appModel)), diff --git a/app/client/ui/TutorialCard.ts b/app/client/ui/TutorialCard.ts index 4ecad7dc..538ded32 100644 --- a/app/client/ui/TutorialCard.ts +++ b/app/client/ui/TutorialCard.ts @@ -15,13 +15,11 @@ export function buildTutorialCard(owner: IDisposableOwner, options: Options) { if (!isFeatureEnabled('tutorials')) { return null; } const {app} = options; - const dismissed = app.dismissedPopup('tutorialFirstCard'); - owner.autoDispose(dismissed); function onClose() { - dismissed.set(true); + app.dismissPopup('tutorialFirstCard', true); } const visible = Computed.create(owner, (use) => - !use(dismissed) + !use(app.dismissedPopups).includes('tutorialFirstCard') && !use(isNarrowScreenObs()) ); return dom.maybe(visible, () => { diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index eb3c7088..9b1cccdb 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -243,8 +243,13 @@ export function getUserRoleText(user: UserAccessData) { 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 { - user: FullUser & {helpScoutSignature?: string}; + user: ExtendedUser; org: Organization|null; orgError?: OrgError; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index c56440d3..a7708354 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -44,8 +44,8 @@ export type ActivationPage = typeof ActivationPage.type; export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password'); export type LoginPage = typeof LoginPage.type; -export const SupportGristPage = StringUnion('support'); -export type SupportGristPage = typeof SupportGristPage.type; +export const AdminPanelPage = StringUnion('admin'); +export type AdminPanelPage = typeof AdminPanelPage.type; // Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience. export const InterfaceStyle = StringUnion('singlePage', 'full'); @@ -124,7 +124,7 @@ export interface IGristUrlState { activation?: ActivationPage; login?: LoginPage; welcome?: WelcomePage; - supportGrist?: SupportGristPage; + adminPanel?: AdminPanelPage; welcomeTour?: boolean; docTour?: boolean; manageUsers?: boolean; @@ -309,7 +309,7 @@ export function encodeUrl(gristConfig: Partial, 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}; for (const [k, v] of Object.entries(state.params?.linkParameters || {})) { @@ -415,7 +415,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat // the minimum length of a urlId prefix is longer than the maximum length // of any of the valid keys in the url. for (const key of map.keys()) { - if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) { + if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) { map.set('doc', key); map.set('slug', map.get(key)!); map.delete(key); @@ -453,9 +453,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat state.activation = ActivationPage.parse(map.get('activation')) || 'activation'; } if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); } - if (map.has('support')) { - state.supportGrist = SupportGristPage.parse(map.get('support')) || 'support'; - } + if (map.has('admin')) { state.adminPanel = AdminPanelPage.parse(map.get('admin')) || 'admin'; } if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; } if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; } 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. tagManagerId?: string; - activation?: Activation; + activation?: ActivationState; // List of enabled features. features?: IFeature[]; @@ -799,6 +797,7 @@ export const Features = StringUnion( "multiAccounts", "sendToDrive", "tutorials", + "supportGrist", ); 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 // non-zero length. const subdomainRegex = /^[-a-z0-9]+$/i; diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index f4c9b35e..9c414d87 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -527,7 +527,10 @@ export class ApiServer { )) : null; const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined; 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, orgError }); diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 86ff54ad..728adee8 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -48,6 +48,7 @@ import {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {IBilling} from 'app/server/lib/IBilling'; import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import {INotifier} from 'app/server/lib/INotifier'; +import {InstallAdmin} from 'app/server/lib/InstallAdmin'; import log from 'app/server/lib/log'; import {getLoginSystem} from 'app/server/lib/logins'; 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 {PluginManager} from 'app/server/lib/PluginManager'; 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, trustOrigin} from 'app/server/lib/requestUtils'; import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage'; @@ -133,6 +134,7 @@ export class FlexServer implements GristServer { private _servesPlugins?: boolean; private _bundledWidgets?: ICustomWidget[]; private _billing: IBilling; + private _installAdmin: InstallAdmin; private _instanceRoot: string; private _docManager: DocManager; private _docWorker: DocWorker; @@ -390,6 +392,11 @@ export class FlexServer implements GristServer { return this._notifier; } + public getInstallAdmin(): InstallAdmin { + if (!this._installAdmin) { throw new Error('no InstallAdmin available'); } + return this._installAdmin; + } + public getAccessTokens() { if (this._accessTokens) { return this._accessTokens; } 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. this._activations = new Activations(this._dbManager); await this._activations.current(); + this._installAdmin = await this.create.createInstallAdmin(this._dbManager); } public addDocWorkerMap() { @@ -864,11 +872,6 @@ export class FlexServer implements GristServer { this._telemetry = this.create.Telemetry(this._dbManager, this); this._telemetry.addEndpoints(this.app); - this._telemetry.addPages(this.app, [ - this._redirectToHostMiddleware, - this._userIdMiddleware, - this._redirectToLoginWithoutExceptionsMiddleware, - ]); await this._telemetry.start(); // Start up a monitor for memory and cpu usage. @@ -1787,15 +1790,23 @@ export class FlexServer implements GristServer { public addInstallEndpoints() { if (this._check('install')) { return; } - const isManager = expressWrap( - (req: express.Request, _res: express.Response, next: express.NextFunction) => { - if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); } + const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); - 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(); 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 activation = await this._activations.current(); activation.checkProperties(props); diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 75482aa0..a1fd8dac 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -16,6 +16,7 @@ import { Hosts } from 'app/server/lib/extractOrg'; import { ICreate } from 'app/server/lib/ICreate'; import { IDocStorageManager } from 'app/server/lib/IDocStorageManager'; import { INotifier } from 'app/server/lib/INotifier'; +import { InstallAdmin } from 'app/server/lib/InstallAdmin'; import { IPermitStore } from 'app/server/lib/Permit'; import { ISendAppPageOptions } from 'app/server/lib/sendAppPage'; import { fromCallback } from 'app/server/lib/serverUtils'; @@ -47,6 +48,7 @@ export interface GristServer { getDeploymentType(): GristDeploymentType; getHosts(): Hosts; getActivations(): Activations; + getInstallAdmin(): InstallAdmin; getHomeDBManager(): HomeDBManager; getStorageManager(): IDocStorageManager; getTelemetry(): ITelemetry; @@ -135,6 +137,7 @@ export function createDummyGristServer(): GristServer { getDeploymentType() { return 'core'; }, getHosts() { throw new Error('no hosts'); }, getActivations() { throw new Error('no activations'); }, + getInstallAdmin() { throw new Error('no install admin'); }, getHomeDBManager() { throw new Error('no db'); }, getStorageManager() { throw new Error('no storage manager'); }, getTelemetry() { return createDummyTelemetry(); }, @@ -155,7 +158,6 @@ export function createDummyGristServer(): GristServer { export function createDummyTelemetry(): ITelemetry { return { addEndpoints() { /* do nothing */ }, - addPages() { /* do nothing */ }, start() { return Promise.resolve(); }, logEvent() { /* do nothing */ }, logEventAsync() { return Promise.resolve(); }, diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 9c534447..8f3ab17c 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -6,6 +6,7 @@ import {ExternalStorage} from 'app/server/lib/ExternalStorage'; import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer'; import {IBilling} from 'app/server/lib/IBilling'; import {INotifier} from 'app/server/lib/INotifier'; +import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin'; import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox'; import {IShell} from 'app/server/lib/IShell'; import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox'; @@ -28,6 +29,9 @@ export interface ICreate { NSandbox(options: ISandboxCreationOptions): ISandbox; + // Create the logic to determine which users are authorized to manage this Grist installation. + createInstallAdmin(dbManager: HomeDBManager): Promise; + deploymentType(): GristDeploymentType; sessionSecret(): string; // Check configuration of the app early enough to show on startup. @@ -80,6 +84,7 @@ export function makeSimpleCreator(opts: { getExtraHeadHtml?: () => string, getSqliteVariant?: () => SqliteVariant, getSandboxVariants?: () => Record, + createInstallAdmin?: (dbManager: HomeDBManager) => Promise, }): ICreate { const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts; return { @@ -157,5 +162,6 @@ export function makeSimpleCreator(opts: { }, getSqliteVariant: opts.getSqliteVariant, getSandboxVariants: opts.getSandboxVariants, + createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()), }; } diff --git a/app/server/lib/InstallAdmin.ts b/app/server/lib/InstallAdmin.ts new file mode 100644 index 00000000..803656aa --- /dev/null +++ b/app/server/lib/InstallAdmin.ts @@ -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; + + // 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 { + 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 { + return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false; + } +} diff --git a/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts index 7c903ad9..edef1912 100644 --- a/app/server/lib/Telemetry.ts +++ b/app/server/lib/Telemetry.ts @@ -58,7 +58,6 @@ export interface ITelemetry { ): Promise; shouldLogEvent(name: TelemetryEvent): boolean; addEndpoints(app: express.Express): void; - addPages(app: express.Express, middleware: express.RequestHandler[]): void; getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined; fetchTelemetryPrefs(): Promise; } @@ -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 { const prefs = this._telemetryPrefs; if (!prefs) { diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index e25cef58..e37e8a06 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError'; import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import * as gutil from 'app/common/gutil'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; -import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; +import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithGrist} from 'app/server/lib/GristServer'; import log from 'app/server/lib/log'; @@ -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; -} diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index c2a92680..98aff5fb 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -82,7 +82,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi ((server?.getBundledWidgets().length || 0) > 0), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO), 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), supportedLngs: readLoadedLngs(req?.i18n), namespaces: readLoadedNamespaces(req?.i18n), @@ -306,11 +306,3 @@ function getDocFromConfig(config: GristLoadConfig): Document | 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), - }; -} diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 3eac5567..a3b83401 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -31,7 +31,8 @@ if (!process.env.GRIST_SINGLE_ORG) { 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); import {updateDb} from 'app/server/lib/dbUtils'; import {main as mergedServerMain, parseServerTypes} from 'app/server/mergedServerMain'; diff --git a/test/nbrowser/AdminPanel.ts b/test/nbrowser/AdminPanel.ts new file mode 100644 index 00000000..fd92ff68 --- /dev/null +++ b/test/nbrowser/AdminPanel.ts @@ -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) { + 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); diff --git a/test/nbrowser/SupportGrist.ts b/test/nbrowser/SupportGrist.ts index 97eeb27a..3f62e1b4 100644 --- a/test/nbrowser/SupportGrist.ts +++ b/test/nbrowser/SupportGrist.ts @@ -5,6 +5,8 @@ import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import * as testUtils from 'test/server/testUtils'; +const sponsorshipUrl = 'https://github.com/sponsors/gristlabs'; + describe('SupportGrist', function() { this.timeout(30000); setupTestSuite(); @@ -14,6 +16,10 @@ describe('SupportGrist', function() { afterEach(() => gu.checkForErrors()); + after(async function() { + await server.restart(); + }); + describe('in grist-core', function() { before(async function() { oldEnv = new testUtils.EnvironmentSnapshot(); @@ -24,133 +30,66 @@ describe('SupportGrist', function() { after(async function() { oldEnv.restore(); - await server.restart(); }); describe('when user is not a manager', function() { before(async function() { - oldEnv = new testUtils.EnvironmentSnapshot(); - await server.restart(); session = await gu.session().user('user2').personalSite.login(); await session.loadDocMenu('/'); }); - after(async function() { - oldEnv.restore(); - }); - it('does not show a nudge on the doc menu', async function() { - await assertNudgeButtonShown(false); await assertNudgeCardShown(false); + await assertSupportButtonShown(true, {isSponsorLink: true}); }); it('shows a link to the Support Grist page in the user menu', async function() { await gu.openAccountMenu(); - await driver.find('.test-usermenu-support-grist').click(); - assert.isTrue(await driver.findContentWait( - '.test-support-grist-page-sponsorship-section', - /Sponsor Grist Labs on GitHub/, - 4000 - ).isDisplayed()); - }); - - it('shows a message that telemetry is managed by the site administrator', async function() { - assert.isTrue(await driver.findContentWait( - '.test-support-grist-page-telemetry-section', - /This instance is opted out of telemetry\. Only the site administrator has permission to change this\./, - 4000 - ).isDisplayed()); - - process.env.GRIST_TELEMETRY_LEVEL = 'limited'; - await server.restart(); - await driver.navigate().refresh(); - assert.isTrue(await driver.findContentWait( - '.test-support-grist-page-telemetry-section', - /This instance is opted in to telemetry\. Only the site administrator has permission to change this\./, - 4000 - ).isDisplayed()); + await assertMenuHasAdminPanel(false); + await assertMenuHasSupportGrist(true); }); }); describe('when user is a manager', function() { before(async function() { - oldEnv = new testUtils.EnvironmentSnapshot(); - await server.restart(); session = await gu.session().personalSite.login(); await session.loadDocMenu('/'); }); - after(async function() { - oldEnv.restore(); - }); - it('shows a nudge on the doc menu', async function() { // Check that the nudge is expanded by default. - await assertNudgeButtonShown(false); + await assertSupportButtonShown(false); await assertNudgeCardShown(true); // Reload the doc menu and check that it's still expanded. await session.loadDocMenu('/'); - await assertNudgeButtonShown(false); + await assertSupportButtonShown(false); await assertNudgeCardShown(true); // Close the nudge and check that it's now collapsed. - await driver.find('.test-support-grist-nudge-card-close').click(); - await assertNudgeButtonShown(true); + await driver.find('.test-support-nudge-close').click(); + await assertSupportButtonShown(true, {isSponsorLink: false}); await assertNudgeCardShown(false); // Reload again, and check that it's still collapsed. await session.loadDocMenu('/'); - await assertNudgeButtonShown(true); + await assertSupportButtonShown(true, {isSponsorLink: false}); await assertNudgeCardShown(false); // Dismiss the contribute button and check that it's now gone, even after reloading. - await driver.find('.test-support-grist-nudge-contribute-button').mouseMove(); - await driver.find('.test-support-grist-nudge-contribute-button-close').click(); - await assertNudgeButtonShown(false); + await driver.find('.test-support-grist-button').mouseMove(); + await driver.find('.test-support-grist-button-dismiss').click(); + await assertSupportButtonShown(false); await assertNudgeCardShown(false); await session.loadDocMenu('/'); - await assertNudgeButtonShown(false); + await assertSupportButtonShown(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 driver.find('.test-usermenu-support-grist').click(); - await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000); - assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent()); - }); - - it('supports opting in to telemetry from the page', async function() { - await assertTelemetryLevel('off'); - await driver.findContentWait( - '.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click(); - await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000); - assert.equal( - await driver.find('.test-support-grist-page-telemetry-section-message').getText(), - 'You have opted in to telemetry. Thank you! 🙏' - ); - - // Reload the page and check that the Grist config indicates telemetry is set to "limited". - await driver.navigate().refresh(); - await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000); - assert.equal( - await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(), - 'You have opted in to telemetry. Thank you! 🙏' - ); - await assertTelemetryLevel('limited'); - }); - - it('supports opting out of telemetry from the page', async function() { - await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click(); - await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000); - assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent()); - - // Reload the page and check that the Grist config indicates telemetry is set to "off". - await driver.navigate().refresh(); - await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000); - assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent()); - await assertTelemetryLevel('off'); + await assertMenuHasAdminPanel(true); + await assertMenuHasSupportGrist(true); }); it('supports opting in to telemetry from the nudge', async function() { @@ -160,14 +99,14 @@ describe('SupportGrist', function() { await session.loadDocMenu('/'); // Opt in to telemetry and reload the page. - await driver.find('.test-support-grist-nudge-card-opt-in').click(); - await driver.findWait('.test-support-grist-nudge-card-close-button', 1000).click(); - await assertNudgeButtonShown(false); + await driver.find('.test-support-nudge-opt-in').click(); + await driver.findWait('.test-support-nudge-close-button', 1000).click(); + await assertSupportButtonShown(false); await assertNudgeCardShown(false); await session.loadDocMenu('/'); // Check that the nudge is no longer shown and telemetry is set to "limited". - await assertNudgeButtonShown(false); + await assertSupportButtonShown(false); await assertNudgeCardShown(false); await assertTelemetryLevel('limited'); }); @@ -179,52 +118,55 @@ describe('SupportGrist', function() { // Reload the doc menu and check that the nudge still isn't shown. await session.loadDocMenu('/'); - await assertNudgeButtonShown(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. 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( '.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000).click(); await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000); // Reload the doc menu and check that the nudge is now shown. await gu.loadDocMenu('/'); - await assertNudgeButtonShown(false); + await assertSupportButtonShown(false); await assertNudgeCardShown(true); }); - it('shows telemetry opt-in status even when set via environment variable', async function() { - // Set the telemetry level to "limited" via environment variable and restart the server. - process.env.GRIST_TELEMETRY_LEVEL = 'limited'; - await server.restart(); + it('shows sponsorship link when no telemetry nudge, and allows dismissing it', async function() { + // Reset all dismissed popups, including the telemetry nudge. + await driver.executeScript('resetDismissedPopups();'); + 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 gu.loadDocMenu('/'); - await gu.openAccountMenu(); - await driver.find('.test-usermenu-support-grist').click(); - assert.equal( - await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(), - 'You have opted in to telemetry. Thank you! 🙏' - ); - assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button', - /Opt out of Telemetry/).isPresent()); - - // Now set the telemetry level to "off" and restart the server. - process.env.GRIST_TELEMETRY_LEVEL = 'off'; - await server.restart(); - - // Check that the Support Grist page reports telemetry is disabled. - await gu.loadDocMenu('/'); - await gu.openAccountMenu(); - await driver.find('.test-usermenu-support-grist').click(); - assert.equal( - await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(), - 'You have opted out of telemetry.' - ); - assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button', - /Opt in to Telemetry/).isPresent()); + await session.loadDocMenu('/'); + await assertTelemetryLevel('limited'); + + // We still show the "Support Grist" button linking to sponsorship page. + await assertSupportButtonShown(true, {isSponsorLink: true}); + await assertNudgeCardShown(false); + + // we can dismiss it. + await driver.find('.test-support-grist-button').mouseMove(); + await driver.find('.test-support-grist-button-dismiss').click(); + await assertSupportButtonShown(false); + + // And this will get remembered. + await session.loadDocMenu('/'); + await assertNudgeCardShown(false); + await assertSupportButtonShown(false); }); }); }); @@ -241,17 +183,25 @@ describe('SupportGrist', function() { after(async function() { oldEnv.restore(); - await server.restart(); }); it('does not show a nudge on the doc menu', async function() { - await assertNudgeButtonShown(false); + await assertSupportButtonShown(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(); - 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() { oldEnv.restore(); - await server.restart(); }); it('does not show a nudge on the doc menu', async function() { - await assertNudgeButtonShown(false); + await assertSupportButtonShown(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(); - 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; +async function assertSupportButtonShown(isShown: true, opts: {isSponsorLink: boolean}): Promise; +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) { - assert.isTrue( - await driver.find('.test-support-grist-nudge-contribute-button').isDisplayed() - ); - } else { - assert.isFalse(await driver.find('.test-support-grist-nudge-contribute-button').isPresent()); + assert.equal(await button.getAttribute('href'), opts?.isSponsorLink ? sponsorshipUrl : null); } } 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) { - assert.isTrue( - await driver.find('.test-support-grist-nudge-card').isDisplayed() - ); - } else { - assert.isFalse(await driver.find('.test-support-grist-nudge-card').isPresent()); + assert.equal(await elem.getAttribute('href'), sponsorshipUrl); } } diff --git a/test/server/lib/Telemetry.ts b/test/server/lib/Telemetry.ts index e0bd199f..ab352919 100644 --- a/test/server/lib/Telemetry.ts +++ b/test/server/lib/Telemetry.ts @@ -393,17 +393,9 @@ describe('Telemetry', function() { 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); - assert.equal(resp.status, 200); - assert.deepEqual(resp.data, { - telemetry: { - telemetryLevel: { - value: 'off', - source: 'preferences', - }, - }, - }); + assert.equal(resp.status, 403); }); it('GET /install/prefs returns 200 for the default user', async function() {