(core) Tweak navbar, breadcrumbs, and sign-in buttons

Summary:
The changes are intended to smooth over some sharp edges when a signed-out user
is using Grist (particularly while on the templates site).

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3957
pull/598/head
George Gevoian 10 months ago
parent bc54a6646e
commit a77170c4bd

@ -282,6 +282,7 @@ GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - oth
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_FORCE_LOGIN | when set to 'true' disables anonymous access
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_HELP_CENTER | set the help center link ref GRIST_HELP_CENTER | set the help center link ref
GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default)
GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way.

@ -13,7 +13,7 @@ import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {OrgUsageSummary} from 'app/common/DocUsage'; import {OrgUsageSummary} from 'app/common/DocUsage';
import {Features, isLegacyPlan, Product} from 'app/common/Features'; import {Features, isLegacyPlan, Product} from 'app/common/Features';
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin'; import {LocalPlugin} from 'app/common/plugin';
import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs'; import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs';
@ -23,7 +23,7 @@ import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs'; ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes'; import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs'; import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
@ -93,6 +93,7 @@ export interface AppModel {
isPersonal: boolean; // Is it a personal site? isPersonal: boolean; // Is it a personal site?
isTeamSite: boolean; // Is it a team site? isTeamSite: boolean; // Is it a team site?
isLegacySite: boolean; // Is it a legacy site? isLegacySite: boolean; // Is it a legacy site?
isTemplatesSite: boolean; // Is it the templates site?
orgError?: OrgError; // If currentOrg is null, the error that caused it. orgError?: OrgError; // If currentOrg is null, the error that caused it.
lastVisitedOrgDomain: Observable<string|null>; lastVisitedOrgDomain: Observable<string|null>;
@ -249,6 +250,7 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly isPersonal = Boolean(this.currentOrg?.owner); public readonly isPersonal = Boolean(this.currentOrg?.owner);
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal; public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
public readonly isLegacySite = Boolean(this.currentProduct && isLegacyPlan(this.currentProduct.name)); public readonly isLegacySite = Boolean(this.currentProduct && isLegacyPlan(this.currentProduct.name));
public readonly isTemplatesSite = isTemplatesOrg(this.currentOrg);
public readonly userPrefsObs = getUserPrefsObs(this); public readonly userPrefsObs = getUserPrefsObs(this);
public readonly themePrefs = getUserPrefObs(this.userPrefsObs, 'theme', { public readonly themePrefs = getUserPrefObs(this.userPrefsObs, 'theme', {
@ -325,12 +327,8 @@ export class AppModelImpl extends Disposable implements AppModel {
this.behavioralPromptsManager.reset(); this.behavioralPromptsManager.reset();
}; };
this.autoDispose(subscribe(urlState().state, async (_use, {doc, org}) => { this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {
// Keep track of the last valid org domain the user visited, ignoring those this._updateLastVisitedOrgDomain(s, orgs);
// with a document id in the URL.
if (!this.currentOrg || doc) { return; }
this.lastVisitedOrgDomain.set(org ?? null);
})); }));
} }
@ -404,6 +402,23 @@ export class AppModelImpl extends Disposable implements AppModel {
return computed; return computed;
} }
private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) {
if (
!org ||
// Invalid or inaccessible sites shouldn't be counted as visited.
!this.currentOrg ||
// Visits to a document shouldn't be counted either.
doc
) {
return;
}
// Only count sites that a user has access to (i.e. those listed in the Site Switcher).
if (!availableOrgs.some(({domain}) => domain === org)) { return; }
this.lastVisitedOrgDomain.set(org);
}
/** /**
* If the current user is a new user, record a sign-up event via Google Tag Manager. * If the current user is a new user, record a sign-up event via Google Tag Manager.
*/ */

@ -42,6 +42,7 @@ export interface DocInfo extends Document {
isSnapshot: boolean; isSnapshot: boolean;
isTutorialTrunk: boolean; isTutorialTrunk: boolean;
isTutorialFork: boolean; isTutorialFork: boolean;
isTemplate: boolean;
idParts: UrlIdParts; idParts: UrlIdParts;
openMode: OpenDocMode; openMode: OpenDocMode;
} }
@ -76,6 +77,7 @@ export interface DocPageModel {
isSnapshot: Observable<boolean>; isSnapshot: Observable<boolean>;
isTutorialTrunk: Observable<boolean>; isTutorialTrunk: Observable<boolean>;
isTutorialFork: Observable<boolean>; isTutorialFork: Observable<boolean>;
isTemplate: Observable<boolean>;
importSources: ImportSource[]; importSources: ImportSource[];
@ -131,6 +133,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
(use, doc) => doc ? doc.isTutorialTrunk : false); (use, doc) => doc ? doc.isTutorialTrunk : false);
public readonly isTutorialFork = Computed.create(this, this.currentDoc, public readonly isTutorialFork = Computed.create(this, this.currentDoc,
(use, doc) => doc ? doc.isTutorialFork : false); (use, doc) => doc ? doc.isTutorialFork : false);
public readonly isTemplate = Computed.create(this, this.currentDoc,
(use, doc) => doc ? doc.isTemplate : false);
public readonly importSources: ImportSource[] = []; public readonly importSources: ImportSource[] = [];
@ -431,24 +435,33 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
const idParts = parseUrlId(doc.urlId || doc.id); const idParts = parseUrlId(doc.urlId || doc.id);
const isFork = Boolean(idParts.forkId || idParts.snapshotId); const isFork = Boolean(idParts.forkId || idParts.snapshotId);
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
const isSnapshot = Boolean(idParts.snapshotId);
const isTutorial = doc.type === 'tutorial';
const isTutorialTrunk = isTutorial && !isFork && mode !== 'default';
const isTutorialFork = isTutorial && isFork;
let openMode = mode; let openMode = mode;
if (!openMode) { if (!openMode) {
if (isFork) { if (isFork || isTutorialTrunk || isTutorialFork) {
// Ignore the document 'openMode' setting if the doc is an unsaved fork. // Tutorials (if no explicit /m/default mode is set) automatically get or
// create a fork on load, which then behaves as a document that is in default
// mode. Since the document's 'openMode' has no effect, don't bother trying
// to set it here, as it'll potentially be confusing for other code reading it.
openMode = 'default'; openMode = 'default';
} else if (!isFork && doc.type === 'template') {
// Templates should always open in fork mode by default.
openMode = 'fork';
} else { } else {
// Try to use the document's 'openMode' if it's set. // Try to use the document's 'openMode' if it's set.
openMode = doc.options?.openMode ?? 'default'; openMode = doc.options?.openMode ?? 'default';
} }
} }
const isPreFork = (openMode === 'fork'); const isPreFork = openMode === 'fork';
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; const isTemplate = doc.type === 'template' && (isFork || isPreFork);
const isSnapshot = Boolean(idParts.snapshotId);
const isTutorialTrunk = !isFork && doc.type === 'tutorial' && mode !== 'default';
const isTutorialFork = isFork && doc.type === 'tutorial';
const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork); const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork);
return { return {
...doc, ...doc,
isFork, isFork,
@ -459,6 +472,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
isSnapshot, isSnapshot,
isTutorialTrunk, isTutorialTrunk,
isTutorialFork, isTutorialFork,
isTemplate,
isReadonly: !isEditable, isReadonly: !isEditable,
idParts, idParts,
openMode, openMode,

@ -11,6 +11,7 @@ import {IHomePage} from 'app/common/gristUrls';
import {isLongerThan} from 'app/common/gutil'; import {isLongerThan} from 'app/common/gutil';
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Document, Organization, Workspace} from 'app/common/UserAPI'; import {Document, Organization, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import moment from 'moment'; import moment from 'moment';
@ -311,7 +312,9 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
// now, but it is good to show names to highlight the possibility of adding more. // now, but it is good to show names to highlight the possibility of adding more.
const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null; const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null;
this.singleWorkspace.set( this.singleWorkspace.set(
!!nonSupportWss && nonSupportWss.length === 1 && _isSingleWorkspaceMode(this._app) // The anon personal site always has 0 non-support workspaces.
nonSupportWss?.length === 0 ||
nonSupportWss?.length === 1 && _isSingleWorkspaceMode(this._app)
); );
}); });
} }
@ -357,6 +360,9 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
* Only fetches featured (pinned) templates on the All Documents page. * Only fetches featured (pinned) templates on the All Documents page.
*/ */
private async _maybeFetchTemplates(): Promise<Workspace[] | null> { private async _maybeFetchTemplates(): Promise<Workspace[] | null> {
const {templateOrg} = getGristConfig();
if (!templateOrg) { return null; }
const currentPage = this.currentPage.get(); const currentPage = this.currentPage.get();
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
if (!shouldFetchTemplates) { return null; } if (!shouldFetchTemplates) { return null; }
@ -366,10 +372,10 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
const onlyFeatured = currentPage === 'all'; const onlyFeatured = currentPage === 'all';
templateWss = await this._app.api.getTemplates(onlyFeatured); templateWss = await this._app.api.getTemplates(onlyFeatured);
} catch { } catch {
// If the org doesn't exist (404), return nothing and don't report error to user. reportError('Failed to load templates');
return null;
} }
if (this.isDisposed()) { return null; } if (this.isDisposed()) { return null; }
for (const ws of templateWss) { for (const ws of templateWss) {
for (const doc of ws.docs) { for (const doc of ws.docs) {
// Populate doc.workspace, which is used by DocMenu/PinnedDocs and // Populate doc.workspace, which is used by DocMenu/PinnedDocs and

@ -47,7 +47,7 @@ export class AccountPage extends Disposable {
panelWidth: Observable.create(this, 240), panelWidth: Observable.create(this, 240),
panelOpen, panelOpen,
hideOpener: true, hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), header: dom.create(AppHeader, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen), content: leftPanelBasic(this._appModel, panelOpen),
}, },
headerMain: this._buildHeaderMain(), headerMain: this._buildHeaderMain(),

@ -1,10 +1,10 @@
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {manageTeamUsers} from 'app/client/ui/OpenUserManager'; import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
import {createUserImage} from 'app/client/ui/UserImage'; import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport'; import * as viewport from 'app/client/ui/viewport';
import {primaryButton} from 'app/client/ui2018/buttons'; import {bigPrimaryButtonLink, primaryButtonLink} from 'app/client/ui2018/buttons';
import {mediaDeviceNotSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {mediaDeviceNotSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
@ -20,8 +20,12 @@ import {getGristConfig} from 'app/common/urlUtils';
const t = makeT('AccountWidget'); const t = makeT('AccountWidget');
/** /**
* Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in * Render the user-icon that opens the account menu.
* button instead. *
* When no user is logged in, render "Sign In" and "Sign Up" buttons.
*
* When no user is logged in and a template document is open, render a "Use This Template"
* button.
*/ */
export class AccountWidget extends Disposable { export class AccountWidget extends Disposable {
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
@ -30,20 +34,60 @@ export class AccountWidget extends Disposable {
public buildDom() { public buildDom() {
return cssAccountWidget( return cssAccountWidget(
dom.domComputed(this._appModel.currentValidUser, (user) => dom.domComputed(use => {
(user ? const isTemplate = Boolean(this._docPageModel && use(this._docPageModel.isTemplate));
cssUserIcon(createUserImage(user, 'medium', testId('user-icon')), const user = this._appModel.currentValidUser;
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), if (!user && isTemplate) {
) : return this._buildUseThisTemplateButton();
cssSignInButton(t("Sign in"), icon('Collapse'), testId('user-signin'), } else if (!user) {
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), return this._buildSignInAndSignUpButtons();
) } else {
) return this._buildAccountMenuButton(user);
), }
}),
testId('dm-account'), testId('dm-account'),
); );
} }
private _buildAccountMenuButton(user: FullUser|null) {
return cssUserIcon(
createUserImage(user, 'medium', testId('user-icon')),
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}),
);
}
private _buildSignInAndSignUpButtons() {
return [
cssSigninButton(t('Sign In'),
cssSigninButton.cls('-secondary'),
dom.attr('href', use => {
// Keep the redirect param of the login URL fresh.
use(urlState().state);
return getLoginUrl();
}),
testId('user-sign-in'),
),
cssSigninButton(t('Sign Up'),
dom.attr('href', use => {
// Keep the redirect param of the signup URL fresh.
use(urlState().state);
return getSignupUrl();
}),
testId('user-sign-up'),
),
];
}
private _buildUseThisTemplateButton() {
return cssUseThisTemplateButton(t('Use This Template'),
dom.attr('href', use => {
// Keep the redirect param of the login/signup URL fresh.
use(urlState().state);
return getLoginOrSignupUrl();
}),
testId('dm-account-use-this-template'),
);
}
/** /**
* Renders the content of the account menu, with a list of available orgs, settings, and sign-out. * Renders the content of the account menu, with a list of available orgs, settings, and sign-out.
@ -187,6 +231,7 @@ export class AccountWidget extends Disposable {
} }
const cssAccountWidget = styled('div', ` const cssAccountWidget = styled('div', `
display: flex;
margin-right: 16px; margin-right: 16px;
white-space: nowrap; white-space: nowrap;
`); `);
@ -251,8 +296,22 @@ const cssSmallDeviceOnly = styled(menuItem, `
} }
`); `);
const cssSignInButton = styled(primaryButton, ` const cssSigninButton = styled(bigPrimaryButtonLink, `
display: flex; display: flex;
align-items: center;
font-weight: 700;
min-height: unset;
height: 36px;
padding: 8px 16px 8px 16px;
font-size: ${vars.mediumFontSize};
&-secondary, &-secondary:hover {
background-color: transparent;
border-color: transparent;
color: ${theme.text};
}
`);
const cssUseThisTemplateButton = styled(primaryButtonLink, `
margin: 8px; margin: 8px;
gap: 4px;
`); `);

@ -4,14 +4,15 @@ import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
import * as version from 'app/common/version'; import * as version from 'app/common/version';
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {isTemplatesOrg, Organization} from 'app/common/UserAPI'; import {commonUrls} from 'app/common/gristUrls';
import {getOrgName, isTemplatesOrg, Organization} from 'app/common/UserAPI';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
import {BindableValue, Disposable, dom, DomContents, styled} from 'grainjs'; import {Computed, Disposable, dom, DomContents, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
@ -28,46 +29,95 @@ const productPills: {[name: string]: string|null} = {
// Other plans are either personal, or grandfathered, or for testing. // Other plans are either personal, or grandfathered, or for testing.
}; };
interface AppLogoOrgNameAndLink {
name: string;
link: AppLogoLink;
org?: string;
href?: string;
}
type AppLogoLink = AppLogoOrgDomain | AppLogoHref;
interface AppLogoOrgDomain {
type: 'domain';
domain: string;
}
interface AppLogoHref {
type: 'href';
href: string;
}
export class AppHeader extends Disposable { export class AppHeader extends Disposable {
constructor(private _orgName: BindableValue<string>, private _appModel: AppModel, private _currentOrg = this._appModel.currentOrg;
private _docPageModel?: DocPageModel) {
/**
* The name and link of the site shown next to the logo.
*
* The last visited site is used, if known. Otherwise, the current site is used.
*/
private _appLogoOrg = Computed.create<AppLogoOrgNameAndLink>(this, (use) => {
const availableOrgs = use(this._appModel.topAppModel.orgs);
const currentOrgName = (this._appModel.currentOrgName ||
(this._docPageModel && use(this._docPageModel.currentOrgName))) ?? '';
const lastVisitedOrgDomain = use(this._appModel.lastVisitedOrgDomain);
return this._getAppLogoOrgNameAndLink({availableOrgs, currentOrgName, lastVisitedOrgDomain});
});
private _appLogoOrgName = Computed.create(this, this._appLogoOrg, (_use, {name}) => name);
private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, {link}) => link);
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
super(); super();
} }
public buildDom() { public buildDom() {
const productFlavor = getTheme(this._appModel.topAppModel.productFlavor); const productFlavor = getTheme(this._appModel.topAppModel.productFlavor);
const currentOrg = this._appModel.currentOrg;
return cssAppHeader( return cssAppHeader(
cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false), cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false),
// Show version when hovering over the application icon. dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(
// Include gitcommit when known. Cast version.gitcommit since, depending // Show version when hovering over the application icon.
// on how Grist is compiled, tsc may believe it to be a constant and // Include gitcommit when known. Cast version.gitcommit since, depending
// believe that testing it is unnecessary. // on how Grist is compiled, tsc may believe it to be a constant and
cssAppLogo( // believe that testing it is unnecessary.
{title: `Version ${version.version}` + {title: `Version ${version.version}` +
((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')}, ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')},
this._setHomePageUrl(), this._setHomePageUrl(orgLink),
testId('dm-logo') testId('dm-logo')
), )),
cssOrg( this._buildOrgLinkOrMenu(),
cssOrgName(dom.text(this._orgName), testId('dm-orgname')), );
productPill(currentOrg), }
this._orgName && cssDropdownIcon('Dropdown'),
private _buildOrgLinkOrMenu() {
const {currentValidUser, isPersonal, isTemplatesSite} = this._appModel;
if (!currentValidUser && (isPersonal || isTemplatesSite)) {
return cssOrgLink(
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
{href: commonUrls.templates},
testId('dm-org'),
);
} else {
return cssOrg(
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
productPill(this._currentOrg),
dom.maybe(this._appLogoOrgName, () => cssDropdownIcon('Dropdown')),
menu(() => [ menu(() => [
menuSubHeader( menuSubHeader(
this._appModel.isTeamSite ? t("Team Site") : t("Personal Site") this._appModel.isPersonal
+ (this._appModel.isLegacySite ? ` (${t("Legacy")})` : ''), ? t("Personal Site") + (this._appModel.isLegacySite ? ` (${t("Legacy")})` : '')
: t("Team Site"),
testId('orgmenu-title'), testId('orgmenu-title'),
), ),
menuItemLink(urlState().setLinkUrl({}), t("Home Page"), testId('orgmenu-home-page')), menuItemLink(urlState().setLinkUrl({}), t("Home Page"), testId('orgmenu-home-page')),
// Show 'Organization Settings' when on a home page of a valid org. // Show 'Organization Settings' when on a home page of a valid org.
(!this._docPageModel && currentOrg && !currentOrg.owner ? (!this._docPageModel && this._currentOrg && !this._currentOrg.owner ?
menuItem(() => manageTeamUsersApp(this._appModel), menuItem(() => manageTeamUsersApp(this._appModel),
'Manage Team', testId('orgmenu-manage-team'), 'Manage Team', testId('orgmenu-manage-team'),
dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) : dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) :
// Don't show on doc pages, or for personal orgs. // Don't show on doc pages, or for personal orgs.
null), null),
@ -77,16 +127,15 @@ export class AppHeader extends Disposable {
maybeAddSiteSwitcherSection(this._appModel), maybeAddSiteSwitcherSection(this._appModel),
], { placement: 'bottom-start' }), ], { placement: 'bottom-start' }),
testId('dm-org'), testId('dm-org'),
), );
); }
} }
private _setHomePageUrl() { private _setHomePageUrl(link: AppLogoLink) {
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get(); if (link.type === 'href') {
if (lastVisitedOrg) { return {href: link.href};
return urlState().setLinkUrl({org: lastVisitedOrg});
} else { } else {
return {href: getWelcomeHomeUrl()}; return urlState().setLinkUrl({org: link.domain});
} }
} }
@ -123,6 +172,50 @@ export class AppHeader extends Disposable {
return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'}));
} }
private _getAppLogoOrgNameAndLink(params: {
availableOrgs: Organization[],
currentOrgName: string,
lastVisitedOrgDomain: string|null,
}): AppLogoOrgNameAndLink {
const {
currentValidUser,
isPersonal,
isTemplatesSite,
} = this._appModel;
if (!currentValidUser && (isPersonal || isTemplatesSite)) {
// When signed out and not on a team site, link to the templates site.
return {
name: t('Grist Templates'),
link: {
type: 'href',
href: commonUrls.templates,
},
};
}
const {availableOrgs, currentOrgName, lastVisitedOrgDomain} = params;
if (lastVisitedOrgDomain) {
const lastVisitedOrg = availableOrgs.find(({domain}) => domain === lastVisitedOrgDomain);
if (lastVisitedOrg) {
return {
name: getOrgName(lastVisitedOrg),
link: {
type: 'domain',
domain: lastVisitedOrgDomain,
},
};
}
}
return {
name: currentOrgName ?? '',
link: {
type: 'href',
href: getWelcomeHomeUrl(),
},
};
}
} }
export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents { export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents {
@ -198,6 +291,31 @@ const cssOrg = styled('div', `
} }
`); `);
const cssOrgLink = styled('a', `
display: none;
flex-grow: 1;
align-items: center;
max-width: calc(100% - 48px);
cursor: pointer;
height: 100%;
font-weight: 500;
color: ${theme.text};
user-select: none;
&, &:hover, &:focus {
text-decoration: none;
}
&:hover {
color: ${theme.text};
background-color: ${theme.hover};
}
.${cssLeftPane.className}-open & {
display: flex;
}
`);
const cssOrgName = styled('div', ` const cssOrgName = styled('div', `
padding-left: 16px; padding-left: 16px;
padding-right: 8px; padding-right: 8px;

@ -106,7 +106,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
panelWidth: Observable.create(owner, 240), panelWidth: Observable.create(owner, 240),
panelOpen: leftPanelOpen, panelOpen: leftPanelOpen,
hideOpener: true, hideOpener: true,
header: dom.create(AppHeader, appModel.currentOrgName, appModel), header: dom.create(AppHeader, appModel),
content: createHomeLeftPane(leftPanelOpen, pageModel), content: createHomeLeftPane(leftPanelOpen, pageModel),
}, },
headerMain: createTopBarHome(appModel), headerMain: createTopBarHome(appModel),
@ -153,7 +153,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
leftPanel: { leftPanel: {
panelWidth: leftPanelWidth, panelWidth: leftPanelWidth,
panelOpen: leftPanelOpen, panelOpen: leftPanelOpen,
header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel, pageModel), header: dom.create(AppHeader, appModel, pageModel),
content: pageModel.createLeftPane(leftPanelOpen), content: pageModel.createLeftPane(leftPanelOpen),
}, },
rightPanel: { rightPanel: {

@ -18,6 +18,7 @@ import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/cli
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Workspace} from 'app/common/UserAPI'; import {Workspace} from 'app/common/UserAPI';
import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs'; import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs';
import {createHelpTools, cssLeftPanel, cssScrollPane, import {createHelpTools, cssLeftPanel, cssScrollPane,
@ -28,6 +29,7 @@ const t = makeT('HomeLeftPane');
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) { export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
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;
return cssContent( return cssContent(
dom.autoDispose(creating), dom.autoDispose(creating),
@ -109,14 +111,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
)), )),
cssTools( cssTools(
cssPageEntry( cssPageEntry(
dom.show(isFeatureEnabled("templates")), dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)),
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"), cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")), cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
urlState().setLinkUrl({homePage: "templates"}), urlState().setLinkUrl({homePage: "templates"}),
testId('dm-templates-page'), testId('dm-templates-page'),
), ),
), ),
cssPageEntry( isAnonymous ? null : cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"), cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"),
cssPageLink(cssPageIcon('RemoveBig'), cssLinkText(t("Trash")), cssPageLink(cssPageIcon('RemoveBig'), cssLinkText(t("Trash")),
urlState().setLinkUrl({homePage: "trash"}), urlState().setLinkUrl({homePage: "trash"}),

@ -47,7 +47,7 @@ export class SupportGristPage extends Disposable {
panelWidth: Observable.create(this, 240), panelWidth: Observable.create(this, 240),
panelOpen, panelOpen,
hideOpener: true, hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), header: dom.create(AppHeader, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen), content: leftPanelBasic(this._appModel, panelOpen),
}, },
headerMain: this._buildMainHeader(), headerMain: this._buildMainHeader(),

@ -24,6 +24,8 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style
const t = makeT('TopBar'); const t = makeT('TopBar');
export function createTopBarHome(appModel: AppModel) { export function createTopBarHome(appModel: AppModel) {
const isAnonymous = !appModel.currentValidUser;
return [ return [
cssFlexSpace(), cssFlexSpace(),
appModel.supportGristNudge.showButton(), appModel.supportGristNudge.showButton(),
@ -40,7 +42,7 @@ export function createTopBarHome(appModel: AppModel) {
), ),
buildLanguageMenu(appModel), buildLanguageMenu(appModel),
buildNotifyMenuButton(appModel.notifier, appModel), isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)), dom('div', dom.create(AccountWidget, appModel)),
]; ];
} }
@ -78,6 +80,8 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
return !use(undoStack.isDisabled); return !use(undoStack.isDisabled);
}); });
const isAnonymous = !pageModel.appModel.currentValidUser;
return [ return [
// TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay // TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay
// showing of breadcrumbs until gristDoc is loaded. // showing of breadcrumbs until gristDoc is loaded.
@ -96,6 +100,8 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)),
isSnapshot: pageModel.isSnapshot, isSnapshot: pageModel.isSnapshot,
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
isTemplate: pageModel.isTemplate,
isAnonymous,
}) })
) )
), ),
@ -121,23 +127,21 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
const model = use(searchModelObs); const model = use(searchModelObs);
return model && use(moduleObs)?.searchBar(model, makeTestId('test-tb-search-')); return model && use(moduleObs)?.searchBar(model, makeTestId('test-tb-search-'));
}), }),
dom.maybe(use => !(use(pageModel.isTemplate) && isAnonymous), () => [
buildShareMenuButton(pageModel), buildShareMenuButton(pageModel),
dom.maybe(use =>
dom.maybe(use => (
( use(pageModel.gristDoc)
use(pageModel.gristDoc) && !use(use(pageModel.gristDoc)!.isReadonly)
&& !use(use(pageModel.gristDoc)!.isReadonly) && use(COMMENTS())
&& use(COMMENTS()) ),
() => buildShowDiscussionButton(pageModel)),
dom.update(
buildNotifyMenuButton(appModel.notifier, appModel),
cssHideForNarrowScreen.cls(''),
), ),
() => buildShowDiscussionButton(pageModel)), ]),
dom('div', dom.create(AccountWidget, appModel, pageModel)),
dom.update(
buildNotifyMenuButton(appModel.notifier, appModel),
cssHideForNarrowScreen.cls(''),
),
dom('div', dom.create(AccountWidget, appModel, pageModel))
]; ];
} }

@ -50,7 +50,7 @@ export class WelcomePage extends Disposable {
panelWidth: Observable.create(this, 240), panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false), panelOpen: Observable.create(this, false),
hideOpener: true, hideOpener: true,
header: dom.create(AppHeader, '', this._appModel), header: dom.create(AppHeader, this._appModel),
content: null, content: null,
}, },
headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)],

@ -110,7 +110,7 @@ function pagePanelsError(appModel: AppModel, header: string, content: DomElement
panelWidth: observable(240), panelWidth: observable(240),
panelOpen, panelOpen,
hideOpener: true, hideOpener: true,
header: dom.create(AppHeader, appModel.currentOrgName, appModel), header: dom.create(AppHeader, appModel),
content: leftPanelBasic(appModel, panelOpen), content: leftPanelBasic(appModel, panelOpen),
}, },
headerMain: createTopBarHome(appModel), headerMain: createTopBarHome(appModel),

@ -100,10 +100,13 @@ export function docBreadcrumbs(
isRecoveryMode: Observable<boolean>, isRecoveryMode: Observable<boolean>,
isSnapshot?: Observable<boolean>, isSnapshot?: Observable<boolean>,
isPublic?: Observable<boolean>, isPublic?: Observable<boolean>,
isTemplate?: Observable<boolean>,
isAnonymous?: boolean,
} }
): Element { ): Element {
const shouldShowWorkspace = !(options.isTemplate && options.isAnonymous);
return cssBreadcrumbs( return cssBreadcrumbs(
dom.domComputed<[boolean, PartialWorkspace|null]>( !shouldShowWorkspace ? null : dom.domComputed<[boolean, PartialWorkspace|null]>(
(use) => [use(options.isBareFork), use(workspace)], (use) => [use(options.isBareFork), use(workspace)],
([isBareFork, ws]) => { ([isBareFork, ws]) => {
if (isBareFork || !ws) { return null; } if (isBareFork || !ws) { return null; }

@ -20,7 +20,7 @@ import {
WebhookSummaryCollection, WebhookSummaryCollection,
WebhookUpdate WebhookUpdate
} from 'app/common/Triggers'; } from 'app/common/Triggers';
import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {addCurrentOrgToPath, getGristConfig} from 'app/common/urlUtils';
import omitBy from 'lodash/omitBy'; import omitBy from 'lodash/omitBy';
@ -92,11 +92,14 @@ export function getOrgName(org: Organization): string {
} }
/** /**
* Returns whether the given org is the templates org, which contains the public templates. * Returns whether the given org is the templates org, which contains the public
* templates and tutorials.
*/ */
export function isTemplatesOrg(org: Organization): boolean { export function isTemplatesOrg(org: {domain: Organization['domain']}|null): boolean {
// TODO: It would be nice to have a more robust way to detect the templates org. if (!org) { return false; }
return org.domain === 'templates' || org.domain === 'templates-s';
const {templateOrg} = getGristConfig();
return org.domain === templateOrg;
} }
export type WorkspaceProperties = CommonProperties; export type WorkspaceProperties = CommonProperties;
@ -117,7 +120,7 @@ export interface Workspace extends WorkspaceProperties {
isSupportWorkspace?: boolean; isSupportWorkspace?: boolean;
} }
export type DocumentType = 'tutorial'; export type DocumentType = 'tutorial'|'template';
// Non-core options for a document. // Non-core options for a document.
// "Non-core" means bundled into a single options column in the database. // "Non-core" means bundled into a single options column in the database.

@ -80,6 +80,7 @@ export const commonUrls = {
plans: "https://www.getgrist.com/pricing", plans: "https://www.getgrist.com/pricing",
sproutsProgram: "https://www.getgrist.com/sprouts-program", sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact", contact: "https://www.getgrist.com/contact",
templates: 'https://www.getgrist.com/templates',
community: 'https://community.getgrist.com', community: 'https://community.getgrist.com',
functions: 'https://support.getgrist.com/functions', functions: 'https://support.getgrist.com/functions',
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
@ -647,6 +648,9 @@ export interface GristLoadConfig {
// The Grist deployment type (e.g. core, enterprise). // The Grist deployment type (e.g. core, enterprise).
deploymentType?: GristDeploymentType; deploymentType?: GristDeploymentType;
// The org containing public templates and tutorials.
templateOrg?: string|null;
} }
export const Features = StringUnion( export const Features = StringUnion(

@ -14,17 +14,13 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {getTemplateOrg} from 'app/server/lib/sendAppPage';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {User} from './entity/User'; import {User} from './entity/User';
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
import {getCookieDomain} from 'app/server/lib/gristSessions'; import {getCookieDomain} from 'app/server/lib/gristSessions';
// Special public organization that contains examples and templates.
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
`templates-${process.env.GRIST_ID_PREFIX}` :
'templates';
// exposed for testing purposes // exposed for testing purposes
export const Deps = { export const Deps = {
apiKeyGenerator: () => crypto.randomBytes(20).toString('hex') apiKeyGenerator: () => crypto.randomBytes(20).toString('hex')
@ -247,10 +243,15 @@ export class ApiServer {
// GET /api/templates/ // GET /api/templates/
// Get all templates (or only featured templates if `onlyFeatured` is set). // Get all templates (or only featured templates if `onlyFeatured` is set).
this._app.get('/api/templates/', expressWrap(async (req, res) => { this._app.get('/api/templates/', expressWrap(async (req, res) => {
const templateOrg = getTemplateOrg();
if (!templateOrg) {
throw new ApiError('Template org is not configured', 500);
}
const onlyFeatured = isParameterOn(req.query.onlyFeatured); const onlyFeatured = isParameterOn(req.query.onlyFeatured);
const query = await this._dbManager.getOrgWorkspaces( const query = await this._dbManager.getOrgWorkspaces(
{...getScope(req), showOnlyPinned: onlyFeatured}, {...getScope(req), showOnlyPinned: onlyFeatured},
TEMPLATES_ORG_DOMAIN templateOrg
); );
return sendReply(req, res, query); return sendReply(req, res, query);
})); }));

@ -79,7 +79,6 @@ import {Document as APIDocument, DocReplacementOptions, DocState, DocStateCompar
import {convertFromColumn} from 'app/common/ValueConverter'; import {convertFromColumn} from 'app/common/ValueConverter';
import {guessColInfo} from 'app/common/ValueGuesser'; import {guessColInfo} from 'app/common/ValueGuesser';
import {parseUserAction} from 'app/common/ValueParser'; import {parseUserAction} from 'app/common/ValueParser';
import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer';
import {Document} from 'app/gen-server/entity/Document'; import {Document} from 'app/gen-server/entity/Document';
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
@ -98,6 +97,7 @@ import log from 'app/server/lib/log';
import {LogMethods} from "app/server/lib/LogMethods"; import {LogMethods} from "app/server/lib/LogMethods";
import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox'; import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox';
import {DocRequests} from 'app/server/lib/Requests'; import {DocRequests} from 'app/server/lib/Requests';
import {getTemplateOrg} from 'app/server/lib/sendAppPage';
import {shortDesc} from 'app/server/lib/shortDesc'; import {shortDesc} from 'app/server/lib/shortDesc';
import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader'; import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader';
import {DocTriggers} from "app/server/lib/Triggers"; import {DocTriggers} from "app/server/lib/Triggers";
@ -1402,8 +1402,9 @@ export class ActiveDoc extends EventEmitter {
await dbManager.forkDoc(userId, doc, forkIds.forkId); await dbManager.forkDoc(userId, doc, forkIds.forkId);
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.) // TODO: Remove the right side once all template docs have their type set to "template".
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; const isTemplate = doc.type === 'template' ||
(doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial');
this.logTelemetryEvent(docSession, 'documentForked', { this.logTelemetryEvent(docSession, 'documentForked', {
limited: { limited: {
forkIdDigest: forkIds.forkId, forkIdDigest: forkIds.forkId,

@ -12,7 +12,6 @@ import {removeTrailingSlash} from 'app/common/gutil';
import {LocalPlugin} from "app/common/plugin"; import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI'; import {Document as APIDocument} from 'app/common/UserAPI';
import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer';
import {Document} from "app/gen-server/entity/Document"; import {Document} from "app/gen-server/entity/Document";
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
@ -24,7 +23,7 @@ import {getCookieDomain} from 'app/server/lib/gristSessions';
import {getAssignmentId} from 'app/server/lib/idUtils'; import {getAssignmentId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils'; import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions} from 'app/server/lib/sendAppPage'; import {getTemplateOrg, ISendAppPageOptions} from 'app/server/lib/sendAppPage';
export interface AttachOptions { export interface AttachOptions {
app: express.Application; // Express app to which to add endpoints app: express.Application; // Express app to which to add endpoints
@ -304,8 +303,9 @@ export function attachAppEndpoint(options: AttachOptions): void {
const isPublic = ((doc as unknown) as APIDocument).public ?? false; const isPublic = ((doc as unknown) as APIDocument).public ?? false;
const isSnapshot = Boolean(parseUrlId(urlId).snapshotId); const isSnapshot = Boolean(parseUrlId(urlId).snapshotId);
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.) // TODO: Remove the right side once all template docs have their type set to "template".
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; const isTemplate = doc.type === 'template' ||
(doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial');
if (isPublic || isTemplate) { if (isPublic || isTemplate) {
gristServer.getTelemetry().logEvent('documentOpened', { gristServer.getTelemetry().logEvent('documentOpened', {
limited: { limited: {

@ -3,6 +3,7 @@ import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager'; import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
import {appSettings} from 'app/server/lib/AppSettings';
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer'; import {GristServer} from 'app/server/lib/GristServer';
@ -78,6 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
telemetry: server?.getTelemetry().getTelemetryConfig(), telemetry: server?.getTelemetry().getTelemetryConfig(),
deploymentType: server?.getDeploymentType(), deploymentType: server?.getDeploymentType(),
templateOrg: getTemplateOrg(),
...extra, ...extra,
}; };
} }
@ -152,6 +154,18 @@ export function makeSendAppPage(opts: {
}; };
} }
export function getTemplateOrg() {
let org = appSettings.section('templates').flag('org').readString({
envVar: 'GRIST_TEMPLATE_ORG',
});
if (!org) { return null; }
if (process.env.GRIST_ID_PREFIX) {
org += `-${process.env.GRIST_ID_PREFIX}`;
}
return org;
}
function shouldSupportAnon() { function shouldSupportAnon() {
// Enable UI for anonymous access if a flag is explicitly set in the environment // Enable UI for anonymous access if a flag is explicitly set in the environment
return process.env.GRIST_SUPPORT_ANON === "true"; return process.env.GRIST_SUPPORT_ANON === "true";

@ -139,6 +139,12 @@ describe("DuplicateDocument", function() {
await gu.session().teamSite2.createHomeApi().updateOrgPermissions('current', {users: { await gu.session().teamSite2.createHomeApi().updateOrgPermissions('current', {users: {
[session2.email]: 'owners', [session2.email]: 'owners',
}}); }});
// Reset tracking of the last visited site. We seem to need this now to get consistent
// behavior across Jenkins and local test runs. (May have something to do with newer
// versions of chromedriver and headless Chrome.)
await driver.executeScript('window.sessionStorage.clear();');
await session2.login(); await session2.login();
await session2.loadDoc(`/doc/${urlId}`); await session2.loadDoc(`/doc/${urlId}`);

@ -5,7 +5,7 @@ import { EnvironmentSnapshot } from 'test/server/testUtils';
describe('Features', function () { describe('Features', function () {
this.timeout(20000); this.timeout(20000);
setupTestSuite(); setupTestSuite({samples: true});
let session: gu.Session; let session: gu.Session;
let oldEnv: EnvironmentSnapshot; let oldEnv: EnvironmentSnapshot;
@ -21,6 +21,7 @@ describe('Features', function () {
}); });
it('can be enabled with the GRIST_UI_FEATURES env variable', async function () { it('can be enabled with the GRIST_UI_FEATURES env variable', async function () {
process.env.GRIST_TEMPLATE_ORG = 'templates';
process.env.GRIST_UI_FEATURES = 'helpCenter,templates'; process.env.GRIST_UI_FEATURES = 'helpCenter,templates';
await server.restart(); await server.restart();
await session.loadDocMenu('/'); await session.loadDocMenu('/');

@ -89,12 +89,12 @@ describe("Fork", function() {
for (const mode of ['anonymous', 'logged in']) { for (const mode of ['anonymous', 'logged in']) {
for (const content of ['empty', 'imported']) { for (const content of ['empty', 'imported']) {
it(`can create an ${content} unsaved document when ${mode}`, async function() { it(`can create an ${content} unsaved document when ${mode}`, async function() {
let name: string; let visitedSites: string[];
if (mode === 'anonymous') { if (mode === 'anonymous') {
name = '@Guest'; visitedSites = ['Grist Templates'];
await personal.anon.login(); await personal.anon.login();
} else { } else {
name = `@${personal.name}`; visitedSites = ['Test Grist', `@${personal.name}`];
await personal.login(); await personal.login();
} }
const anonApi = personal.anon.createHomeApi(); const anonApi = personal.anon.createHomeApi();
@ -106,8 +106,10 @@ describe("Fork", function() {
await gu.dismissWelcomeTourIfNeeded(); await gu.dismissWelcomeTourIfNeeded();
// check that the tag is there // check that the tag is there
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true); assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
// check that the org name area is showing the user (not @Support). // check that the org name area is showing one of the last visited sites. this is
assert.equal(await driver.find('.test-dm-org').getText(), name); // an imprecise check; doing an assert.equal instead is possible, but would require
// changing this test significantly.
assert.include(visitedSites, await driver.find('.test-dm-org').getText());
if (content === 'imported') { if (content === 'imported') {
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '999'); assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '999');
} else { } else {
@ -331,13 +333,13 @@ describe("Fork", function() {
// Check others without view access to trunk cannot see fork // Check others without view access to trunk cannot see fork
await team.user('user2').login(); await team.user('user2').login();
await driver.get(forkUrl); await driver.get(forkUrl);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/); assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
await server.removeLogin(); await server.removeLogin();
await driver.get(forkUrl); await driver.get(forkUrl);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/); assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
}); });
it('fails to create forks with inconsistent user id', async function() { it('fails to create forks with inconsistent user id', async function() {
@ -364,8 +366,8 @@ describe("Fork", function() {
// new doc user2 has no access granted via the doc, or // new doc user2 has no access granted via the doc, or
// workspace, or org). // workspace, or org).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/); assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
// Same, but as an anonymous user. // Same, but as an anonymous user.
const anonSession = await altSession.anon.login(); const anonSession = await altSession.anon.login();
@ -375,8 +377,8 @@ describe("Fork", function() {
// A new doc cannot be created either (because of access mismatch). // A new doc cannot be created either (because of access mismatch).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/); assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
// Now as a user who *is* allowed to create the fork. // Now as a user who *is* allowed to create the fork.
// But doc forks cannot be casually created this way anymore, so it still doesn't work. // But doc forks cannot be casually created this way anymore, so it still doesn't work.

@ -10,6 +10,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils';
describe('HomeIntro', function() { describe('HomeIntro', function() {
this.timeout(40000); this.timeout(40000);
setupTestSuite({samples: true}); setupTestSuite({samples: true});
gu.withEnvironmentSnapshot({'GRIST_TEMPLATE_ORG': 'templates'});
describe("Anonymous on merged-org", function() { describe("Anonymous on merged-org", function() {
it('should show welcome for anonymous user', async function() { it('should show welcome for anonymous user', async function() {

@ -116,9 +116,8 @@ export class HomeUtil {
// When running against an external server, we log in through the Grist login page. // When running against an external server, we log in through the Grist login page.
await this.driver.get(this.server.getUrl(org, "")); await this.driver.get(this.server.getUrl(org, ""));
if (!await this.isOnLoginPage()) { if (!await this.isOnLoginPage()) {
// Explicitly click sign-in link if necessary. // Explicitly click Sign In button if necessary.
await this.driver.findWait('.test-user-signin', 4000).click(); await this.driver.findWait('.test-user-sign-in', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
} }
// Fill the login form (either test or Grist). // Fill the login form (either test or Grist).
@ -382,8 +381,7 @@ export class HomeUtil {
await this.deleteCurrentUser(); await this.deleteCurrentUser();
await this.removeLogin(org); await this.removeLogin(org);
await this.driver.get(this.server.getUrl(org, "")); await this.driver.get(this.server.getUrl(org, ""));
await this.driver.findWait('.test-user-signin', 4000).click(); await this.driver.findWait('.test-user-sign-in', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
await this.checkLoginPage(); await this.checkLoginPage();
// Fill the login form (either test or Grist). // Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage()) { if (await this.isOnTestLoginPage()) {

Loading…
Cancel
Save