From eded090e9e55c25dcc08f39f7c88488984ea0b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Thu, 18 Jul 2024 17:38:19 -0400 Subject: [PATCH 01/13] Dockerfile: silence a warning Docker doesn't like mixed case in keywords. --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index cdd584f7ea..52a798185c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,13 @@ ## docker buildx build -t ... --build-context=ext= . ## The code in will then be built along with the rest of Grist. ################################################################################ -FROM scratch as ext +FROM scratch AS ext ################################################################################ ## Javascript build stage ################################################################################ -FROM node:18-buster as builder +FROM node:18-buster AS builder # Install all node dependencies. WORKDIR /grist @@ -46,7 +46,7 @@ RUN \ ################################################################################ # Fetch python3.11 and python2.7 -FROM python:3.11-slim-buster as collector +FROM python:3.11-slim-buster AS collector # Install all python dependencies. ADD sandbox/requirements.txt requirements.txt @@ -66,7 +66,7 @@ RUN \ # Fetch gvisor-based sandbox. Note, to enable it to run within default # unprivileged docker, layers of protection that require privilege have # been stripped away, see https://github.com/google/gvisor/issues/4371 -FROM docker.io/gristlabs/gvisor-unprivileged:buster as sandbox +FROM docker.io/gristlabs/gvisor-unprivileged:buster AS sandbox ################################################################################ ## Run-time stage From 566d873e2eb2aae70fe96afffe0c8842fa1cc895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 09:43:29 -0400 Subject: [PATCH 02/13] config: end the file with a newline Small cosmetic change, POSIX requires final newlines in text files. https://stackoverflow.com/questions/729692/why-should-text-files-end-with-a-newline --- app/server/lib/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts index 490cddcf0b..f668154649 100644 --- a/app/server/lib/config.ts +++ b/app/server/lib/config.ts @@ -98,7 +98,7 @@ export class FileConfig { } public async persistToDisk() { - await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2)); + await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2) + "\n"); } } From 6562988518223cf8bf940d57308070c4a9215d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 09:45:12 -0400 Subject: [PATCH 03/13] configCore: default to enterprise edition if TEST_ENABLE_ACTIVATION is truthy This will ensure that the grist-ee image will have a consistent config setting when created from the default value. --- app/server/lib/configCore.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts index 58ddf627c7..99c3770bcd 100644 --- a/app/server/lib/configCore.ts +++ b/app/server/lib/configCore.ts @@ -4,7 +4,8 @@ import { fileConfigAccessorFactory, IWritableConfigValue } from "./config"; -import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats"; +import {convertToCoreFileContents, IGristCoreConfigFileLatest} from "./configCoreFileFormats"; +import {isAffirmative} from 'app/common/gutil'; export type Edition = "core" | "enterprise"; @@ -23,6 +24,9 @@ export function loadGristCoreConfigFile(configPath?: string): IGristCoreConfig { export function loadGristCoreConfig(fileConfig?: FileConfig): IGristCoreConfig { const fileConfigValue = fileConfigAccessorFactory(fileConfig); return { - edition: createConfigValue("core", fileConfigValue("edition")) + edition: createConfigValue( + isAffirmative(process.env.TEST_ENABLE_ACTIVATION) ? "enterprise" : "core", + fileConfigValue("edition") + ) }; } From 089b37f4626cd1b73c18510d4a885dafab5ad557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 09:47:52 -0400 Subject: [PATCH 04/13] config: new API endpoint This adds PATCH and GET endpoints to handle `config.json`. --- app/server/lib/ConfigBackendAPI.ts | 35 ++++++++++++++++++++++++++++++ app/server/lib/FlexServer.ts | 11 +++++++++- app/server/mergedServerMain.ts | 1 + 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 app/server/lib/ConfigBackendAPI.ts diff --git a/app/server/lib/ConfigBackendAPI.ts b/app/server/lib/ConfigBackendAPI.ts new file mode 100644 index 0000000000..afb475faf6 --- /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 {getGlobalConfig} from 'app/server/lib/globalConfig'; + +import log from "app/server/lib/log"; + +export class ConfigBackendAPI { + public addEndpoints(app: express.Express, requireInstallAdmin: express.RequestHandler) { + app.get('/api/config/:key', requireInstallAdmin, expressWrap((req, resp) => { + log.debug('config: requesting configuration', req.params); + + // Only one key is valid for now + if (req.params.key === 'edition') { + resp.send({value: getGlobalConfig().edition.get()}); + } else { + resp.status(404).send({ error: 'Configuration key not found.' }); + } + })); + + app.patch('/api/config', requireInstallAdmin, expressWrap(async (req, resp) => { + const config = req.body.config; + log.debug('config: received new configuration item', config); + + // Only one key is valid for now + if(config.edition !== undefined) { + await getGlobalConfig().edition.set(config.edition); + + resp.send({ msg: 'ok' }); + } else { + resp.status(400).send({ error: 'Invalid configuration key' }); + } + })); + } +} diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 06e9b85506..0b5f9ea813 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -87,7 +87,8 @@ import {AddressInfo} from 'net'; 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"; +import {IGristCoreConfig} from "app/server/lib/configCore"; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -1948,6 +1949,14 @@ export class FlexServer implements GristServer { })); } + public addConfigEndpoints() { + // Need to be an admin to change the Grist config + const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); + + const configBackendAPI = new ConfigBackendAPI(); + 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 466eddafca..1bef819dfc 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(); + server.addConfigEndpoints(); } if (includeDocs) { From 6bc4e677f924a74521fa0f9443c9cd55b5706958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Sun, 28 Jul 2024 17:59:48 -0400 Subject: [PATCH 05/13] FlexServer: remove config from restart endpoint The config endpoint now handles changing config values, so we only need to handle restarts here. --- app/server/lib/FlexServer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 0b5f9ea813..15429b2de3 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1877,14 +1877,13 @@ export class FlexServer implements GristServer { const probes = new BootProbes(this.app, this, '/api', adminMiddleware); probes.addEndpoints(); - this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (req, resp) => { - const newConfig = req.body.newConfig; + this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (_, resp) => { resp.on('finish', () => { // If we have IPC with parent process (e.g. when running under // Docker) tell the parent that we have a new environment so it // can restart us. if (process.send) { - process.send({ action: 'restart', newConfig }); + process.send({ action: 'restart' }); } }); // On the topic of http response codes, thus spake MDN: From 57e50ca3a0c3592e1e7e3a5cb442429291e38fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 10:00:07 -0400 Subject: [PATCH 06/13] supervisor: remove config stub The configuration is now handled by the config API, so we no longer need the stub function here. --- sandbox/supervisor.mjs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/sandbox/supervisor.mjs b/sandbox/supervisor.mjs index 2508cb1705..832d42dfa5 100644 --- a/sandbox/supervisor.mjs +++ b/sandbox/supervisor.mjs @@ -3,14 +3,13 @@ import {spawn} from 'child_process'; let grist; function startGrist(newConfig={}) { - saveNewConfig(newConfig); // H/T https://stackoverflow.com/a/36995148/11352427 grist = spawn('./sandbox/run.sh', { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] }); grist.on('message', function(data) { if (data.action === 'restart') { - console.log('Restarting Grist with new environment'); + console.log('Restarting Grist with new configuration'); // Note that we only set this event handler here, after we have // a new environment to reload with. Small chance of a race here @@ -26,10 +25,4 @@ function startGrist(newConfig={}) { return grist; } -// Stub function -function saveNewConfig(newConfig) { - // TODO: something here to actually persist the new config before - // restarting Grist. -} - startGrist(); From 48c872fd18232478076512e81526cf9dbf5667ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 29 Jul 2024 16:28:55 -0400 Subject: [PATCH 07/13] restart: gracefully handle restart failure In case Grist isn't running with the supervisor (e.g. it's running under nodemon instead via `yarn start`), surface the problem to the frontend. --- app/server/lib/FlexServer.ts | 13 +++++++++---- sandbox/supervisor.mjs | 3 ++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 15429b2de3..240d819542 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1886,10 +1886,15 @@ export class FlexServer implements GristServer { process.send({ action: 'restart' }); } }); - // On the topic of http response codes, thus spake MDN: - // "409: This response is sent when a request conflicts with the current state of the server." - const status = process.send ? 200 : 409; - return resp.status(status).send(); + + if(!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) { + // On the topic of http response codes, thus spake MDN: + // "409: This response is sent when a request conflicts with the current state of the server." + return resp.status(409).send({ + error: "Cannot automatically restart the Grist server to enact changes. Please restart server manually." + }); + } + return resp.status(200).send({ msg: 'ok' }); })); // Restrict this endpoint to install admins diff --git a/sandbox/supervisor.mjs b/sandbox/supervisor.mjs index 832d42dfa5..f7178f3c63 100644 --- a/sandbox/supervisor.mjs +++ b/sandbox/supervisor.mjs @@ -5,7 +5,8 @@ let grist; function startGrist(newConfig={}) { // H/T https://stackoverflow.com/a/36995148/11352427 grist = spawn('./sandbox/run.sh', { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: {...process.env, GRIST_RUNNING_UNDER_SUPERVISOR: true} }); grist.on('message', function(data) { if (data.action === 'restart') { From 35b2516483ea03879ed235837e0e15e894275dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 10:01:57 -0400 Subject: [PATCH 08/13] ConfigAPI: new class to handle frontend requests to config backend This new API is somewhat patterned after the InstallAPI, but simpler whenever possible. --- app/common/ConfigAPI.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 app/common/ConfigAPI.ts diff --git a/app/common/ConfigAPI.ts b/app/common/ConfigAPI.ts new file mode 100644 index 0000000000..a8a12e8307 --- /dev/null +++ b/app/common/ConfigAPI.ts @@ -0,0 +1,31 @@ +import {BaseAPI, IOptions} from "app/common/BaseAPI"; +import {addCurrentOrgToPath} from 'app/common/urlUtils'; + +/** + * An API for accessing the internal Grist configuration, stored in + * config.json. + */ +export class ConfigAPI extends BaseAPI { + 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, restart=false): Promise { + await this.request(`${this._url}/api/config`, { + method: 'PATCH', + body: JSON.stringify({config: value, restart}), + }); + } + + public async restartServer(): Promise { + await this.request(`${this._url}/api/admin/restart`, {method: 'POST'}); + } + + private get _url(): string { + return addCurrentOrgToPath(this._homeUrl); + } +} From d8a4d321c6c3a643f89234051d8c1cc1414086c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 22 Jul 2024 10:17:32 -0400 Subject: [PATCH 09/13] ToggleEnterpriseModel: new GrainJS model to handle changes to config API Patterned after TelemetryModel.ts --- app/client/models/ToggleEnterpriseModel.ts | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 app/client/models/ToggleEnterpriseModel.ts diff --git a/app/client/models/ToggleEnterpriseModel.ts b/app/client/models/ToggleEnterpriseModel.ts new file mode 100644 index 0000000000..3c23d24899 --- /dev/null +++ b/app/client/models/ToggleEnterpriseModel.ts @@ -0,0 +1,44 @@ +import {getHomeUrl} from 'app/client/models/AppModel'; +import {Disposable, Observable} from "grainjs"; +import {ConfigAPI} from 'app/common/ConfigAPI'; +import {delay} from 'app/common/delay'; + +export class ToggleEnterpriseModel extends Disposable { + public readonly edition: Observable = Observable.create(this, null); + private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl()); + + public async fetchEnterpriseToggle(): Promise { + const edition = await this._configAPI.getValue('edition'); + this.edition.set(edition); + } + + public async updateEnterpriseToggle(edition: string): Promise { + // We may be restarting the server, so these requests may well + // fail if done in quick succession. + await retryOnNetworkError(() => this._configAPI.setValue({edition})); + this.edition.set(edition); + await retryOnNetworkError(() => this._configAPI.restartServer()); + } +} + +// Copied from DocPageModel.ts +const reconnectIntervals = [1000, 1000, 2000, 5000, 10000]; +async function retryOnNetworkError(func: () => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await func(); + } catch (err) { + // fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too. + if (err.name !== "TypeError" && err.name !== "NetworkError") { + throw err; + } + // We really can't reach the server. Make it known. + if (attempt >= reconnectIntervals.length) { + throw err; + } + const reconnectTimeout = reconnectIntervals[attempt]; + console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err); + await delay(reconnectTimeout); + } + } +} From f9a0519e18c72cf66d13305e39556abd284c2468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Sun, 28 Jul 2024 19:08:33 -0400 Subject: [PATCH 10/13] AdminToggleCss: factor out CSS from SupportGristPage We will create a new enterprise toggle, so we will need to share the same CSS. --- app/client/ui/AdminTogglesCss.ts | 45 ++++++++++++++++++++++++ app/client/ui/SupportGristPage.ts | 58 +++++++------------------------ 2 files changed, 58 insertions(+), 45 deletions(-) create mode 100644 app/client/ui/AdminTogglesCss.ts diff --git a/app/client/ui/AdminTogglesCss.ts b/app/client/ui/AdminTogglesCss.ts new file mode 100644 index 0000000000..5419d4289f --- /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 306ef858a3..13e0ed8fec 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -1,14 +1,24 @@ 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 { + cssButtonIconAndText, + cssButtonText, + cssOptInButton, + cssOptInOutMessage, + cssOptOutButton, + cssParagraph, + cssSection, + cssSpinnerBox, + cssSponsorButton, +} from 'app/client/ui/AdminTogglesCss'; +import {basicButtonLink} from 'app/client/ui2018/buttons'; 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'; const testId = makeTestId('test-support-grist-page-'); @@ -164,45 +174,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; -`); From d0d9150aa300bdf7b69755d0fb5437f4286924d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 29 Jul 2024 19:13:20 -0400 Subject: [PATCH 11/13] markdown: new utility module Since we've started using Markdown, why not a simple utility function to start using it? --- app/client/lib/markdown.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/client/lib/markdown.ts diff --git a/app/client/lib/markdown.ts b/app/client/lib/markdown.ts new file mode 100644 index 0000000000..49365b08a6 --- /dev/null +++ b/app/client/lib/markdown.ts @@ -0,0 +1,11 @@ +import { sanitizeHTML } from 'app/client/ui/sanitizeHTML'; +import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs'; +import { marked } from 'marked'; + +export function markdown(markdownObs: BindableValue): DomElementMethod { + return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value)); +} + +function setMarkdownValue(elem: Element, markdownValue: string): void { + elem.innerHTML = sanitizeHTML(marked(markdownValue)); +} From a6b26d497ea6cb40bd974a433212cafd2e706403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Wed, 24 Jul 2024 18:55:19 -0400 Subject: [PATCH 12/13] ToggleEnterpriseWidget: new frontend toggle for the admin Strongly patterned after SupportGristPage. In fact, it has almost the same structure. Perhaps one day it would be possible to synchronise the logic between the two toggles even further, but I couldn't see a simple way to do so now. For now, some code structure duplication seemed easiest in lieau of more abstractions. --- app/client/ui/ToggleEnterpriseWidget.ts | 78 +++++++++++++++++++++++++ app/common/gristUrls.ts | 1 + 2 files changed, 79 insertions(+) create mode 100644 app/client/ui/ToggleEnterpriseWidget.ts diff --git a/app/client/ui/ToggleEnterpriseWidget.ts b/app/client/ui/ToggleEnterpriseWidget.ts new file mode 100644 index 0000000000..0101990078 --- /dev/null +++ b/app/client/ui/ToggleEnterpriseWidget.ts @@ -0,0 +1,78 @@ +import {makeT} from 'app/client/lib/localization'; +import {markdown} from 'app/client/lib/markdown'; +import {Computed, Disposable, dom, makeTestId} from "grainjs"; +import {commonUrls} from "app/common/gristUrls"; +import {ToggleEnterpriseModel} from 'app/client/models/ToggleEnterpriseModel'; +import { + cssOptInButton, + cssOptOutButton, + cssParagraph, + cssSection, +} from 'app/client/ui/AdminTogglesCss'; + + +const t = makeT('ToggleEnterprsiePage'); +const testId = makeTestId('test-toggle-enterprise-page-'); + +export class ToggleEnterpriseWidget extends Disposable { + private readonly _model: ToggleEnterpriseModel = new ToggleEnterpriseModel(); + 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() { + super(); + this._model.fetchEnterpriseToggle().catch(reportError); + } + + public getEnterpriseToggleObservable() { + return this._isEnterprise; + } + + public buildEnterpriseSection() { + return cssSection( + dom.domComputed(this._isEnterprise, (enterpriseEnabled) => { + return [ + enterpriseEnabled ? + cssParagraph( + markdown(t('Grist Enterprise is **enabled**.')), + testId('enterprise-opt-out-message'), + ) : null, + cssParagraph( + markdown(t(`An activation key is used to run Grist Enterprise after a trial period +of 30 days has expired. Get an activation key by [signing up for Grist +Enterprise]({{signupLink}}). You do not need an activation key to run +Grist Core. + +Learn more in our [Help Center]({{helpCenter}}).`, { + signupLink: commonUrls.plans, + helpCenter: commonUrls.helpEnterpriseOptIn + })) + ), + this._buildEnterpriseSectionButtons(), + ]; + }), + testId('enterprise-opt-in-section'), + ); + } + + public _buildEnterpriseSectionButtons() { + return dom.domComputed(this._isEnterprise, (enterpriseEnabled) => { + if (enterpriseEnabled) { + return [ + cssOptOutButton(t('Disable Grist Enterprise'), + dom.on('click', () => this._isEnterprise.set(false)), + ), + ]; + } else { + return [ + cssOptInButton(t('Enable Grist Enterprise'), + dom.on('click', () => this._isEnterprise.set(true)), + ), + ]; + } + }); + } +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 2f81d215f2..6edc6fa859 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", From ee716aefdc48e459304123a879fbc0ea42cf0a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Tue, 30 Jul 2024 12:52:24 -0400 Subject: [PATCH 13/13] AdminPanel: add the toggle for enterprise Final ingredient. This surfaces the work in creating the backend config API, the frontend model, the grainjs observable, and the grainjs DOM and CSS components. --- app/client/ui/AdminPanel.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index 91f6802b49..c0bb068292 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 {ToggleEnterpriseWidget} from 'app/client/ui/ToggleEnterpriseWidget'; 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 = ToggleEnterpriseWidget.create(this); 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'), + value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()), + expandedContent: this._toggleEnterprise.buildEnterpriseSection(), + }), this._buildUpdates(owner), ]), dom.create(AdminSection, t('Self Checks'), [