Introduce GRIST_ANON_PLAYGROUND variable #642 (#651)

* `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)

---------

Co-authored-by: Florent FAYOLLE <florent.fayolle@beta.gouv.fr>
This commit is contained in:
Florent 2023-09-08 15:05:52 +02:00 committed by GitHub
parent dc5ddc27b0
commit 5ff79703b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 149 additions and 12 deletions

View File

@ -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_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_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_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_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_TEMPLATE_ORG | set to an org "domain" to show public docs from that org
GRIST_HELP_CENTER | set the help center link ref GRIST_HELP_CENTER | set the help center link ref

View File

@ -190,7 +190,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
public createLeftPane(leftPanelOpen: Observable<boolean>) { public createLeftPane(leftPanelOpen: Observable<boolean>) {
return cssLeftPanel( return cssLeftPanel(
dom.maybe(this.gristDoc, (activeDoc) => [ dom.maybe(this.gristDoc, (activeDoc) => [
addNewButton(leftPanelOpen, addNewButton({ isOpen: leftPanelOpen },
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), { menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
placement: 'bottom-start', placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it. // "Add New" menu should have the same width as the "Add New" button that opens it.

View File

@ -5,14 +5,27 @@ import {dom, DomElementArg, Observable, styled} from "grainjs";
const t = makeT(`AddNewButton`); const t = makeT(`AddNewButton`);
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) { export function addNewButton(
{
isOpen,
isDisabled = false,
}: {
isOpen: Observable<boolean> | boolean,
isDisabled?: boolean
},
...args: DomElementArg[]
) {
return cssAddNewButton( return cssAddNewButton(
cssAddNewButton.cls('-open', isOpen), cssAddNewButton.cls('-open', isOpen),
cssAddNewButton.cls('-disabled', isDisabled),
// Setting spacing as flex items allows them to shrink faster when there isn't enough space. // Setting spacing as flex items allows them to shrink faster when there isn't enough space.
cssLeftMargin(), cssLeftMargin(),
cssAddText(t("Add New")), cssAddText(t("Add New")),
dom('div', {style: 'flex: 1 1 16px'}), dom('div', {style: 'flex: 1 1 16px'}),
cssPlusButton(cssPlusIcon('Plus')), cssPlusButton(
cssPlusButton.cls('-disabled', isDisabled),
cssPlusIcon('Plus')
),
dom('div', {style: 'flex: 0 1 16px'}), dom('div', {style: 'flex: 0 1 16px'}),
...args, ...args,
); );
@ -47,6 +60,11 @@ export const cssAddNewButton = styled('div', `
background-color: ${theme.controlPrimaryHoverBg}; background-color: ${theme.controlPrimaryHoverBg};
--circle-color: ${theme.addNewCircleHoverBg}; --circle-color: ${theme.addNewCircleHoverBg};
} }
&-disabled, &-disabled:hover {
color: ${theme.controlDisabledFg};
background-color: ${theme.controlDisabledBg}
}
`); `);
const cssLeftMargin = styled('div', ` const cssLeftMargin = styled('div', `
flex: 0 1 24px; flex: 0 1 24px;
@ -72,6 +90,9 @@ const cssPlusButton = styled('div', `
border-radius: 14px; border-radius: 14px;
background-color: var(--circle-color); background-color: var(--circle-color);
text-align: center; text-align: center;
&-disabled {
background-color: ${theme.controlDisabledBg};
}
`); `);
const cssPlusIcon = styled(icon, ` const cssPlusIcon = styled(icon, `
background-color: ${theme.addNewCircleFg}; background-color: ${theme.addNewCircleFg};

View File

@ -1,5 +1,5 @@
import {makeT} from 'app/client/lib/localization'; 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 {HomeModel} from 'app/client/models/HomeModel';
import {productPill} from 'app/client/ui/AppHeader'; import {productPill} from 'app/client/ui/AppHeader';
import * as css from 'app/client/ui/DocMenuCss'; 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 {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomContents, styled} from 'grainjs'; import {Computed, dom, DomContents, styled} from 'grainjs';
const t = makeT('HomeIntro'); 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) { 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")); const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up"));
return [ 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("Get started by exploring templates, or creating your first Grist document.")),
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}), cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
(!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })), (!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),

View File

@ -30,16 +30,17 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
const creating = observable<boolean>(false); const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null); const renaming = observable<Workspace|null>(null);
const isAnonymous = !home.app.currentValidUser; const isAnonymous = !home.app.currentValidUser;
const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
return cssContent( return cssContent(
dom.autoDispose(creating), dom.autoDispose(creating),
dom.autoDispose(renaming), dom.autoDispose(renaming),
addNewButton(leftPanelOpen, addNewButton({ isOpen: leftPanelOpen, isDisabled: !canCreate },
menu(() => addMenu(home, creating), { canCreate ? menu(() => addMenu(home, creating), {
placement: 'bottom-start', placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it. // "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}` stretchToSelector: `.${cssAddNewButton.className}`
}), }) : null,
dom.cls('behavioral-prompt-add-new'), dom.cls('behavioral-prompt-add-new'),
testId('dm-add-new'), testId('dm-add-new'),
), ),

View File

@ -604,6 +604,9 @@ export interface GristLoadConfig {
// If set, enable anonymous sharing UI elements. // If set, enable anonymous sharing UI elements.
supportAnon?: boolean; supportAnon?: boolean;
// If set, enable anonymous playground.
enableAnonPlayground?: boolean;
// If set, allow selection of the specified engines. // If set, allow selection of the specified engines.
// TODO: move this list to a separate endpoint. // TODO: move this list to a separate endpoint.
supportEngines?: EngineCode[]; supportEngines?: EngineCode[];

View File

@ -170,6 +170,7 @@ export class DocWorkerApi {
const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false)); const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));
// check document exists (not soft deleted) and user can edit it // check document exists (not soft deleted) and user can edit it
const canEdit = expressWrap(this._assertAccess.bind(this, 'editors', false)); 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)); const isOwner = expressWrap(this._assertAccess.bind(this, 'owners', false));
// check user can edit document, with soft-deleted documents being acceptable // check user can edit document, with soft-deleted documents being acceptable
const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true)); 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. * 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); const userId = getUserId(req);
let uploadId: number|undefined; let uploadId: number|undefined;
@ -1452,6 +1453,17 @@ export class DocWorkerApi {
return await this._dbManager.increaseUsage(getDocScope(req), limit, {delta: 1}); 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, private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean,
req: Request, res: Response, next: NextFunction) { req: Request, res: Response, next: NextFunction) {
const scope = getDocScope(req); const scope = getDocScope(req);

View File

@ -850,10 +850,11 @@ export class FlexServer implements GristServer {
baseDomain: this._defaultBaseDomain, baseDomain: this._defaultBaseDomain,
}); });
const isForced = appSettings.section('login').flag('forced').readBool({ const forceLogin = appSettings.section('login').flag('forced').readBool({
envVar: 'GRIST_FORCE_LOGIN', envVar: 'GRIST_FORCE_LOGIN',
}); });
const forcedLoginMiddleware = isForced ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
const welcomeNewUser: express.RequestHandler = isSingleUserMode() ? const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
(req, res, next) => next() : (req, res, next) => next() :

View File

@ -56,6 +56,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com", helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
pathOnly, pathOnly,
supportAnon: shouldSupportAnon(), supportAnon: shouldSupportAnon(),
enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),
supportEngines: getSupportedEngineChoices(), supportEngines: getSupportedEngineChoices(),
features: getFeatures(), features: getFeatures(),
pageTitleSuffix: configuredPageTitleSuffix(), pageTitleSuffix: configuredPageTitleSuffix(),

View File

@ -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);
});
});
});

View File

@ -120,6 +120,24 @@ describe('DocApi', function () {
testDocApi(); 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. // the way these tests are written, non-merged server requires redis.
if (process.env.TEST_REDIS_URL) { if (process.env.TEST_REDIS_URL) {
describe("should work with a home server and a docworker", async () => { describe("should work with a home server and a docworker", async () => {