mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							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_FORCE_LOGIN    | when set to 'true' disables anonymous access | ||||
| 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_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. | ||||
|  | ||||
| @ -13,7 +13,7 @@ import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; | ||||
| import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; | ||||
| import {OrgUsageSummary} from 'app/common/DocUsage'; | ||||
| 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 {LocalPlugin} from 'app/common/plugin'; | ||||
| import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs'; | ||||
| @ -23,7 +23,7 @@ import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs, | ||||
|         ThemePrefsChecker} from 'app/common/ThemePrefs'; | ||||
| import {getThemeColors} from 'app/common/Themes'; | ||||
| 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 {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; | ||||
| 
 | ||||
| @ -93,6 +93,7 @@ export interface AppModel { | ||||
|   isPersonal: boolean;                  // Is it a personal site?
 | ||||
|   isTeamSite: boolean;                  // Is it a team 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.
 | ||||
|   lastVisitedOrgDomain: Observable<string|null>; | ||||
| 
 | ||||
| @ -249,6 +250,7 @@ export class AppModelImpl extends Disposable implements AppModel { | ||||
|   public readonly isPersonal = Boolean(this.currentOrg?.owner); | ||||
|   public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal; | ||||
|   public readonly isLegacySite = Boolean(this.currentProduct && isLegacyPlan(this.currentProduct.name)); | ||||
|   public readonly isTemplatesSite = isTemplatesOrg(this.currentOrg); | ||||
| 
 | ||||
|   public readonly userPrefsObs = getUserPrefsObs(this); | ||||
|   public readonly themePrefs = getUserPrefObs(this.userPrefsObs, 'theme', { | ||||
| @ -325,12 +327,8 @@ export class AppModelImpl extends Disposable implements AppModel { | ||||
|       this.behavioralPromptsManager.reset(); | ||||
|     }; | ||||
| 
 | ||||
|     this.autoDispose(subscribe(urlState().state, async (_use, {doc, org}) => { | ||||
|       // Keep track of the last valid org domain the user visited, ignoring those
 | ||||
|       // with a document id in the URL.
 | ||||
|       if (!this.currentOrg || doc) { return; } | ||||
| 
 | ||||
|       this.lastVisitedOrgDomain.set(org ?? null); | ||||
|     this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => { | ||||
|       this._updateLastVisitedOrgDomain(s, orgs); | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
| @ -404,6 +402,23 @@ export class AppModelImpl extends Disposable implements AppModel { | ||||
|     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. | ||||
|    */ | ||||
|  | ||||
| @ -42,6 +42,7 @@ export interface DocInfo extends Document { | ||||
|   isSnapshot: boolean; | ||||
|   isTutorialTrunk: boolean; | ||||
|   isTutorialFork: boolean; | ||||
|   isTemplate: boolean; | ||||
|   idParts: UrlIdParts; | ||||
|   openMode: OpenDocMode; | ||||
| } | ||||
| @ -76,6 +77,7 @@ export interface DocPageModel { | ||||
|   isSnapshot: Observable<boolean>; | ||||
|   isTutorialTrunk: Observable<boolean>; | ||||
|   isTutorialFork: Observable<boolean>; | ||||
|   isTemplate: Observable<boolean>; | ||||
| 
 | ||||
|   importSources: ImportSource[]; | ||||
| 
 | ||||
| @ -131,6 +133,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { | ||||
|     (use, doc) => doc ? doc.isTutorialTrunk : false); | ||||
|   public readonly isTutorialFork = Computed.create(this, this.currentDoc, | ||||
|     (use, doc) => doc ? doc.isTutorialFork : false); | ||||
|   public readonly isTemplate = Computed.create(this, this.currentDoc, | ||||
|     (use, doc) => doc ? doc.isTemplate : false); | ||||
| 
 | ||||
|   public readonly importSources: ImportSource[] = []; | ||||
| 
 | ||||
| @ -431,24 +435,33 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: | ||||
| function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { | ||||
|   const idParts = parseUrlId(doc.urlId || doc.id); | ||||
|   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; | ||||
|   if (!openMode) { | ||||
|     if (isFork) { | ||||
|       // Ignore the document 'openMode' setting if the doc is an unsaved fork.
 | ||||
|     if (isFork || isTutorialTrunk || isTutorialFork) { | ||||
|       // 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'; | ||||
|     } else if (!isFork && doc.type === 'template') { | ||||
|       // Templates should always open in fork mode by default.
 | ||||
|       openMode = 'fork'; | ||||
|     } else { | ||||
|       // Try to use the document's 'openMode' if it's set.
 | ||||
|       openMode = doc.options?.openMode ?? 'default'; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const isPreFork = (openMode === 'fork'); | ||||
|   const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; | ||||
|   const isSnapshot = Boolean(idParts.snapshotId); | ||||
|   const isTutorialTrunk = !isFork && doc.type === 'tutorial' && mode !== 'default'; | ||||
|   const isTutorialFork = isFork && doc.type === 'tutorial'; | ||||
|   const isPreFork = openMode === 'fork'; | ||||
|   const isTemplate = doc.type === 'template' && (isFork || isPreFork); | ||||
|   const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork); | ||||
| 
 | ||||
|   return { | ||||
|     ...doc, | ||||
|     isFork, | ||||
| @ -459,6 +472,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { | ||||
|     isSnapshot, | ||||
|     isTutorialTrunk, | ||||
|     isTutorialFork, | ||||
|     isTemplate, | ||||
|     isReadonly: !isEditable, | ||||
|     idParts, | ||||
|     openMode, | ||||
|  | ||||
| @ -11,6 +11,7 @@ import {IHomePage} from 'app/common/gristUrls'; | ||||
| import {isLongerThan} from 'app/common/gutil'; | ||||
| import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; | ||||
| import * as roles from 'app/common/roles'; | ||||
| import {getGristConfig} from 'app/common/urlUtils'; | ||||
| import {Document, Organization, Workspace} from 'app/common/UserAPI'; | ||||
| import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; | ||||
| 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.
 | ||||
|       const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null; | ||||
|       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. | ||||
|    */ | ||||
|   private async _maybeFetchTemplates(): Promise<Workspace[] | null> { | ||||
|     const {templateOrg} = getGristConfig(); | ||||
|     if (!templateOrg) { return null; } | ||||
| 
 | ||||
|     const currentPage = this.currentPage.get(); | ||||
|     const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); | ||||
|     if (!shouldFetchTemplates) { return null; } | ||||
| @ -366,10 +372,10 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings | ||||
|       const onlyFeatured = currentPage === 'all'; | ||||
|       templateWss = await this._app.api.getTemplates(onlyFeatured); | ||||
|     } catch { | ||||
|       // If the org doesn't exist (404), return nothing and don't report error to user.
 | ||||
|       return null; | ||||
|       reportError('Failed to load templates'); | ||||
|     } | ||||
|     if (this.isDisposed()) { return null; } | ||||
| 
 | ||||
|     for (const ws of templateWss) { | ||||
|       for (const doc of ws.docs) { | ||||
|         // Populate doc.workspace, which is used by DocMenu/PinnedDocs and
 | ||||
|  | ||||
| @ -47,7 +47,7 @@ export class AccountPage extends Disposable { | ||||
|         panelWidth: Observable.create(this, 240), | ||||
|         panelOpen, | ||||
|         hideOpener: true, | ||||
|         header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), | ||||
|         header: dom.create(AppHeader, this._appModel), | ||||
|         content: leftPanelBasic(this._appModel, panelOpen), | ||||
|       }, | ||||
|       headerMain: this._buildHeaderMain(), | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| import {AppModel} from 'app/client/models/AppModel'; | ||||
| 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 {createUserImage} from 'app/client/ui/UserImage'; | ||||
| 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 {icon} from 'app/client/ui2018/icons'; | ||||
| 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'); | ||||
| 
 | ||||
| /** | ||||
|  * Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in | ||||
|  * button instead. | ||||
|  * Render the user-icon that opens the account menu. | ||||
|  * | ||||
|  * 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 { | ||||
|   constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { | ||||
| @ -30,20 +34,60 @@ export class AccountWidget extends Disposable { | ||||
| 
 | ||||
|   public buildDom() { | ||||
|     return cssAccountWidget( | ||||
|       dom.domComputed(this._appModel.currentValidUser, (user) => | ||||
|         (user ? | ||||
|           cssUserIcon(createUserImage(user, 'medium', testId('user-icon')), | ||||
|             menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), | ||||
|           ) : | ||||
|           cssSignInButton(t("Sign in"), icon('Collapse'), testId('user-signin'), | ||||
|             menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), | ||||
|           ) | ||||
|         ) | ||||
|       ), | ||||
|       dom.domComputed(use => { | ||||
|         const isTemplate = Boolean(this._docPageModel && use(this._docPageModel.isTemplate)); | ||||
|         const user = this._appModel.currentValidUser; | ||||
|         if (!user && isTemplate) { | ||||
|           return this._buildUseThisTemplateButton(); | ||||
|         } else if (!user) { | ||||
|           return this._buildSignInAndSignUpButtons(); | ||||
|         } else { | ||||
|           return this._buildAccountMenuButton(user); | ||||
|         } | ||||
|       }), | ||||
|       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. | ||||
| @ -187,6 +231,7 @@ export class AccountWidget extends Disposable { | ||||
| } | ||||
| 
 | ||||
| const cssAccountWidget = styled('div', ` | ||||
|   display: flex; | ||||
|   margin-right: 16px; | ||||
|   white-space: nowrap; | ||||
| `);
 | ||||
| @ -251,8 +296,22 @@ const cssSmallDeviceOnly = styled(menuItem, ` | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssSignInButton = styled(primaryButton, ` | ||||
| const cssSigninButton = styled(bigPrimaryButtonLink, ` | ||||
|   display: flex; | ||||
|   margin: 8px; | ||||
|   gap: 4px; | ||||
|   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; | ||||
| `);
 | ||||
|  | ||||
| @ -4,14 +4,15 @@ import {cssLeftPane} from 'app/client/ui/PagePanels'; | ||||
| import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import * as version from 'app/common/version'; | ||||
| 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 {icon} from 'app/client/ui2018/icons'; | ||||
| import {DocPageModel} from 'app/client/models/DocPageModel'; | ||||
| import * as roles from 'app/common/roles'; | ||||
| import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; | ||||
| 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 {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.
 | ||||
| }; | ||||
| 
 | ||||
| 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 { | ||||
|   constructor(private _orgName: BindableValue<string>, private _appModel: AppModel, | ||||
|               private _docPageModel?: DocPageModel) { | ||||
|   private _currentOrg = this._appModel.currentOrg; | ||||
| 
 | ||||
|   /** | ||||
|    * 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(); | ||||
|   } | ||||
| 
 | ||||
|   public buildDom() { | ||||
|     const productFlavor = getTheme(this._appModel.topAppModel.productFlavor); | ||||
| 
 | ||||
|     const currentOrg = this._appModel.currentOrg; | ||||
| 
 | ||||
|     return cssAppHeader( | ||||
|       cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false), | ||||
|       // Show version when hovering over the application icon.
 | ||||
|       // Include gitcommit when known. Cast version.gitcommit since, depending
 | ||||
|       // on how Grist is compiled, tsc may believe it to be a constant and
 | ||||
|       // believe that testing it is unnecessary.
 | ||||
|       cssAppLogo( | ||||
|       dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo( | ||||
|         // Show version when hovering over the application icon.
 | ||||
|         // Include gitcommit when known. Cast version.gitcommit since, depending
 | ||||
|         // on how Grist is compiled, tsc may believe it to be a constant and
 | ||||
|         // believe that testing it is unnecessary.
 | ||||
|         {title: `Version ${version.version}` + | ||||
|           ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')}, | ||||
|         this._setHomePageUrl(), | ||||
|         this._setHomePageUrl(orgLink), | ||||
|         testId('dm-logo') | ||||
|       ), | ||||
|       cssOrg( | ||||
|         cssOrgName(dom.text(this._orgName), testId('dm-orgname')), | ||||
|         productPill(currentOrg), | ||||
|         this._orgName && cssDropdownIcon('Dropdown'), | ||||
|       )), | ||||
|       this._buildOrgLinkOrMenu(), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   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(() => [ | ||||
|           menuSubHeader( | ||||
|             this._appModel.isTeamSite ? t("Team Site") : t("Personal Site") | ||||
|               + (this._appModel.isLegacySite ? ` (${t("Legacy")})` : ''), | ||||
|             this._appModel.isPersonal | ||||
|               ? t("Personal Site") + (this._appModel.isLegacySite ? ` (${t("Legacy")})` : '') | ||||
|               : t("Team Site"), | ||||
|             testId('orgmenu-title'), | ||||
|           ), | ||||
|           menuItemLink(urlState().setLinkUrl({}), t("Home Page"), testId('orgmenu-home-page')), | ||||
| 
 | ||||
|           // 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), | ||||
|               '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.
 | ||||
|             null), | ||||
| 
 | ||||
| @ -77,16 +127,15 @@ export class AppHeader extends Disposable { | ||||
|           maybeAddSiteSwitcherSection(this._appModel), | ||||
|         ], { placement: 'bottom-start' }), | ||||
|         testId('dm-org'), | ||||
|       ), | ||||
|     ); | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private _setHomePageUrl() { | ||||
|     const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get(); | ||||
|     if (lastVisitedOrg) { | ||||
|       return urlState().setLinkUrl({org: lastVisitedOrg}); | ||||
|   private _setHomePageUrl(link: AppLogoLink) { | ||||
|     if (link.type === 'href') { | ||||
|       return {href: link.href}; | ||||
|     } 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'})); | ||||
|   } | ||||
| 
 | ||||
|   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 { | ||||
| @ -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', ` | ||||
|   padding-left: 16px; | ||||
|   padding-right: 8px; | ||||
|  | ||||
| @ -106,7 +106,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { | ||||
|       panelWidth: Observable.create(owner, 240), | ||||
|       panelOpen: leftPanelOpen, | ||||
|       hideOpener: true, | ||||
|       header: dom.create(AppHeader, appModel.currentOrgName, appModel), | ||||
|       header: dom.create(AppHeader, appModel), | ||||
|       content: createHomeLeftPane(leftPanelOpen, pageModel), | ||||
|     }, | ||||
|     headerMain: createTopBarHome(appModel), | ||||
| @ -153,7 +153,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) | ||||
|     leftPanel: { | ||||
|       panelWidth: leftPanelWidth, | ||||
|       panelOpen: leftPanelOpen, | ||||
|       header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel, pageModel), | ||||
|       header: dom.create(AppHeader, appModel, pageModel), | ||||
|       content: pageModel.createLeftPane(leftPanelOpen), | ||||
|     }, | ||||
|     rightPanel: { | ||||
|  | ||||
| @ -18,6 +18,7 @@ import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/cli | ||||
| import {confirmModal} from 'app/client/ui2018/modals'; | ||||
| import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; | ||||
| import * as roles from 'app/common/roles'; | ||||
| import {getGristConfig} from 'app/common/urlUtils'; | ||||
| import {Workspace} from 'app/common/UserAPI'; | ||||
| import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs'; | ||||
| import {createHelpTools, cssLeftPanel, cssScrollPane, | ||||
| @ -28,6 +29,7 @@ const t = makeT('HomeLeftPane'); | ||||
| export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) { | ||||
|   const creating = observable<boolean>(false); | ||||
|   const renaming = observable<Workspace|null>(null); | ||||
|   const isAnonymous = !home.app.currentValidUser; | ||||
| 
 | ||||
|   return cssContent( | ||||
|     dom.autoDispose(creating), | ||||
| @ -109,14 +111,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom | ||||
|       )), | ||||
|       cssTools( | ||||
|         cssPageEntry( | ||||
|           dom.show(isFeatureEnabled("templates")), | ||||
|           dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)), | ||||
|           cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"), | ||||
|           cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")), | ||||
|             urlState().setLinkUrl({homePage: "templates"}), | ||||
|             testId('dm-templates-page'), | ||||
|           ), | ||||
|         ), | ||||
|         cssPageEntry( | ||||
|         isAnonymous ? null : cssPageEntry( | ||||
|           cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"), | ||||
|           cssPageLink(cssPageIcon('RemoveBig'), cssLinkText(t("Trash")), | ||||
|             urlState().setLinkUrl({homePage: "trash"}), | ||||
|  | ||||
| @ -47,7 +47,7 @@ export class SupportGristPage extends Disposable { | ||||
|         panelWidth: Observable.create(this, 240), | ||||
|         panelOpen, | ||||
|         hideOpener: true, | ||||
|         header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), | ||||
|         header: dom.create(AppHeader, this._appModel), | ||||
|         content: leftPanelBasic(this._appModel, panelOpen), | ||||
|       }, | ||||
|       headerMain: this._buildMainHeader(), | ||||
|  | ||||
| @ -24,6 +24,8 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style | ||||
| const t = makeT('TopBar'); | ||||
| 
 | ||||
| export function createTopBarHome(appModel: AppModel) { | ||||
|   const isAnonymous = !appModel.currentValidUser; | ||||
| 
 | ||||
|   return [ | ||||
|     cssFlexSpace(), | ||||
|     appModel.supportGristNudge.showButton(), | ||||
| @ -40,7 +42,7 @@ export function createTopBarHome(appModel: AppModel) { | ||||
|     ), | ||||
| 
 | ||||
|     buildLanguageMenu(appModel), | ||||
|     buildNotifyMenuButton(appModel.notifier, appModel), | ||||
|     isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel), | ||||
|     dom('div', dom.create(AccountWidget, appModel)), | ||||
|   ]; | ||||
| } | ||||
| @ -78,6 +80,8 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode | ||||
|     return !use(undoStack.isDisabled); | ||||
|   }); | ||||
| 
 | ||||
|   const isAnonymous = !pageModel.appModel.currentValidUser; | ||||
| 
 | ||||
|   return [ | ||||
|     // TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay
 | ||||
|     // 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)), | ||||
|           isSnapshot: pageModel.isSnapshot, | ||||
|           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); | ||||
|       return model && use(moduleObs)?.searchBar(model, makeTestId('test-tb-search-')); | ||||
|     }), | ||||
| 
 | ||||
|     buildShareMenuButton(pageModel), | ||||
| 
 | ||||
|     dom.maybe(use => | ||||
|       ( | ||||
|         use(pageModel.gristDoc) | ||||
|         && !use(use(pageModel.gristDoc)!.isReadonly) | ||||
|         && use(COMMENTS()) | ||||
|     dom.maybe(use => !(use(pageModel.isTemplate) && isAnonymous), () => [ | ||||
|       buildShareMenuButton(pageModel), | ||||
|       dom.maybe(use => | ||||
|         ( | ||||
|           use(pageModel.gristDoc) | ||||
|           && !use(use(pageModel.gristDoc)!.isReadonly) | ||||
|           && use(COMMENTS()) | ||||
|         ), | ||||
|         () => buildShowDiscussionButton(pageModel)), | ||||
|       dom.update( | ||||
|         buildNotifyMenuButton(appModel.notifier, appModel), | ||||
|         cssHideForNarrowScreen.cls(''), | ||||
|       ), | ||||
|       () => buildShowDiscussionButton(pageModel)), | ||||
| 
 | ||||
|     dom.update( | ||||
|       buildNotifyMenuButton(appModel.notifier, appModel), | ||||
|       cssHideForNarrowScreen.cls(''), | ||||
|     ), | ||||
| 
 | ||||
|     dom('div', dom.create(AccountWidget, appModel, pageModel)) | ||||
|     ]), | ||||
|     dom('div', dom.create(AccountWidget, appModel, pageModel)), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -50,7 +50,7 @@ export class WelcomePage extends Disposable { | ||||
|         panelWidth: Observable.create(this, 240), | ||||
|         panelOpen: Observable.create(this, false), | ||||
|         hideOpener: true, | ||||
|         header: dom.create(AppHeader, '', this._appModel), | ||||
|         header: dom.create(AppHeader, this._appModel), | ||||
|         content: null, | ||||
|       }, | ||||
|       headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], | ||||
|  | ||||
| @ -110,7 +110,7 @@ function pagePanelsError(appModel: AppModel, header: string, content: DomElement | ||||
|       panelWidth: observable(240), | ||||
|       panelOpen, | ||||
|       hideOpener: true, | ||||
|       header: dom.create(AppHeader, appModel.currentOrgName, appModel), | ||||
|       header: dom.create(AppHeader, appModel), | ||||
|       content: leftPanelBasic(appModel, panelOpen), | ||||
|     }, | ||||
|     headerMain: createTopBarHome(appModel), | ||||
|  | ||||
| @ -100,10 +100,13 @@ export function docBreadcrumbs( | ||||
|     isRecoveryMode: Observable<boolean>, | ||||
|     isSnapshot?: Observable<boolean>, | ||||
|     isPublic?: Observable<boolean>, | ||||
|     isTemplate?: Observable<boolean>, | ||||
|     isAnonymous?: boolean, | ||||
|   } | ||||
|   ): Element { | ||||
|     const shouldShowWorkspace = !(options.isTemplate && options.isAnonymous); | ||||
|     return cssBreadcrumbs( | ||||
|       dom.domComputed<[boolean, PartialWorkspace|null]>( | ||||
|       !shouldShowWorkspace ? null : dom.domComputed<[boolean, PartialWorkspace|null]>( | ||||
|         (use) => [use(options.isBareFork), use(workspace)], | ||||
|         ([isBareFork, ws]) => { | ||||
|           if (isBareFork || !ws) { return null; } | ||||
|  | ||||
| @ -20,7 +20,7 @@ import { | ||||
|   WebhookSummaryCollection, | ||||
|   WebhookUpdate | ||||
| } from 'app/common/Triggers'; | ||||
| import {addCurrentOrgToPath} from 'app/common/urlUtils'; | ||||
| import {addCurrentOrgToPath, getGristConfig} from 'app/common/urlUtils'; | ||||
| 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 { | ||||
|   // TODO: It would be nice to have a more robust way to detect the templates org.
 | ||||
|   return org.domain === 'templates' || org.domain === 'templates-s'; | ||||
| export function isTemplatesOrg(org: {domain: Organization['domain']}|null): boolean { | ||||
|   if (!org) { return false; } | ||||
| 
 | ||||
|   const {templateOrg} = getGristConfig(); | ||||
|   return org.domain === templateOrg; | ||||
| } | ||||
| 
 | ||||
| export type WorkspaceProperties = CommonProperties; | ||||
| @ -117,7 +120,7 @@ export interface Workspace extends WorkspaceProperties { | ||||
|   isSupportWorkspace?: boolean; | ||||
| } | ||||
| 
 | ||||
| export type DocumentType = 'tutorial'; | ||||
| export type DocumentType = 'tutorial'|'template'; | ||||
| 
 | ||||
| // Non-core options for a document.
 | ||||
| // "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", | ||||
|   sproutsProgram: "https://www.getgrist.com/sprouts-program", | ||||
|   contact: "https://www.getgrist.com/contact", | ||||
|   templates: 'https://www.getgrist.com/templates', | ||||
|   community: 'https://community.getgrist.com', | ||||
|   functions: 'https://support.getgrist.com/functions', | ||||
|   formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', | ||||
| @ -647,6 +648,9 @@ export interface GristLoadConfig { | ||||
| 
 | ||||
|   // The Grist deployment type (e.g. core, enterprise).
 | ||||
|   deploymentType?: GristDeploymentType; | ||||
| 
 | ||||
|   // The org containing public templates and tutorials.
 | ||||
|   templateOrg?: string|null; | ||||
| } | ||||
| 
 | ||||
| export const Features = StringUnion( | ||||
|  | ||||
| @ -14,17 +14,13 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, | ||||
|         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 {User} from './entity/User'; | ||||
| import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; | ||||
| 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
 | ||||
| export const Deps = { | ||||
|   apiKeyGenerator: () => crypto.randomBytes(20).toString('hex') | ||||
| @ -247,10 +243,15 @@ export class ApiServer { | ||||
|     // GET /api/templates/
 | ||||
|     // Get all templates (or only featured templates if `onlyFeatured` is set).
 | ||||
|     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 query = await this._dbManager.getOrgWorkspaces( | ||||
|         {...getScope(req), showOnlyPinned: onlyFeatured}, | ||||
|         TEMPLATES_ORG_DOMAIN | ||||
|         templateOrg | ||||
|       ); | ||||
|       return sendReply(req, res, query); | ||||
|     })); | ||||
|  | ||||
| @ -79,7 +79,6 @@ import {Document as APIDocument, DocReplacementOptions, DocState, DocStateCompar | ||||
| import {convertFromColumn} from 'app/common/ValueConverter'; | ||||
| import {guessColInfo} from 'app/common/ValueGuesser'; | ||||
| import {parseUserAction} from 'app/common/ValueParser'; | ||||
| import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer'; | ||||
| import {Document} from 'app/gen-server/entity/Document'; | ||||
| import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; | ||||
| 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 {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox'; | ||||
| import {DocRequests} from 'app/server/lib/Requests'; | ||||
| import {getTemplateOrg} from 'app/server/lib/sendAppPage'; | ||||
| import {shortDesc} from 'app/server/lib/shortDesc'; | ||||
| import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader'; | ||||
| import {DocTriggers} from "app/server/lib/Triggers"; | ||||
| @ -1402,8 +1402,9 @@ export class ActiveDoc extends EventEmitter { | ||||
| 
 | ||||
|       await dbManager.forkDoc(userId, doc, forkIds.forkId); | ||||
| 
 | ||||
|       // TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
 | ||||
|       const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; | ||||
|       // TODO: Remove the right side once all template docs have their type set to "template".
 | ||||
|       const isTemplate = doc.type === 'template' || | ||||
|         (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); | ||||
|       this.logTelemetryEvent(docSession, 'documentForked', { | ||||
|         limited: { | ||||
|           forkIdDigest: forkIds.forkId, | ||||
|  | ||||
| @ -12,7 +12,6 @@ import {removeTrailingSlash} from 'app/common/gutil'; | ||||
| import {LocalPlugin} from "app/common/plugin"; | ||||
| import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; | ||||
| 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 {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; | ||||
| import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, | ||||
| @ -24,7 +23,7 @@ import {getCookieDomain} from 'app/server/lib/gristSessions'; | ||||
| import {getAssignmentId} from 'app/server/lib/idUtils'; | ||||
| import log from 'app/server/lib/log'; | ||||
| 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 { | ||||
|   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 isSnapshot = Boolean(parseUrlId(urlId).snapshotId); | ||||
|     // TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
 | ||||
|     const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; | ||||
|     // TODO: Remove the right side once all template docs have their type set to "template".
 | ||||
|     const isTemplate = doc.type === 'template' || | ||||
|       (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); | ||||
|     if (isPublic || isTemplate) { | ||||
|       gristServer.getTelemetry().logEvent('documentOpened', { | ||||
|         limited: { | ||||
|  | ||||
| @ -3,6 +3,7 @@ import {isAffirmative} from 'app/common/gutil'; | ||||
| import {getTagManagerSnippet} from 'app/common/tagManager'; | ||||
| import {Document} from 'app/common/UserAPI'; | ||||
| 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 {RequestWithOrg} from 'app/server/lib/extractOrg'; | ||||
| 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, | ||||
|     telemetry: server?.getTelemetry().getTelemetryConfig(), | ||||
|     deploymentType: server?.getDeploymentType(), | ||||
|     templateOrg: getTemplateOrg(), | ||||
|     ...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() { | ||||
|   // Enable UI for anonymous access if a flag is explicitly set in the environment
 | ||||
|   return process.env.GRIST_SUPPORT_ANON === "true"; | ||||
|  | ||||
| @ -139,6 +139,12 @@ describe("DuplicateDocument", function() { | ||||
|     await gu.session().teamSite2.createHomeApi().updateOrgPermissions('current', {users: { | ||||
|       [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.loadDoc(`/doc/${urlId}`); | ||||
| 
 | ||||
|  | ||||
| @ -5,7 +5,7 @@ import { EnvironmentSnapshot } from 'test/server/testUtils'; | ||||
| 
 | ||||
| describe('Features', function () { | ||||
|   this.timeout(20000); | ||||
|   setupTestSuite(); | ||||
|   setupTestSuite({samples: true}); | ||||
| 
 | ||||
|   let session: gu.Session; | ||||
|   let oldEnv: EnvironmentSnapshot; | ||||
| @ -21,6 +21,7 @@ describe('Features', 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'; | ||||
|     await server.restart(); | ||||
|     await session.loadDocMenu('/'); | ||||
|  | ||||
| @ -89,12 +89,12 @@ describe("Fork", function() { | ||||
|       for (const mode of ['anonymous', 'logged in']) { | ||||
|         for (const content of ['empty', 'imported']) { | ||||
|           it(`can create an ${content} unsaved document when ${mode}`, async function() { | ||||
|             let name: string; | ||||
|             let visitedSites: string[]; | ||||
|             if (mode === 'anonymous') { | ||||
|               name = '@Guest'; | ||||
|               visitedSites = ['Grist Templates']; | ||||
|               await personal.anon.login(); | ||||
|             } else { | ||||
|               name = `@${personal.name}`; | ||||
|               visitedSites = ['Test Grist', `@${personal.name}`]; | ||||
|               await personal.login(); | ||||
|             } | ||||
|             const anonApi = personal.anon.createHomeApi(); | ||||
| @ -106,8 +106,10 @@ describe("Fork", function() { | ||||
|             await gu.dismissWelcomeTourIfNeeded(); | ||||
|             // check that the tag is there
 | ||||
|             assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true); | ||||
|             // check that the org name area is showing the user (not @Support).
 | ||||
|             assert.equal(await driver.find('.test-dm-org').getText(), name); | ||||
|             // check that the org name area is showing one of the last visited sites. this is
 | ||||
|             // 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') { | ||||
|               assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '999'); | ||||
|             } else { | ||||
| @ -331,13 +333,13 @@ describe("Fork", function() { | ||||
|         // Check others without view access to trunk cannot see fork
 | ||||
|         await team.user('user2').login(); | ||||
|         await driver.get(forkUrl); | ||||
|         assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); | ||||
|         assert.match(await driver.find('.test-error-header').getText(), /Access denied/); | ||||
|         assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); | ||||
|         assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); | ||||
| 
 | ||||
|         await server.removeLogin(); | ||||
|         await driver.get(forkUrl); | ||||
|         assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); | ||||
|         assert.match(await driver.find('.test-error-header').getText(), /Access denied/); | ||||
|         assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); | ||||
|         assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); | ||||
|       }); | ||||
| 
 | ||||
|       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
 | ||||
|         // workspace, or org).
 | ||||
|         await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); | ||||
|         assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); | ||||
|         assert.match(await driver.find('.test-error-header').getText(), /Access denied/); | ||||
|         assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); | ||||
|         assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); | ||||
| 
 | ||||
|         // Same, but as an anonymous user.
 | ||||
|         const anonSession = await altSession.anon.login(); | ||||
| @ -375,8 +377,8 @@ describe("Fork", function() { | ||||
| 
 | ||||
|         // A new doc cannot be created either (because of access mismatch).
 | ||||
|         await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); | ||||
|         assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); | ||||
|         assert.match(await driver.find('.test-error-header').getText(), /Access denied/); | ||||
|         assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); | ||||
|         assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); | ||||
| 
 | ||||
|         // 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.
 | ||||
|  | ||||
| @ -10,6 +10,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils'; | ||||
| describe('HomeIntro', function() { | ||||
|   this.timeout(40000); | ||||
|   setupTestSuite({samples: true}); | ||||
|   gu.withEnvironmentSnapshot({'GRIST_TEMPLATE_ORG': 'templates'}); | ||||
| 
 | ||||
|   describe("Anonymous on merged-org", 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.
 | ||||
|       await this.driver.get(this.server.getUrl(org, "")); | ||||
|       if (!await this.isOnLoginPage()) { | ||||
|         // Explicitly click sign-in link if necessary.
 | ||||
|         await this.driver.findWait('.test-user-signin', 4000).click(); | ||||
|         await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click(); | ||||
|         // Explicitly click Sign In button if necessary.
 | ||||
|         await this.driver.findWait('.test-user-sign-in', 4000).click(); | ||||
|       } | ||||
| 
 | ||||
|       // Fill the login form (either test or Grist).
 | ||||
| @ -382,8 +381,7 @@ export class HomeUtil { | ||||
|     await this.deleteCurrentUser(); | ||||
|     await this.removeLogin(org); | ||||
|     await this.driver.get(this.server.getUrl(org, "")); | ||||
|     await this.driver.findWait('.test-user-signin', 4000).click(); | ||||
|     await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click(); | ||||
|     await this.driver.findWait('.test-user-sign-in', 4000).click(); | ||||
|     await this.checkLoginPage(); | ||||
|     // Fill the login form (either test or Grist).
 | ||||
|     if (await this.isOnTestLoginPage()) { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user