From 74ec9358da8cc0ff66d4e10c131eab29ddf6f6f5 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 25 May 2022 23:47:26 -0700 Subject: [PATCH] (core) Show usage banners in doc menu of free team sites Summary: Also fixes a minor CSS regression in UserManager where the link to add a team member wasn't shown on a separate row. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3444 --- app/client/components/Banner.ts | 165 ++++++++++++++++++ app/client/components/DocUsageBanner.ts | 171 ------------------- app/client/components/DocumentUsage.ts | 16 +- app/client/components/SiteUsageBanner.ts | 93 ++++++++++ app/client/models/DocPageModel.ts | 8 +- app/client/models/HomeModel.ts | 15 ++ app/client/ui/AppUI.ts | 2 + app/client/ui/Tools.ts | 7 +- app/client/ui/UserManager.ts | 20 +-- app/common/Features.ts | 11 ++ app/common/UserAPI.ts | 8 +- app/common/resetOrg.ts | 3 +- app/common/roles.ts | 11 ++ app/gen-server/entity/Product.ts | 10 +- app/server/lib/HostedMetadataManager.ts | 4 +- stubs/app/client/component/DocUsageBanner.ts | 10 ++ 16 files changed, 341 insertions(+), 213 deletions(-) create mode 100644 app/client/components/Banner.ts delete mode 100644 app/client/components/DocUsageBanner.ts create mode 100644 app/client/components/SiteUsageBanner.ts create mode 100644 stubs/app/client/component/DocUsageBanner.ts diff --git a/app/client/components/Banner.ts b/app/client/components/Banner.ts new file mode 100644 index 00000000..e4ae9563 --- /dev/null +++ b/app/client/components/Banner.ts @@ -0,0 +1,165 @@ +import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {Disposable, dom, DomArg, DomElementArg, makeTestId, Observable, styled} from 'grainjs'; + +const testId = makeTestId('test-banner-'); + +export interface BannerOptions { + /** + * Content to display in the banner. + */ + content: DomArg; + + /** + * The banner style. + * + * Warning banners have a yellow background. Error banners have a red + * background. + */ + style: 'warning' | 'error'; + + /** + * Optional variant of `content` to display when screen width becomes narrow. + */ + contentSmall?: DomArg; + + /** + * Whether a button to close the banner should be shown. + * + * If true, `onClose` should also be specified; it will be called when the close + * button is clicked. + * + * Defaults to false. + */ + showCloseButton?: boolean; + + /** + * Whether a button to collapse/expand the banner should be shown on narrow screens. + * + * Defaults to false. + */ + showExpandButton?: boolean; + + /** + * Function that is called when the banner close button is clicked. + * + * Should be used to handle disposal of the Banner. + */ + onClose?(): void; +} + +/** + * A customizable banner for displaying at the top of a page. + */ +export class Banner extends Disposable { + private readonly _isExpanded = Observable.create(this, true); + + constructor(private _options: BannerOptions) { + super(); + } + + public buildDom() { + return cssBanner( + cssBanner.cls(`-${this._options.style}`), + this._buildContent(), + this._buildButtons(), + ); + } + + private _buildContent() { + const {content, contentSmall} = this._options; + return dom.domComputed(use => { + if (contentSmall === undefined) { return [content]; } + + const isExpanded = use(this._isExpanded); + const isNarrowScreen = use(isNarrowScreenObs()); + return [isNarrowScreen && !isExpanded ? contentSmall : content]; + }); + } + + private _buildButtons() { + return cssButtons( + this._options.showExpandButton ? this._buildExpandButton() : null, + this._options.showCloseButton ? this._buildCloseButton() : null, + ); + } + + private _buildCloseButton() { + return cssButton('CrossBig', + dom.on('click', () => this._options.onClose?.()), + testId('close'), + ); + } + + private _buildExpandButton() { + return dom.maybe(isNarrowScreenObs(), () => { + return cssExpandButton('Dropdown', + cssExpandButton.cls('-expanded', this._isExpanded), + dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())), + ); + }); + } +} + +export function buildBannerMessage(...domArgs: DomElementArg[]) { + return cssBannerMessage( + cssIcon('Idea'), + cssLightlyBoldedText(domArgs), + ); +} + +const cssBanner = styled('div', ` + display: flex; + padding: 10px; + gap: 16px; + color: white; + + &-warning { + background: #E6A117; + } + + &-error { + background: ${colors.error}; + } +`); + +const cssButtons = styled('div', ` + display: flex; + gap: 16px; + flex-shrink: 0; + margin-left: auto; +`); + +const cssButton = styled(icon, ` + width: 16px; + height: 16px; + cursor: pointer; + background-color: white; +`); + +const cssExpandButton = styled(cssButton, ` + &-expanded { + -webkit-mask-image: var(--icon-DropdownUp); + } +`); + +const cssLightlyBoldedText = styled('div', ` + font-weight: 500; +`); + +const cssIconAndText = styled('div', ` + display: flex; + gap: 16px; +`); + +const cssBannerMessage = styled(cssIconAndText, ` + flex-grow: 1; + justify-content: center; +`); + +const cssIcon = styled(icon, ` + flex-shrink: 0; + width: 16px; + height: 16px; + background-color: white; +`); diff --git a/app/client/components/DocUsageBanner.ts b/app/client/components/DocUsageBanner.ts deleted file mode 100644 index 0762f7b4..00000000 --- a/app/client/components/DocUsageBanner.ts +++ /dev/null @@ -1,171 +0,0 @@ -import {buildLimitStatusMessage, buildUpgradeMessage} from 'app/client/components/DocumentUsage'; -import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs'; -import {DocPageModel} from 'app/client/models/DocPageModel'; -import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {Computed, Disposable, dom, DomComputed, makeTestId, Observable, styled} from 'grainjs'; - -const testId = makeTestId('test-doc-usage-banner-'); - -export class DocUsageBanner extends Disposable { - // Whether the banner is vertically expanded on narrow screens. - private readonly _isExpanded = Observable.create(this, true); - - private readonly _currentDocId = this._docPageModel.currentDocId; - private readonly _currentDocUsage = this._docPageModel.currentDocUsage; - private readonly _currentOrg = this._docPageModel.currentOrg; - - private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => { - return usage?.dataLimitStatus ?? null; - }); - - private readonly _shouldShowBanner: Computed = - Computed.create(this, this._currentOrg, (_use, org) => { - return org?.access !== 'guests' && org?.access !== null; - }); - - // Session storage observable. Set to false to dismiss the banner for the session. - private _showApproachingLimitBannerPref: Observable; - - constructor(private _docPageModel: DocPageModel) { - super(); - this.autoDispose(this._currentDocId.addListener((docId) => { - if (this._showApproachingLimitBannerPref?.isDisposed() === false) { - this._showApproachingLimitBannerPref.dispose(); - } - const userId = this._docPageModel.appModel.currentUser?.id ?? 0; - this._showApproachingLimitBannerPref = sessionStorageBoolObs( - `u=${userId}:doc=${docId}:showApproachingLimitBanner`, - true, - ); - })); - } - - public buildDom() { - return dom.maybe(this._dataLimitStatus, (status): DomComputed => { - switch (status) { - case 'approachingLimit': { return this._buildApproachingLimitBanner(); } - case 'gracePeriod': - case 'deleteOnly': { return this._buildExceedingLimitBanner(status === 'deleteOnly'); } - } - }); - } - - private _buildApproachingLimitBanner() { - return dom.maybe(this._shouldShowBanner, () => { - return dom.domComputed(use => { - if (!use(this._showApproachingLimitBannerPref)) { - return null; - } - - const org = use(this._currentOrg); - if (!org) { return null; } - - const features = org.billingAccount?.product.features; - return cssApproachingLimitBanner( - cssBannerMessage( - cssWhiteIcon('Idea'), - cssLightlyBoldedText( - buildLimitStatusMessage('approachingLimit', features), - ' ', - buildUpgradeMessage(org.access === 'owners'), - testId('text'), - ), - ), - cssCloseButton('CrossBig', - dom.on('click', () => this._showApproachingLimitBannerPref.set(false)), - testId('close'), - ), - testId('container'), - ); - }); - }); - } - - private _buildExceedingLimitBanner(isDeleteOnly: boolean) { - return dom.maybe(this._shouldShowBanner, () => { - return dom.maybe(this._currentOrg, org => { - const features = org.billingAccount?.product.features; - return cssExceedingLimitBanner( - cssBannerMessage( - cssWhiteIcon('Idea'), - cssLightlyBoldedText( - dom.domComputed(use => { - const isExpanded = use(this._isExpanded); - const isNarrowScreen = use(isNarrowScreenObs()); - const isOwner = org.access === 'owners'; - if (isNarrowScreen && !isExpanded) { - return buildUpgradeMessage(isOwner, 'short'); - } - - return [ - buildLimitStatusMessage(isDeleteOnly ? 'deleteOnly' : 'gracePeriod', features), - ' ', - buildUpgradeMessage(isOwner), - ]; - }), - testId('text'), - ), - ), - dom.maybe(isNarrowScreenObs(), () => { - return dom.domComputed(this._isExpanded, isExpanded => - cssExpandButton( - isExpanded ? 'DropdownUp' : 'Dropdown', - dom.on('click', () => this._isExpanded.set(!isExpanded)), - ), - ); - }), - testId('container'), - ); - }); - }); - } -} - -const cssLightlyBoldedText = styled('div', ` - font-weight: 500; -`); - -const cssUsageBanner = styled('div', ` - display: flex; - align-items: flex-start; - padding: 10px; - color: white; - gap: 16px; -`); - -const cssApproachingLimitBanner = styled(cssUsageBanner, ` - background: #E6A117; -`); - -const cssExceedingLimitBanner = styled(cssUsageBanner, ` - background: ${colors.error}; -`); - -const cssIconAndText = styled('div', ` - display: flex; - gap: 16px; -`); - -const cssBannerMessage = styled(cssIconAndText, ` - flex-grow: 1; - justify-content: center; -`); - -const cssIcon = styled(icon, ` - flex-shrink: 0; - width: 16px; - height: 16px; -`); - -const cssWhiteIcon = styled(cssIcon, ` - background-color: white; -`); - -const cssCloseButton = styled(cssIcon, ` - flex-shrink: 0; - cursor: pointer; - background-color: white; -`); - -const cssExpandButton = cssCloseButton; diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index bfd4f241..65e978bf 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -6,9 +6,10 @@ import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage'; -import {Features} from 'app/common/Features'; +import {Features, isFreeProduct} from 'app/common/Features'; import {commonUrls} from 'app/common/gristUrls'; import {capitalizeFirstWord} from 'app/common/gutil'; +import {canUpgradeOrg} from 'app/common/roles'; import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs'; const testId = makeTestId('test-doc-usage-'); @@ -156,12 +157,15 @@ export class DocumentUsage extends Disposable { const status = use(this._dataLimitStatus); if (!org || !status) { return null; } + const product = org.billingAccount?.product; return buildMessage([ - buildLimitStatusMessage(status, org.billingAccount?.product.features, { + buildLimitStatusMessage(status, product?.features, { disableRawDataLink: true }), - ' ', - buildUpgradeMessage(org.access === 'owners') + (product && isFreeProduct(product) + ? [' ', buildUpgradeMessage(canUpgradeOrg(org))] + : null + ), ]); }); } @@ -226,8 +230,8 @@ export function buildLimitStatusMessage( } } -export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') { - if (!isOwner) { return 'Contact the site owner to upgrade the plan to raise limits.'; } +export function buildUpgradeMessage(canUpgrade: boolean, variant: 'short' | 'long' = 'long') { + if (!canUpgrade) { return 'Contact the site owner to upgrade the plan to raise limits.'; } const upgradeLinkText = 'start your 30-day free trial of the Pro plan.'; return [ diff --git a/app/client/components/SiteUsageBanner.ts b/app/client/components/SiteUsageBanner.ts new file mode 100644 index 00000000..d72449d2 --- /dev/null +++ b/app/client/components/SiteUsageBanner.ts @@ -0,0 +1,93 @@ +import {Banner, buildBannerMessage} from 'app/client/components/Banner'; +import {buildUpgradeMessage} from 'app/client/components/DocumentUsage'; +import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs'; +import {HomeModel} from 'app/client/models/HomeModel'; +import {isFreeProduct} from 'app/common/Features'; +import {isOwner} from 'app/common/roles'; +import {Disposable, dom, makeTestId, Observable} from 'grainjs'; + +const testId = makeTestId('test-site-usage-banner-'); + +export class SiteUsageBanner extends Disposable { + private readonly _currentOrg = this._homeModel.app.currentOrg; + private readonly _currentOrgUsage = this._homeModel.currentOrgUsage; + private readonly _product = this._currentOrg?.billingAccount?.product; + private readonly _currentUser = this._homeModel.app.currentValidUser; + + // Session storage observable. Set to false to dismiss the banner for the session. + private _showApproachingLimitBannerPref?: Observable; + + constructor(private _homeModel: HomeModel) { + super(); + + if (this._currentUser && isOwner(this._currentOrg)) { + this._showApproachingLimitBannerPref = this.autoDispose(sessionStorageBoolObs( + `u=${this._currentUser.id}:org=${this._currentOrg.id}:showApproachingLimitBanner`, + true, + )); + } + } + + public buildDom() { + return dom.maybe(this._currentOrgUsage, (usage) => { + const {approachingLimit, gracePeriod, deleteOnly} = usage; + if (deleteOnly > 0 || gracePeriod > 0) { + return this._buildExceedingLimitsBanner(deleteOnly + gracePeriod); + } else if (approachingLimit > 0) { + return this._buildApproachingLimitsBanner(approachingLimit); + } else { + return null; + } + }); + } + + private _buildApproachingLimitsBanner(numDocs: number) { + return dom.domComputed(use => { + if (this._showApproachingLimitBannerPref && !use(this._showApproachingLimitBannerPref)) { + return null; + } + + const limitsMessage = numDocs > 1 + ? `${numDocs} documents are approaching their limits.` + : `${numDocs} document is approaching its limits.`; + return dom.create(Banner, { + content: buildBannerMessage( + limitsMessage, + (this._product && isFreeProduct(this._product) + ? [' ', buildUpgradeMessage(true)] + : null + ), + testId('text'), + ), + style: 'warning', + showCloseButton: true, + onClose: () => this._showApproachingLimitBannerPref?.set(false), + }); + }); + } + + private _buildExceedingLimitsBanner(numDocs: number) { + const limitsMessage = numDocs > 1 + ? `${numDocs} documents have exceeded their limits.` + : `${numDocs} document has exceeded its limits.`; + return dom.create(Banner, { + content: buildBannerMessage( + limitsMessage, + (this._product && isFreeProduct(this._product) + ? [' ', buildUpgradeMessage(true)] + : null + ), + testId('text'), + ), + contentSmall: buildBannerMessage( + (this._product && isFreeProduct(this._product) + ? buildUpgradeMessage(true, 'short') + : limitsMessage + ), + ), + style: 'error', + showCloseButton: false, + showExpandButton: true, + }); + } +} diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index ed015863..43426082 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -20,7 +20,7 @@ import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {getReconnectTimeout} from 'app/common/gutil'; -import {canEdit} from 'app/common/roles'; +import {canEdit, isOwner} from 'app/common/roles'; import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI'; import {Holder, Observable, subscribe} from 'grainjs'; import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; @@ -210,19 +210,19 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { public offerRecovery(err: Error) { const isDenied = (err as any).code === 'ACL_DENY'; - const isOwner = this.currentDoc.get()?.access === 'owners'; + const isDocOwner = isOwner(this.currentDoc.get()); confirmModal( "Error accessing document", "Reload", async () => window.location.reload(true), - isOwner ? `You can try reloading the document, or using recovery mode. ` + + isDocOwner ? `You can try reloading the document, or using recovery mode. ` + `Recovery mode opens the document to be fully accessible to owners, and ` + `inaccessible to others. It also disables formulas. ` + `[${err.message}]` : isDenied ? `Sorry, access to this document has been denied. [${err.message}]` : `Document owners can attempt to recover the document. [${err.message}]`, { hideCancel: true, - extraButtons: (isOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => { + extraButtons: (isDocOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => { await this._api.getDocAPI(this.currentDocId.get()!).recover(true); window.location.reload(true); }), testId('modal-recovery-mode')) : null, diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 197551a6..04471b6f 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -7,6 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel'; import {reportMessage, UserError} from 'app/client/models/errors'; import {urlState} from 'app/client/models/gristUrlState'; import {ownerName} from 'app/client/models/WorkspaceInfo'; +import {OrgUsageSummary} from 'app/common/DocUsage'; import {IHomePage} from 'app/common/gristUrls'; import {isLongerThan} from 'app/common/gutil'; import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; @@ -75,6 +76,8 @@ export interface HomeModel { // user isn't allowed to create a doc. newDocWorkspace: Observable; + currentOrgUsage: Observable; + createWorkspace(name: string): Promise; renameWorkspace(id: number, name: string): Promise; deleteWorkspace(id: number, forever: boolean): Promise; @@ -155,6 +158,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) && Boolean(use(this.newDocWorkspace)))); + public readonly currentOrgUsage: Observable = Observable.create(this, null); + private _userOrgPrefs = Observable.create(this, this._app.currentOrg?.userOrgPrefs); constructor(private _app: AppModel, clientScope: ClientScope) { @@ -187,6 +192,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings clientScope); const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList); this.importSources.set(importSources); + + this._updateCurrentOrgUsage().catch(reportError); } // Accessor for the AppModel containing this HomeModel. @@ -379,6 +386,14 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs}); } } + + private async _updateCurrentOrgUsage() { + const currentOrg = this.app.currentOrg; + if (!roles.isOwner(currentOrg)) { return; } + + const api = this.app.api; + this.currentOrgUsage.set(await api.getOrgUsageSummary(currentOrg.id)); + } } // Check if active product allows just a single workspace. diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 5900375a..8b588a4b 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,4 +1,5 @@ import {DocUsageBanner} from 'app/client/components/DocUsageBanner'; +import {SiteUsageBanner} from 'app/client/components/SiteUsageBanner'; import {domAsync} from 'app/client/lib/domAsync'; import {loadBillingPage} from 'app/client/lib/imports'; import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs'; @@ -102,6 +103,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { }, headerMain: createTopBarHome(appModel), contentMain: createDocMenu(pageModel), + contentTop: dom.create(SiteUsageBanner, pageModel), }); } diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 72945c25..861879f1 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -13,17 +13,18 @@ import {cssLink} from 'app/client/ui2018/links'; import {menuAnnotate} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; import {userOverrideParams} from 'app/common/gristUrls'; +import {isOwner} from 'app/common/roles'; import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs'; const testId = makeTestId('test-tools-'); export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable): Element { const docPageModel = gristDoc.docPageModel; - const isOwner = docPageModel.currentDoc.get()?.access === 'owners'; + const isDocOwner = isOwner(docPageModel.currentDoc.get()); const isOverridden = Boolean(docPageModel.userOverride.get()); const canViewAccessRules = observable(false); function updateCanViewAccessRules() { - canViewAccessRules.set((isOwner && !isOverridden) || + canViewAccessRules.set((isDocOwner && !isOverridden) || gristDoc.docModel.rules.getNumRows() > 0); } owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules)); @@ -103,7 +104,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse testId('doctour'), ), ), - !isOwner ? null : cssPageEntrySmall( + !isDocOwner ? null : cssPageEntrySmall( cssPageLink(cssPageIcon('Remove'), dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () => gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour'])) diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 23699f6c..66588003 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -283,12 +283,9 @@ export class UserManager extends Disposable { !member.name ? null : cssMemberSecondary( member.email, dom.cls('member-email'), testId('um-member-email') ), - dom('span', - (this._model.isPersonal - ? this._buildSelfAnnotationDom(member) - : this._buildAnnotationDom(member) - ), - testId('um-member-annotation'), + (this._model.isPersonal + ? this._buildSelfAnnotationDom(member) + : this._buildAnnotationDom(member) ), ), member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess, @@ -364,15 +361,18 @@ export class UserManager extends Disposable { const annotation = annotations.users.get(user.email); if (!annotation) { return null; } + let memberType: string; if (annotation.isSupport) { - return cssMemberType('Grist support'); + memberType = 'Grist support'; } else if (annotation.isMember && annotations.hasTeam) { - return cssMemberType('Team member'); + memberType = 'Team member'; } else if (annotations.hasTeam) { - return cssMemberType('Outside collaborator'); + memberType = 'Outside collaborator'; } else { - return cssMemberType('Collaborator'); + memberType = 'Collaborator'; } + + return cssMemberType(memberType, testId('um-member-annotation')); }); } diff --git a/app/common/Features.ts b/app/common/Features.ts index a1af60a5..f22bf75f 100644 --- a/app/common/Features.ts +++ b/app/common/Features.ts @@ -3,6 +3,12 @@ export interface SnapshotWindow { unit: 'days' | 'month' | 'year'; } +// Information about the product associated with an org or orgs. +export interface Product { + name: string; + features: Features; +} + // A product is essentially a list of flags and limits that we may enforce/support. export interface Features { vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true) @@ -60,3 +66,8 @@ export interface Features { export function canAddOrgMembers(features: Features): boolean { return features.maxWorkspacesPerOrg !== 1; } + +// Returns true if `product` is free. +export function isFreeProduct(product: Product): boolean { + return ['starter', 'teamFree'].includes(product.name); +} diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index fdd3d4cd..06e10f13 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -6,7 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings'; import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions'; import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {OrgUsageSummary} from 'app/common/DocUsage'; -import {Features} from 'app/common/Features'; +import {Product} from 'app/common/Features'; import {ICustomWidget} from 'app/common/CustomWidget'; import {isClient} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; @@ -73,12 +73,6 @@ export interface BillingAccount { }; } -// Information about the product associated with an org or orgs. -export interface Product { - name: string; - features: Features; -} - // The upload types vary based on which fetch implementation is in use. This is // an incomplete list. For example, node streaming types are supported by node-fetch. export type UploadType = string | Blob | Buffer; diff --git a/app/common/resetOrg.ts b/app/common/resetOrg.ts index a384196d..4dccb07b 100644 --- a/app/common/resetOrg.ts +++ b/app/common/resetOrg.ts @@ -1,3 +1,4 @@ +import {isOwner} from 'app/common/roles'; import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI'; /** @@ -7,7 +8,7 @@ import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI'; */ export async function resetOrg(api: UserAPI, org: string|number) { const session = await api.getSessionActive(); - if (!(session.org && session.org.access === 'owners')) { + if (!isOwner(session.org)) { throw new Error('user must be an owner of the org to be reset'); } const billing = api.getBillingAPI(); diff --git a/app/common/roles.ts b/app/common/roles.ts index 5e6736fe..7ef04be0 100644 --- a/app/common/roles.ts +++ b/app/common/roles.ts @@ -1,3 +1,5 @@ +import {Organization} from 'app/common/UserAPI'; + export const OWNER = 'owners'; export const EDITOR = 'editors'; export const VIEWER = 'viewers'; @@ -39,6 +41,15 @@ export function canView(role: string|null): boolean { return role !== null; } +export function isOwner(resource: {access: Role}|null): resource is {access: Role} { + return resource?.access === OWNER; +} + +export function canUpgradeOrg(org: Organization|null): org is Organization { + // TODO: Need to consider billing managers and support user. + return isOwner(org); +} + // Returns true if the role string is a valid role or null. export function isValidRole(role: string|null): role is Role|null { return (roleOrder as Array).includes(role); diff --git a/app/gen-server/entity/Product.ts b/app/gen-server/entity/Product.ts index 192f4fb4..fcafa07a 100644 --- a/app/gen-server/entity/Product.ts +++ b/app/gen-server/entity/Product.ts @@ -1,4 +1,4 @@ -import {Features} from 'app/common/Features'; +import {Features, Product as IProduct} from 'app/common/Features'; import {nativeValues} from 'app/gen-server/lib/values'; import * as assert from 'assert'; import {BillingAccount} from 'app/gen-server/entity/BillingAccount'; @@ -67,14 +67,6 @@ export const suspendedFeatures: Features = { maxWorkspacesPerOrg: 0, }; -/** - * Basic fields needed for products supported by Grist. - */ -export interface IProduct { - name: string; - features: Features; -} - /** * * Products are a bundle of enabled features. Most products in diff --git a/app/server/lib/HostedMetadataManager.ts b/app/server/lib/HostedMetadataManager.ts index 95789012..9db8e02d 100644 --- a/app/server/lib/HostedMetadataManager.ts +++ b/app/server/lib/HostedMetadataManager.ts @@ -17,10 +17,10 @@ export class HostedMetadataManager { private _lastPushTime: number = 0.0; // Callback for next opportunity to push changes. - private _timeout: any = null; + private _timeout: NodeJS.Timeout|null = null; // Maintains the update Promise to wait on it if the class is closing. - private _push: Promise|null; + private _push: Promise|null; // The default delay in milliseconds between metadata pushes to the database. private readonly _minPushDelayMs: number; diff --git a/stubs/app/client/component/DocUsageBanner.ts b/stubs/app/client/component/DocUsageBanner.ts new file mode 100644 index 00000000..7c922ab1 --- /dev/null +++ b/stubs/app/client/component/DocUsageBanner.ts @@ -0,0 +1,10 @@ +import {DocPageModel} from 'app/client/models/DocPageModel'; +import {Disposable} from 'grainjs'; + +export class DocUsageBanner extends Disposable { + constructor(_docPageModel: DocPageModel) { super(); } + + public buildDom() { + return null; + } +}