diff --git a/README.md b/README.md index af392236..8dead7d6 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,8 @@ GRIST_SERVERS | the types of server to setup. Comma separated values which may c GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN GRIST_SESSION_SECRET | a key used to encode sessions -GRIST_FORCE_LOGIN | when set to 'true' disables anonymous access +GRIST_ANON_PLAYGROUND | When set to 'false' deny anonymous users access to the home page +GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymous access at all (features like sharing docs publicly requires authentication) GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org GRIST_HELP_CENTER | set the help center link ref diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 727f2e70..417d1180 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -190,7 +190,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { public createLeftPane(leftPanelOpen: Observable) { return cssLeftPanel( dom.maybe(this.gristDoc, (activeDoc) => [ - addNewButton(leftPanelOpen, + addNewButton({ isOpen: leftPanelOpen }, menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), { placement: 'bottom-start', // "Add New" menu should have the same width as the "Add New" button that opens it. diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 1cd45dc1..02fa1762 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -5,14 +5,27 @@ import {dom, DomElementArg, Observable, styled} from "grainjs"; const t = makeT(`AddNewButton`); -export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { +export function addNewButton( + { + isOpen, + isDisabled = false, + }: { + isOpen: Observable | boolean, + isDisabled?: boolean + }, + ...args: DomElementArg[] +) { return cssAddNewButton( cssAddNewButton.cls('-open', isOpen), + cssAddNewButton.cls('-disabled', isDisabled), // Setting spacing as flex items allows them to shrink faster when there isn't enough space. cssLeftMargin(), cssAddText(t("Add New")), dom('div', {style: 'flex: 1 1 16px'}), - cssPlusButton(cssPlusIcon('Plus')), + cssPlusButton( + cssPlusButton.cls('-disabled', isDisabled), + cssPlusIcon('Plus') + ), dom('div', {style: 'flex: 0 1 16px'}), ...args, ); @@ -47,6 +60,11 @@ export const cssAddNewButton = styled('div', ` background-color: ${theme.controlPrimaryHoverBg}; --circle-color: ${theme.addNewCircleHoverBg}; } + + &-disabled, &-disabled:hover { + color: ${theme.controlDisabledFg}; + background-color: ${theme.controlDisabledBg} + } `); const cssLeftMargin = styled('div', ` flex: 0 1 24px; @@ -72,6 +90,9 @@ const cssPlusButton = styled('div', ` border-radius: 14px; background-color: var(--circle-color); text-align: center; + &-disabled { + background-color: ${theme.controlDisabledBg}; + } `); const cssPlusIcon = styled(icon, ` background-color: ${theme.addNewCircleFg}; diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index a5d52d10..debe362f 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,5 +1,5 @@ import {makeT} from 'app/client/lib/localization'; -import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; +import {getLoginOrSignupUrl, getLoginUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {productPill} from 'app/client/ui/AppHeader'; import * as css from 'app/client/ui/DocMenuCss'; @@ -12,6 +12,7 @@ import {cssLink} from 'app/client/ui2018/links'; import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; +import {getGristConfig} from 'app/common/urlUtils'; import {Computed, dom, DomContents, styled} from 'grainjs'; const t = makeT('HomeIntro'); @@ -112,10 +113,36 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { ]; } +function makeAnonIntroWithoutPlayground(homeModel: HomeModel) { + return [ + (!isFeatureEnabled('helpCenter') ? null : cssIntroLine(t("Visit our {{link}} to learn more about Grist.", { + link: helpCenterLink() + }), testId('welcome-text-no-playground'))), + cssIntroLine(t("To use Grist, please either sign up or sign in.")), + cssBtnGroup( + cssBtn(t("Sign up"), cssButton.cls('-primary'), testId('intro-sign-up'), + dom.on('click', () => location.href = getSignupUrl()) + ), + cssBtn(t("Sign in"), testId('intro-sign-in'), + dom.on('click', () => location.href = getLoginUrl()) + ) + ) + ]; +} + function makeAnonIntro(homeModel: HomeModel) { + const welcomeToGrist = css.docListHeader(t("Welcome to Grist!"), testId('welcome-title')); + + if (!getGristConfig().enableAnonPlayground) { + return [ + welcomeToGrist, + ...makeAnonIntroWithoutPlayground(homeModel) + ]; + } + const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up")); return [ - css.docListHeader(t("Welcome to Grist!"), testId('welcome-title')), + welcomeToGrist, cssIntroLine(t("Get started by exploring templates, or creating your first Grist document.")), cssIntroLine(t("{{signUp}} to save your work. ", {signUp}), (!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })), diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index 44de1c66..be255bb0 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -30,16 +30,17 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom const creating = observable(false); const renaming = observable(null); const isAnonymous = !home.app.currentValidUser; + const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground; return cssContent( dom.autoDispose(creating), dom.autoDispose(renaming), - addNewButton(leftPanelOpen, - menu(() => addMenu(home, creating), { + addNewButton({ isOpen: leftPanelOpen, isDisabled: !canCreate }, + canCreate ? menu(() => addMenu(home, creating), { placement: 'bottom-start', // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` - }), + }) : null, dom.cls('behavioral-prompt-add-new'), testId('dm-add-new'), ), diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index dc0ca1c0..454193f0 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -604,6 +604,9 @@ export interface GristLoadConfig { // If set, enable anonymous sharing UI elements. supportAnon?: boolean; + // If set, enable anonymous playground. + enableAnonPlayground?: boolean; + // If set, allow selection of the specified engines. // TODO: move this list to a separate endpoint. supportEngines?: EngineCode[]; diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 1b707d7c..3533cbf2 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -170,6 +170,7 @@ export class DocWorkerApi { const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false)); // check document exists (not soft deleted) and user can edit it const canEdit = expressWrap(this._assertAccess.bind(this, 'editors', false)); + const checkAnonymousCreation = expressWrap(this._checkAnonymousCreation.bind(this)); const isOwner = expressWrap(this._assertAccess.bind(this, 'owners', false)); // check user can edit document, with soft-deleted documents being acceptable const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true)); @@ -1234,7 +1235,7 @@ export class DocWorkerApi { * * TODO: unify this with the other document creation and import endpoints. */ - this._app.post('/api/docs', expressWrap(async (req, res) => { + this._app.post('/api/docs', checkAnonymousCreation, expressWrap(async (req, res) => { const userId = getUserId(req); let uploadId: number|undefined; @@ -1452,6 +1453,17 @@ export class DocWorkerApi { return await this._dbManager.increaseUsage(getDocScope(req), limit, {delta: 1}); } + /** + * Disallow document creation for anonymous users if GRIST_ANONYMOUS_CREATION is set to false. + */ + private async _checkAnonymousCreation(req: Request, res: Response, next: NextFunction) { + const isAnonPlayground = isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true); + if (isAnonymousUser(req) && !isAnonPlayground) { + throw new ApiError('Anonymous document creation is disabled', 403); + } + next(); + } + private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean, req: Request, res: Response, next: NextFunction) { const scope = getDocScope(req); diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index cb60b1d2..524ab419 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -850,10 +850,11 @@ export class FlexServer implements GristServer { baseDomain: this._defaultBaseDomain, }); - const isForced = appSettings.section('login').flag('forced').readBool({ + const forceLogin = appSettings.section('login').flag('forced').readBool({ envVar: 'GRIST_FORCE_LOGIN', }); - const forcedLoginMiddleware = isForced ? this._redirectToLoginWithoutExceptionsMiddleware : noop; + + const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop; const welcomeNewUser: express.RequestHandler = isSingleUserMode() ? (req, res, next) => next() : diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index d80bc0d3..304c0422 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -56,6 +56,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com", pathOnly, supportAnon: shouldSupportAnon(), + enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true), supportEngines: getSupportedEngineChoices(), features: getFeatures(), pageTitleSuffix: configuredPageTitleSuffix(), diff --git a/test/nbrowser/HomeIntroWithoutPlaygound.ts b/test/nbrowser/HomeIntroWithoutPlaygound.ts new file mode 100644 index 00000000..aafc5353 --- /dev/null +++ b/test/nbrowser/HomeIntroWithoutPlaygound.ts @@ -0,0 +1,52 @@ +import {assert, driver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('HomeIntroWithoutPlayground', function() { + this.timeout(40000); + setupTestSuite({samples: true}); + gu.withEnvironmentSnapshot({'GRIST_ANON_PLAYGROUND': false}); + + describe("Anonymous on merged-org", function() { + it('should show welcome page with signin and signup buttons and "add new" button disabled', async function () { + // Sign out + const session = await gu.session().personalSite.anon.login(); + + // Open doc-menu + await session.loadDocMenu('/'); + + assert.equal(await driver.find('.test-welcome-title').getText(), 'Welcome to Grist!'); + assert.match( + await driver.find('.test-welcome-text-no-playground').getText(), + /Visit our Help Center.*about Grist./ + ); + + // Check the sign-up and sign-in buttons. + const getSignUp = async () => await driver.findContent('.test-intro-sign-up', 'Sign up'); + const getSignIn = async () => await driver.findContent('.test-intro-sign-in', 'Sign in'); + // Check that these buttons take us to a Grist login page. + for (const getButton of [getSignUp, getSignIn]) { + const button = await getButton(); + await button.click(); + await gu.checkLoginPage(); + await driver.navigate().back(); + await gu.waitForDocMenuToLoad(); + } + }); + + it('should not allow creating new documents', async function () { + // Sign out + const session = await gu.session().personalSite.anon.login(); + + // Open doc-menu + await session.loadDocMenu('/'); + + // Check that add-new button is disabled + assert.equal(await driver.find('.test-dm-add-new').matches('[class*=-disabled]'), true); + + // Check that add-new menu is not displayed + await driver.find('.test-dm-add-new').doClick(); + assert.equal(await driver.find('.test-dm-new-doc').isPresent(), false); + }); + }); +}); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index f6e0ad37..0305a4a1 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -120,6 +120,24 @@ describe('DocApi', function () { testDocApi(); }); + describe('With GRIST_ANON_PLAYGROUND disabled', async () => { + setup('anon-playground', async () => { + const additionalEnvConfiguration = { + ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`, + GRIST_DATA_DIR: dataDir, + GRIST_ANON_PLAYGROUND: 'false' + }; + home = docs = await TestServer.startServer('home,docs', tmpDir, suitename, additionalEnvConfiguration); + homeUrl = serverUrl = home.serverUrl; + hasHomeApi = true; + }); + + it('should not allow anonymous users to create new docs', async () => { + const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody); + assert.equal(resp.status, 403); + }); + }); + // the way these tests are written, non-merged server requires redis. if (process.env.TEST_REDIS_URL) { describe("should work with a home server and a docworker", async () => {