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