From 3139328e82b8b976470156be69c23465693aa1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 15 Jul 2024 21:50:48 -0400 Subject: [PATCH] wip --- app/client/models/ToggleEnterpriseModel.ts | 23 ++++++ app/client/ui/AdminPanel.ts | 10 ++- app/client/ui/AdminTogglesCss.ts | 45 +++++++++++ app/client/ui/SupportGristPage.ts | 59 ++++----------- app/client/ui/ToggleEnterprisePage.ts | 88 ++++++++++++++++++++++ app/common/ConfigAPI.ts | 28 +++++++ app/common/gristUrls.ts | 1 + app/server/lib/ConfigBackendAPI.ts | 35 +++++++++ app/server/lib/FlexServer.ts | 10 +++ app/server/mergedServerMain.ts | 1 + 10 files changed, 254 insertions(+), 46 deletions(-) create mode 100644 app/client/models/ToggleEnterpriseModel.ts create mode 100644 app/client/ui/AdminTogglesCss.ts create mode 100644 app/client/ui/ToggleEnterprisePage.ts create mode 100644 app/common/ConfigAPI.ts create mode 100644 app/server/lib/ConfigBackendAPI.ts diff --git a/app/client/models/ToggleEnterpriseModel.ts b/app/client/models/ToggleEnterpriseModel.ts new file mode 100644 index 00000000000..62b91822e00 --- /dev/null +++ b/app/client/models/ToggleEnterpriseModel.ts @@ -0,0 +1,23 @@ +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {bundleChanges, Disposable, Observable} from "grainjs"; +import {ConfigAPI, ConfigAPIImpl} from 'app/common/ConfigAPI'; + +export class ToggleEnterpriseModel extends Disposable { + public readonly edition: Observable = Observable.create(this, null); + private readonly _configAPI: ConfigAPI = new ConfigAPIImpl(getHomeUrl()); + constructor (_appModel: AppModel) { + super(); + } + + public async fetchEnterpriseToggle(): Promise { + const edition = await this._configAPI.getValue('edition'); + bundleChanges(() => { + this.edition.set(edition); + }); + } + + public async updateEnterpriseToggle(edition: string): Promise { + await this._configAPI.setValue({edition}); + await this.fetchEnterpriseToggle(); + } +} diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index 91f6802b498..6c9da5c94fe 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -9,6 +9,7 @@ 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 {ToggleEnterprisePage} from 'app/client/ui/ToggleEnterprisePage'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; import {basicButton} from 'app/client/ui2018/buttons'; @@ -25,7 +26,6 @@ import {Computed, Disposable, dom, IDisposable, IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs'; import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss'; - const t = makeT('AdminPanel'); // Translated "Admin Panel" name, made available to other modules. @@ -35,6 +35,7 @@ export function getAdminPanelName() { export class AdminPanel extends Disposable { private _supportGrist = SupportGristPage.create(this, this._appModel); + private _toggleEnterprise = ToggleEnterprisePage.create(this, this._appModel); private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); private _checks: AdminChecks; @@ -161,6 +162,13 @@ Please log in as an administrator.`)), description: t('Current version of Grist'), value: cssValueLabel(`Version ${version.version}`), }), + dom.create(AdminSectionItem, { + id: 'enterprise', + name: t('Enterprise'), + description: t('Enable Grist Enterprise features'), + value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()), + expandedContent: this._toggleEnterprise.buildEnterpriseSection(), + }), this._buildUpdates(owner), ]), dom.create(AdminSection, t('Self Checks'), [ diff --git a/app/client/ui/AdminTogglesCss.ts b/app/client/ui/AdminTogglesCss.ts new file mode 100644 index 00000000000..5419d4289fb --- /dev/null +++ b/app/client/ui/AdminTogglesCss.ts @@ -0,0 +1,45 @@ +import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {theme} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const cssSection = styled('div', ``); + +export const cssParagraph = styled('div', ` + color: ${theme.text}; + font-size: 14px; + line-height: 20px; + margin-bottom: 12px; +`); + +export const cssOptInOutMessage = styled(cssParagraph, ` + line-height: 40px; + font-weight: 600; + margin-top: 24px; + margin-bottom: 0px; +`); + +export const cssOptInButton = styled(bigPrimaryButton, ` + margin-top: 24px; +`); + +export const cssOptOutButton = styled(bigBasicButton, ` + margin-top: 24px; +`); + +export const cssSponsorButton = styled(bigBasicButtonLink, ` + margin-top: 24px; +`); + +export const cssButtonIconAndText = styled('div', ` + display: flex; + align-items: center; +`); + +export const cssButtonText = styled('span', ` + margin-left: 8px; +`); + +export const cssSpinnerBox = styled('div', ` + margin-top: 24px; + text-align: center; +`); diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts index 306ef858a34..7dec93ea200 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -1,14 +1,25 @@ import {makeT} from 'app/client/lib/localization'; import {AppModel} from 'app/client/models/AppModel'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; -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} from 'app/common/gristUrls'; import {TelemetryPrefsWithSources} from 'app/common/InstallAPI'; -import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs'; +import {Computed, Disposable, dom, makeTestId} from 'grainjs'; +import {basicButtonLink} from 'app/client/ui2018/buttons'; +import { + cssButtonIconAndText, + cssButtonText, + cssOptInButton, + cssOptInOutMessage, + cssOptOutButton, + cssParagraph, + cssSection, + cssSpinnerBox, + cssSponsorButton, +} from 'app/client/ui/AdminTogglesCss'; + const testId = makeTestId('test-support-grist-page-'); @@ -164,45 +175,3 @@ function gristCoreLink() { {href: commonUrls.githubGristCore, target: '_blank'}, ); } - -const cssSection = styled('div', ``); - -const cssParagraph = styled('div', ` - color: ${theme.text}; - font-size: 14px; - line-height: 20px; - margin-bottom: 12px; -`); - -const cssOptInOutMessage = styled(cssParagraph, ` - line-height: 40px; - font-weight: 600; - margin-top: 24px; - margin-bottom: 0px; -`); - -const cssOptInButton = styled(bigPrimaryButton, ` - margin-top: 24px; -`); - -const cssOptOutButton = styled(bigBasicButton, ` - margin-top: 24px; -`); - -const cssSponsorButton = styled(bigBasicButtonLink, ` - margin-top: 24px; -`); - -const cssButtonIconAndText = styled('div', ` - display: flex; - align-items: center; -`); - -const cssButtonText = styled('span', ` - margin-left: 8px; -`); - -const cssSpinnerBox = styled('div', ` - margin-top: 24px; - text-align: center; -`); diff --git a/app/client/ui/ToggleEnterprisePage.ts b/app/client/ui/ToggleEnterprisePage.ts new file mode 100644 index 00000000000..9773699f5fa --- /dev/null +++ b/app/client/ui/ToggleEnterprisePage.ts @@ -0,0 +1,88 @@ +import {makeT} from 'app/client/lib/localization'; +import {Computed, Disposable, dom, makeTestId} from "grainjs"; +import {AppModel} from "app/client/models/AppModel"; +import {loadingSpinner} from 'app/client/ui2018/loaders'; +import {commonUrls} from "app/common/gristUrls"; +import {cssLink} from 'app/client/ui2018/links'; +import {ToggleEnterpriseModel} from 'app/client/models/ToggleEnterpriseModel'; +import { + cssOptInButton, + cssOptInOutMessage, + cssOptOutButton, + cssParagraph, + cssSection, + cssSpinnerBox +} from 'app/client/ui/AdminTogglesCss'; + + +const t = makeT('ToggleEnterprsiePage'); +const testId = makeTestId('test-toggle-enterprise-page-'); + +export class ToggleEnterprisePage extends Disposable { + private readonly _model: ToggleEnterpriseModel = new ToggleEnterpriseModel(this._appModel); + private readonly _isEnterprise = Computed.create(this, this._model.edition, (_use, edition) => { + return edition === 'enterprise'; + }) + .onWrite(async (enabled) => { + await this._model.updateEnterpriseToggle(enabled ? 'enterprise' : 'core'); + }); + constructor(private _appModel: AppModel) { + super(); + this._model.fetchEnterpriseToggle().catch(reportError); + } + + public getEnterpriseToggleObservable() { + return this._isEnterprise; + } + + public buildEnterpriseSection() { + return cssSection( + dom.domComputed(this._isEnterprise, isEnterprise => { + if (isEnterprise === null) { + return cssSpinnerBox(loadingSpinner()); + } + return [ + cssParagraph( + t('Activation keys are used to run Grist Enterprise after a trial period ' + + 'of 30 days has expired. Get an activation key by signing up for Grist ' + + 'Enterprise. You do not need an activation key to run Grist Core.') + ), + cssParagraph(t('Learn more in our {{link}}.', { + link: enterpriseHelpCenterLink(), + })), + this._buildEnterpriseSectionButtons(), + ]; + }), + testId('enterprise-opt-in-section'), + ); + } + + public _buildEnterpriseSectionButtons() { + return dom.domComputed(this._isEnterprise, (enterpriseEnabled) => { + if (enterpriseEnabled) { + return [ + cssOptInOutMessage( + t('Grist Enterprise Edition is enabled.'), + testId('enterprise-opt-out-message'), + ), + cssOptOutButton(t('Disable Grist Enterprise Edition'), + dom.on('click', () => this._isEnterprise.set(false)), + ), + ]; + } else { + return [ + cssOptInButton(t('Enable Grist Enterprise Edition'), + dom.on('click', () => this._isEnterprise.set(true)), + ), + ]; + } + }); + } +} + +function enterpriseHelpCenterLink() { + return cssLink( + t('Help Center'), + {href: commonUrls.helpEnterpriseOptIn, target: '_blank'}, + ); +} diff --git a/app/common/ConfigAPI.ts b/app/common/ConfigAPI.ts new file mode 100644 index 00000000000..23c3fab9f58 --- /dev/null +++ b/app/common/ConfigAPI.ts @@ -0,0 +1,28 @@ +import { BaseAPI, IOptions } from "app/common/BaseAPI"; +import {addCurrentOrgToPath} from 'app/common/urlUtils'; + +export interface ConfigAPI { + getValue(key: string): Promise; + setValue(value: any): Promise; +} + +export class ConfigAPIImpl extends BaseAPI implements ConfigAPI { + constructor(private _homeUrl: string, options: IOptions = {}) { + super(options); + } + + public async getValue(key: string): Promise { + return (await this.requestJson(`${this._url}/api/config/${key}`, {method: 'GET'})).value; + } + + public async setValue(value: any): Promise { + await this.request(`${this._url}/api/config`, { + method: 'PATCH', + body: JSON.stringify(value), + }); + } + + private get _url(): string { + return addCurrentOrgToPath(this._homeUrl); + } +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 1f66291ecf5..448cd86ecf0 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -84,6 +84,7 @@ export const commonUrls = { helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes", helpCustomWidgets: "https://support.getgrist.com/widget-custom", helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited", + helpEnterpriseOptIn: "https://support.getgrist.com/self-managed/#how-do-i-activate-grist-enterprise", helpCalendarWidget: "https://support.getgrist.com/widget-calendar", helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys", helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown", diff --git a/app/server/lib/ConfigBackendAPI.ts b/app/server/lib/ConfigBackendAPI.ts new file mode 100644 index 00000000000..4e63bfa6312 --- /dev/null +++ b/app/server/lib/ConfigBackendAPI.ts @@ -0,0 +1,35 @@ +import * as express from 'express'; +import {expressWrap} from 'app/server/lib/expressWrap'; + +import {IGristCoreConfig, loadGristCoreConfigFile} from 'app/server/lib/configCore'; +import log from "app/server/lib/log"; + +export class ConfigBackendAPI { + private _config: IGristCoreConfig; + public async init () { + this._config = await loadGristCoreConfigFile(); + } + + public addEndpoints(app: express.Express, requireMiddleware: express.RequestHandler) { + app.get('/api/config/:key', requireMiddleware, expressWrap(async (req, resp) => { + log.debug('config: requesting configuration', req.params); + + // Only one key is valid for now + if (req.params.key === 'edition') { + resp.send({value: this._config.edition.get()}); + } else { + resp.status(404).send('Configuration key not found.'); + } + })); + + app.patch('/api/config', requireMiddleware, expressWrap(async (req, resp) => { + log.debug('config: received new configuration item', req.body); + if(req.body.edition !== undefined) { + await this._config.edition.set(req.body.edition); + resp.sendStatus(200); + } else { + resp.status(400).send('Invalid configuration key'); + } + })); + } +} diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 8004e4e2cf5..beed1c7c35b 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -88,6 +88,7 @@ import fetch from 'node-fetch'; import * as path from 'path'; import * as serveStatic from "serve-static"; import {IGristCoreConfig} from "./configCore"; +import {ConfigBackendAPI} from "app/server/lib/ConfigBackendAPI"; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -1943,6 +1944,15 @@ export class FlexServer implements GristServer { })); } + public async addConfigEndpoints() { + // Need to be an admin to change the Grist config + const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); + + const configBackendAPI = new ConfigBackendAPI(); + await configBackendAPI.init(); + configBackendAPI.addEndpoints(this.app, requireInstallAdmin); + } + // Get the HTML template sent for document pages. public async getDocTemplate(): Promise { const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'), diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index f4e4a4a631d..6b74ab07b1b 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -165,6 +165,7 @@ export async function main(port: number, serverTypes: ServerType[], server.addLogEndpoint(); server.addGoogleAuthEndpoint(); server.addInstallEndpoints(); + await server.addConfigEndpoints(); } if (includeDocs) {