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>
pull/663/head
Florent 8 months ago committed by GitHub
parent dc5ddc27b0
commit 5ff79703b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -190,7 +190,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
public createLeftPane(leftPanelOpen: Observable<boolean>) {
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.

@ -5,14 +5,27 @@ import {dom, DomElementArg, Observable, styled} from "grainjs";
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(
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};

@ -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() })),

@ -30,16 +30,17 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(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'),
),

@ -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[];

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

@ -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() :

@ -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(),

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

@ -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 () => {

Loading…
Cancel
Save