import {buildHomeBanners} from 'app/client/components/Banners'; import {makeT} from 'app/client/lib/localization'; import {localStorageJsonObs} from 'app/client/lib/localStorageObs'; import {getTimeFromNow} from 'app/client/lib/timeUtils'; import {AdminChecks, probeDetails, ProbeDetails} from 'app/client/models/AdminChecks'; import {AppModel, getHomeUrl, reportError} 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 {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'; import {toggle} from 'app/client/ui2018/checkbox'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {cssLink, makeLinks} from 'app/client/ui2018/links'; import {BootProbeInfo, BootProbeResult, SandboxingBootProbeDetails} from 'app/common/BootProbe'; import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI'; import {naturalCompare} from 'app/common/SortFunc'; import {getGristConfig} from 'app/common/urlUtils'; import * as version from 'app/common/version'; 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. export function getAdminPanelName() { return t("Admin Panel"); } 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; constructor(private _appModel: AppModel) { super(); document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig()); this._checks = new AdminChecks(this, this._installAPI); } public buildDom() { this._checks.fetchAvailableChecks().catch(err => { reportError(err); }); 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: MultiHolder) { // If probes are available, show the panel as normal. // Otherwise say it is unavailable, and describe a fallback // mechanism for access. return cssPageContainer( dom.cls('clipboard'), {tabIndex: "-1"}, dom.maybe(this._checks.probes, probes => { return probes.length > 0 ? this._buildMainContentForAdmin(owner) : this._buildMainContentForOthers(owner); }), testId('admin-panel'), ); } /** * Show something helpful to those without access to the panel, * which could include a legit adminstrator if auth is misconfigured. */ private _buildMainContentForOthers(owner: MultiHolder) { const exampleKey = _longCodeForExample(); return dom.create(AdminSection, t('Administrator Panel Unavailable'), [ dom('p', t(`You do not have access to the administrator panel. Please log in as an administrator.`)), dom( 'p', t(`Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}`, { bootKey: dom('pre', `GRIST_BOOT_KEY=${exampleKey}`), url: dom('pre', `/admin?boot-key=${exampleKey}`) }), ), testId('admin-panel-error'), ]); } private _buildMainContentForAdmin(owner: MultiHolder) { return [ dom.create(AdminSection, t('Support Grist'), [ dom.create(AdminSectionItem, { id: 'telemetry', name: t('Telemetry'), description: t('Help us make Grist better'), value: dom.create(HidableToggle, this._supportGrist.getTelemetryOptInObservable()), expandedContent: this._supportGrist.buildTelemetrySection(), }), dom.create(AdminSectionItem, { id: 'sponsor', name: t('Sponsor'), description: t('Support Grist Labs on GitHub'), value: this._supportGrist.buildSponsorshipSmallButton(), expandedContent: this._supportGrist.buildSponsorshipSection(), }), ]), dom.create(AdminSection, t('Security Settings'), [ dom.create(AdminSectionItem, { id: 'sandboxing', name: t('Sandboxing'), description: t('Sandbox settings for data engine'), value: this._buildSandboxingDisplay(owner), expandedContent: this._buildSandboxingNotice(), }), dom.create(AdminSectionItem, { id: 'authentication', name: t('Authentication'), description: t('Current authentication method'), value: this._buildAuthenticationDisplay(owner), expandedContent: this._buildAuthenticationNotice(owner), }), dom.create(AdminSectionItem, { id: 'session', name: t('Session Secret'), description: t('Key to sign sessions with'), value: this._buildSessionSecretDisplay(owner), expandedContent: this._buildSessionSecretNotice(owner), }) ]), dom.create(AdminSection, t('Version'), [ dom.create(AdminSectionItem, { id: 'version', name: t('Current'), 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'), [ this._buildProbeItems(owner, { showRedundant: false, showNovel: true, }), dom.create(AdminSectionItem, { id: 'probe-other', name: 'more...', description: '', value: '', expandedContent: this._buildProbeItems(owner, { showRedundant: true, showNovel: false, }), }), ]), ]; } private _buildSandboxingDisplay(owner: IDisposableOwner) { return dom.domComputed( use => { const req = this._checks.requestCheckById(use, 'sandboxing'); const result = req ? use(req.result) : undefined; const success = result?.status === 'success'; const details = result?.details as SandboxingBootProbeDetails|undefined; if (!details) { return cssValueLabel(t('unknown')); } const flavor = details.flavor; const configured = details.configured; return cssValueLabel( configured ? (success ? cssHappyText(t('OK') + `: ${flavor}`) : cssErrorText(t('Error') + `: ${flavor}`)) : cssErrorText(t('unconfigured'))); } ); } private _buildSandboxingNotice() { return [ // Use AdminChecks text for sandboxing, in order not to // duplicate. probeDetails['sandboxing'].info, dom( 'div', {style: 'margin-top: 8px'}, cssLink({href: commonUrls.helpSandboxing, target: '_blank'}, t('Learn more.')) ), ]; } private _buildAuthenticationDisplay(owner: IDisposableOwner) { return dom.domComputed( use => { const req = this._checks.requestCheckById(use, 'authentication'); const result = req ? use(req.result) : undefined; if (!result) { return cssValueLabel(cssErrorText('unavailable')); } const { status, details } = result; const success = status === 'success'; const loginSystemId = details?.loginSystemId; if (!success || !loginSystemId) { return cssValueLabel(cssErrorText('auth error')); } if (loginSystemId === 'no-logins') { return cssValueLabel(cssDangerText('no authentication')); } return cssValueLabel(cssHappyText(loginSystemId)); } ); } private _buildAuthenticationNotice(owner: IDisposableOwner) { return t('Grist allows different types of authentication to be configured, including SAML and OIDC. \ We recommend enabling one of these if Grist is accessible over the network or being made available \ to multiple people.'); } private _buildSessionSecretDisplay(owner: IDisposableOwner) { return dom.domComputed( use => { const req = this._checks.requestCheckById(use, 'session-secret'); const result = req ? use(req.result) : undefined; if (result?.status === 'warning') { return cssValueLabel(cssDangerText('default')); } return cssValueLabel(cssHappyText('configured')); } ); } private _buildSessionSecretNotice(owner: IDisposableOwner) { return t('Grist signs user session cookies with a secret key. Please set this key via the environment variable \ GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice \ in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.'); } private _buildUpdates(owner: MultiHolder) { // We can be in those states: enum State { // Never checked before (no last version or last check time). // Shows "No information available" [Check now] NEVER, // Did check previously, but it was a while ago, user should press the button to check. // Shows "Last checked X days ago" [Check now] STALE, // In the middle of checking for updates. CHECKING, // Transient state, shown after Check now is clicked. // Grist is up to date (state only shown after a successful check), or even upfront. // Won't be shown after page is reloaded. // Shows "Checking for updates..." CURRENT, // A newer version is available. Can be shown after reload if last // version that was checked is newer than the current version. // Shows "Newer version available" [version] AVAILABLE, // Error occurred during this check. If the error occurred during last check // it is not stored. // Shows "Error checking for updates" [Check now] ERROR, } // Are updates enabled at all. const defaultValue = { onLoad: false, lastCheckDate: null as number|null, lastVersion: null as string|null, }; const prop = (key: T) => { const computed = Computed.create(owner, (use) => use(settings)[key]); computed.onWrite((val) => settings.set({...settings.get(), [key]: val})); return computed as Observable; }; const settings = owner.autoDispose(localStorageJsonObs('new-version-check', defaultValue)); const onLoad = prop('onLoad'); const latestVersion = prop('lastVersion'); const lastCheckDate = prop('lastCheckDate'); const comparison = Computed.create(owner, (use) => { const versions = [version.version, use(latestVersion)]; if (!versions[1]) { return null; } // Sort them in natural order, so that "1.10" comes after "1.9". versions.sort(naturalCompare).reverse(); if (versions[0] === version.version) { return 'old'; } else { return 'new'; } }); // Observable state of the updates check. const state: Observable = Observable.create(owner, State.NEVER); // The background task that checks for updates, can be disposed (cancelled) when needed. let backgroundTask: IDisposable|null = null; // By default we link to the GitHub releases page, but the endpoint might say something different. let releaseURL = 'https://github.com/gristlabs/grist-core/releases'; // All the events that might occur const actions = { checkForUpdates: async () => { state.set(State.CHECKING); latestVersion.set(null); // We can be disabled, why the check is in progress. const controller = new AbortController(); backgroundTask = { dispose() { if (controller.signal.aborted) { return; } backgroundTask = null; controller.abort(); } }; owner.autoDispose(backgroundTask); try { const result = await this._installAPI.checkUpdates(); if (controller.signal.aborted) { return; } actions.gotLatestVersion(result); } catch(err) { if (controller.signal.aborted) { return; } state.set(State.ERROR); reportError(err); } }, disableAutoCheck: () => { backgroundTask?.dispose(); backgroundTask = null; onLoad.set(false); }, enableAutoCheck: () => { onLoad.set(true); if (state.get() !== State.CHECKING && state.get() !== State.AVAILABLE) { actions.checkForUpdates().catch(reportError); } }, gotLatestVersion: (data: LatestVersion) => { lastCheckDate.set(Date.now()); latestVersion.set(data.latestVersion); releaseURL = data.updateURL || releaseURL; const result = comparison.get(); switch (result) { case 'old': state.set(State.CURRENT); break; case 'new': state.set(State.AVAILABLE); break; // This should not happen, but if it does, we should show the error. default: state.set(State.ERROR); break; } } }; const description = Computed.create(owner, (use) => { switch (use(state)) { case State.NEVER: return t('No information available'); case State.CHECKING: return '⌛ ' + t('Checking for updates...'); case State.CURRENT: return '✅ ' + t('Grist is up to date'); case State.AVAILABLE: return t('Newer version available'); case State.ERROR: return '❌ ' + t('Error checking for updates'); case State.STALE: { const lastCheck = use(lastCheckDate); return t('Last checked {{time}}', {time: lastCheck ? getTimeFromNow(lastCheck) : 'n/a'}); } } }); // Now trigger the initial state, by checking if we should auto-check. if (onLoad.get()) { actions.checkForUpdates().catch(reportError); } else { if (comparison.get() === 'new') { state.set(State.AVAILABLE); } else if (comparison.get() === 'old') { state.set(State.STALE); } else { state.set(State.NEVER); // default one. } } // Toggle component operates on a boolean observable, without a way to set the value. So // create a controller for it to intercept the write and call the appropriate action. const enabledController = Computed.create(owner, (use) => use(onLoad)); enabledController.onWrite((val) => { if (val) { actions.enableAutoCheck(); } else { actions.disableAutoCheck(); } }); const upperCheckNowVisible = Computed.create(owner, (use) => { switch (use(state)) { case State.CHECKING: case State.CURRENT: case State.AVAILABLE: return false; default: return true; } }); return dom.create(AdminSectionItem, { id: 'updates', name: t('Updates'), description: dom('span', testId('admin-panel-updates-message'), dom.text(description)), value: cssValueButton( dom.domComputed(use => { if (use(state) === State.CHECKING) { return null; } if (use(upperCheckNowVisible)) { return basicButton( t('Check now'), dom.on('click', actions.checkForUpdates), testId('admin-panel-updates-upper-check-now') ); } if (use(latestVersion)) { return cssValueLabel(`Version ${use(latestVersion)}`, testId('admin-panel-updates-version')); } throw new Error('Invalid state'); }) ), expandedContent: cssColumns( cssColumn( cssColumn.cls('-left'), dom('div', t('Grist releases are at '), makeLinks(releaseURL)), dom.maybe(lastCheckDate, ms => dom('div', dom('span', t('Last checked {{time}}', {time: getTimeFromNow(ms)})), dom('span', ' '), // Format date in local format. cssGrayed(new Date(ms).toLocaleString()), )), dom('div', t('Auto-check when this page loads')), ), cssColumn( cssColumn.cls('-right'), // `Check now` button, only shown when auto checks are enabled and we are not in the // middle of checking. Otherwise the button is shown in the summary row, and there is // no need to duplicate it. dom.maybe(use => !use(upperCheckNowVisible), () => [ cssCheckNowButton( t('Check now'), testId('admin-panel-updates-lower-check-now'), dom.on('click', actions.checkForUpdates), dom.prop('disabled', use => use(state) === State.CHECKING), ), ]), toggle(enabledController, testId('admin-panel-updates-auto-check')), ), ) }); } /** * Show the results of various checks. Of the checks, some are considered * "redundant" (already covered elsewhere in the Admin Panel) and the * remainder are "novel". */ private _buildProbeItems(owner: MultiHolder, options: { showRedundant: boolean, showNovel: boolean, }) { return dom.domComputed( use => [ ...use(this._checks.probes).map(probe => { const isRedundant = [ 'sandboxing', 'authentication', 'session-secret' ].includes(probe.id); const show = isRedundant ? options.showRedundant : options.showNovel; if (!show) { return null; } const req = this._checks.requestCheck(probe); return this._buildProbeItem(owner, req.probe, use(req.result), req.details); }), ] ); } /** * Show the result of an individual check. */ private _buildProbeItem(owner: MultiHolder, info: BootProbeInfo, result: BootProbeResult, details: ProbeDetails|undefined) { const status = this._encodeSuccess(result); return dom.create(AdminSectionItem, { id: `probe-${info.id}`, name: info.id, description: info.name, value: cssStatus(status), expandedContent: [ cssCheckHeader( t('Results'), { style: 'margin-top: 0px; padding-top: 0px;' }, ), result.verdict ? dom('pre', result.verdict) : null, (result.status === 'none') ? null : dom('p', (result.status === 'success') ? t('Check succeeded.') : t('Check failed.')), (result.status !== 'none') ? null : dom('p', t('No fault detected.')), (details?.info === undefined) ? null : [ cssCheckHeader(t('Notes')), details.info, ], (result.details === undefined) ? null : [ cssCheckHeader(t('Details')), ...Object.entries(result.details).map(([key, val]) => { return dom( 'div', cssLabel(key), dom('input', dom.prop( 'value', typeof val === 'string' ? val : JSON.stringify(val)))); }), ], ], }); } /** * Give an icon summarizing success or failure. Factor in the * severity of the result for failures. This is crude, the * visualization of the results can be elaborated in future. */ private _encodeSuccess(result: BootProbeResult) { switch (result.status) { case 'success': return '✅'; case 'fault': return '❌'; case 'warning': return '❗'; case 'hmm': return '?'; case 'none': return '―'; default: // should not arrive here return '??'; } } } // Ugh I'm not a front end person. h5 small-caps, sure why not. // Hopefully someone with taste will edit someday! const cssCheckHeader = styled('h5', ` margin-bottom: 5px; font-variant: small-caps; `); const cssStatus = styled('div', ` display: inline-block; text-align: center; width: 40px; padding: 5px; `); const cssPageContainer = styled('div', ` overflow: auto; padding: 40px; font-size: ${vars.introFontSize}; color: ${theme.text}; @media ${mediaSmall} { & { padding: 0px; font-size: ${vars.mediumFontSize}; } } `); export const cssValueLabel = styled('div', ` padding: 4px 8px; color: ${theme.text}; border: 1px solid ${theme.inputBorder}; border-radius: ${vars.controlBorderRadius}; `); // A wrapper for the version details panel. Shows two columns. // First grows as needed, second shrinks as needed and is aligned to the bottom. const cssColumns = styled('div', ` display: flex; align-items: flex-end; & > div:first-child { flex-grow: 1; flex-shrink: 0; } & > div:last-child { flex-shrink: 1; } `); const cssColumn = styled('div', ` display: flex; flex-direction: column; gap: 8px; flex-grow: 1; flex-shrink: 1; margin-block: 1px; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */ &-left { align-items: flex-start; } &-right { align-items: flex-end; justify-content: flex-end; } `); const cssValueButton = styled('div', ` height: 30px; `); const cssCheckNowButton = styled(basicButton, ` &-hidden { visibility: hidden; } `); const cssGrayed = styled('span', ` color: ${theme.lightText}; `); const cssErrorText = styled('span', ` color: ${theme.errorText}; `); export const cssDangerText = styled('div', ` color: ${theme.dangerText}; `); const cssHappyText = styled('span', ` color: ${theme.controlFg}; `); export const cssLabel = styled('div', ` display: inline-block; min-width: 100px; text-align: right; padding-right: 5px; `); /** * Make a long code to use in the example, so that if people copy * and paste it lazily, they end up decently secure, or at least a * lot more secure than a key like "REPLACE_WITH_YOUR_SECRET" */ function _longCodeForExample() { // Crypto in insecure contexts doesn't have randomUUID if (window.isSecureContext) { return 'example-a' + window.crypto.randomUUID(); } return 'example-b' + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/x/g, () => { return Math.floor(Math.random() * 16).toString(16); }); }