mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add document usage banners
Summary: This also enables the new Usage section for all sites. Currently, it shows metrics for document row count, but only if the user has full document read access. Otherwise, a message about insufficient access is shown. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3377
This commit is contained in:
parent
01b1c310b5
commit
af5b3c9004
171
app/client/components/DocUsageBanner.ts
Normal file
171
app/client/components/DocUsageBanner.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import {buildUpgradeMessage, getLimitStatusMessage} 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 _currentDoc = this._docPageModel.currentDoc;
|
||||||
|
private readonly _currentDocId = this._docPageModel.currentDocId;
|
||||||
|
private readonly _dataLimitStatus = this._docPageModel.dataLimitStatus;
|
||||||
|
|
||||||
|
private readonly _currentOrg = Computed.create(this, this._currentDoc, (_use, doc) => {
|
||||||
|
return doc?.workspace.org ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _shouldShowBanner: Computed<boolean> =
|
||||||
|
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<boolean>;
|
||||||
|
|
||||||
|
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(
|
||||||
|
getLimitStatusMessage('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 [
|
||||||
|
getLimitStatusMessage(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;
|
@ -3,87 +3,196 @@ import {docListHeader} from 'app/client/ui/DocMenuCss';
|
|||||||
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
|
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {DataLimitStatus} from 'app/common/ActiveDocAPI';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
|
import {Features} from 'app/common/Features';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
import {Computed, Disposable, dom, IDisposableOwner, Observable, styled} from 'grainjs';
|
import {capitalizeFirstWord} from 'app/common/gutil';
|
||||||
|
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/Usage';
|
||||||
|
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
|
||||||
|
|
||||||
const limitStatusMessages: Record<NonNullable<DataLimitStatus>, string> = {
|
const testId = makeTestId('test-doc-usage-');
|
||||||
approachingLimit: 'This document is approaching free plan limits.',
|
|
||||||
deleteOnly: 'This document is now in delete-only mode.',
|
// Default used by the progress bar to visually indicate row usage.
|
||||||
gracePeriod: 'This document has exceeded free plan limits.',
|
const DEFAULT_MAX_ROWS = 20000;
|
||||||
};
|
|
||||||
|
const ACCESS_DENIED_MESSAGE = 'Usage statistics are only available to users with '
|
||||||
|
+ 'full access to the document data.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays statistics about document usage, such as number of rows used.
|
* Displays statistics about document usage, such as number of rows used.
|
||||||
*
|
|
||||||
* Currently only shows usage if current site is a free team site.
|
|
||||||
*/
|
*/
|
||||||
export class DocumentUsage extends Disposable {
|
export class DocumentUsage extends Disposable {
|
||||||
|
private readonly _currentDoc = this._docPageModel.currentDoc;
|
||||||
|
private readonly _dataLimitStatus = this._docPageModel.dataLimitStatus;
|
||||||
|
private readonly _rowCount = this._docPageModel.rowCount;
|
||||||
|
|
||||||
|
private readonly _currentOrg = Computed.create(this, this._currentDoc, (_use, doc) => {
|
||||||
|
return doc?.workspace.org ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _rowMetrics: Computed<MetricOptions | null> =
|
||||||
|
Computed.create(this, this._currentOrg, this._rowCount, (_use, org, rowCount) => {
|
||||||
|
const features = org?.billingAccount?.product.features;
|
||||||
|
if (!features || typeof rowCount !== 'number') { return null; }
|
||||||
|
|
||||||
|
const {baseMaxRowsPerDocument: maxRows} = features;
|
||||||
|
// Invalid row limits are currently treated as if they are undefined.
|
||||||
|
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
|
||||||
|
return {
|
||||||
|
name: 'Rows',
|
||||||
|
currentValue: rowCount,
|
||||||
|
maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
|
||||||
|
unit: 'rows',
|
||||||
|
shouldHideLimits: maxValue === undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _isLoading: Computed<boolean> =
|
||||||
|
Computed.create(this, this._currentDoc, this._rowCount, (_use, doc, rowCount) => {
|
||||||
|
return doc === null || rowCount === 'pending';
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _isAccessDenied: Computed<boolean | null> =
|
||||||
|
Computed.create(
|
||||||
|
this, this._isLoading, this._currentDoc, this._rowCount,
|
||||||
|
(_use, isLoading, doc, rowCount) => {
|
||||||
|
if (isLoading) { return null; }
|
||||||
|
|
||||||
|
const {access} = doc!.workspace.org;
|
||||||
|
const isPublicUser = access === 'guests' || access === null;
|
||||||
|
return isPublicUser || rowCount === 'hidden';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
constructor(private _docPageModel: DocPageModel) {
|
constructor(private _docPageModel: DocPageModel) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
const features = this._docPageModel.appModel.currentFeatures;
|
|
||||||
if (features.baseMaxRowsPerDocument === undefined) { return null; }
|
|
||||||
|
|
||||||
return dom('div',
|
return dom('div',
|
||||||
cssHeader('Usage'),
|
cssHeader('Usage', testId('heading')),
|
||||||
dom.domComputed(this._docPageModel.dataLimitStatus, status => {
|
dom.domComputed(this._isLoading, (isLoading) => {
|
||||||
if (!status) { return null; }
|
if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
|
||||||
|
|
||||||
return cssLimitWarning(
|
return [this._buildMessage(), this._buildMetrics()];
|
||||||
cssIcon('Idea'),
|
|
||||||
cssLightlyBoldedText(
|
|
||||||
limitStatusMessages[status],
|
|
||||||
' For higher limits, ',
|
|
||||||
cssUnderlinedLink('start your 30-day free trial of the Pro plan.', {
|
|
||||||
href: commonUrls.plans,
|
|
||||||
target: '_blank',
|
|
||||||
}),
|
}),
|
||||||
),
|
testId('container'),
|
||||||
);
|
);
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
private _buildMessage() {
|
||||||
|
return dom.domComputed((use) => {
|
||||||
|
const isAccessDenied = use(this._isAccessDenied);
|
||||||
|
if (isAccessDenied === null) { return null; }
|
||||||
|
if (isAccessDenied) { return buildMessage(ACCESS_DENIED_MESSAGE); }
|
||||||
|
|
||||||
|
const org = use(this._currentOrg);
|
||||||
|
const status = use(this._dataLimitStatus);
|
||||||
|
if (!org || !status) { return null; }
|
||||||
|
|
||||||
|
return buildMessage([
|
||||||
|
getLimitStatusMessage(status, org.billingAccount?.product.features),
|
||||||
|
' ',
|
||||||
|
buildUpgradeMessage(org.access === 'owners')
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildMetrics() {
|
||||||
|
return dom.maybe(use => use(this._isAccessDenied) === false, () =>
|
||||||
cssUsageMetrics(
|
cssUsageMetrics(
|
||||||
dom.create(buildUsageMetric, {
|
dom.maybe(this._rowMetrics, (metrics) =>
|
||||||
name: 'Rows',
|
buildUsageMetric(metrics, testId('rows')),
|
||||||
currentValue: this._docPageModel.rowCount,
|
),
|
||||||
maximumValue: features.baseMaxRowsPerDocument,
|
testId('metrics'),
|
||||||
units: 'rows',
|
),
|
||||||
}),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMessage(message: DomContents) {
|
||||||
|
return cssWarningMessage(
|
||||||
|
cssIcon('Idea'),
|
||||||
|
cssLightlyBoldedText(message, testId('message-text')),
|
||||||
|
testId('message'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricOptions {
|
||||||
|
name: string;
|
||||||
|
currentValue: number;
|
||||||
|
// If undefined or non-positive (i.e. invalid), no limits will be assumed.
|
||||||
|
maximumValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
// If true, limits will always be hidden, even if `maximumValue` is a positive number.
|
||||||
|
shouldHideLimits?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a component which displays the current and maximum values for
|
* Builds a component which displays the current and maximum values for
|
||||||
* a particular metric (e.g. rows), and a progress meter showing how
|
* a particular metric (e.g. row count), and a progress meter showing how
|
||||||
* close `currentValue` is to hitting `maximumValue`.
|
* close `currentValue` is to hitting `maximumValue`.
|
||||||
*/
|
*/
|
||||||
function buildUsageMetric(owner: IDisposableOwner, {name, currentValue, maximumValue, units}: {
|
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
|
||||||
name: string;
|
const {name, currentValue, maximumValue, unit, shouldHideLimits} = options;
|
||||||
currentValue: Observable<number | undefined>;
|
const ratioUsed = currentValue / (maximumValue || Infinity);
|
||||||
maximumValue: number;
|
const percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
|
||||||
units?: string;
|
|
||||||
}) {
|
|
||||||
const percentUsed = Computed.create(owner, currentValue, (_use, value) => {
|
|
||||||
return Math.min(100, Math.floor(((value ?? 0) / maximumValue) * 100));
|
|
||||||
});
|
|
||||||
return cssUsageMetric(
|
return cssUsageMetric(
|
||||||
cssMetricName(name),
|
cssMetricName(name, testId('name')),
|
||||||
cssProgressBarContainer(
|
cssProgressBarContainer(
|
||||||
cssProgressBarFill(
|
cssProgressBarFill(
|
||||||
dom.style('width', use => `${use(percentUsed)}%`),
|
{style: `width: ${percentUsed}%`},
|
||||||
cssProgressBarFill.cls('-approaching-limit', use => use(percentUsed) >= 90)
|
// Change progress bar to red if close to limit, unless limits are hidden.
|
||||||
)
|
shouldHideLimits || ratioUsed <= APPROACHING_LIMIT_RATIO
|
||||||
|
? null
|
||||||
|
: cssProgressBarFill.cls('-approaching-limit'),
|
||||||
|
testId('progress-fill'),
|
||||||
),
|
),
|
||||||
dom.maybe(currentValue, value =>
|
|
||||||
dom('div', `${value} of ${maximumValue}` + (units ? ` ${units}` : ''))
|
|
||||||
),
|
),
|
||||||
|
dom('div',
|
||||||
|
currentValue
|
||||||
|
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + maximumValue)
|
||||||
|
+ (unit ? ` ${unit}` : ''),
|
||||||
|
testId('value'),
|
||||||
|
),
|
||||||
|
...domArgs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLimitStatusMessage(status: NonNullable<DataLimitStatus>, features?: Features): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'approachingLimit': {
|
||||||
|
return 'This document is approaching free plan limits.';
|
||||||
|
}
|
||||||
|
case 'gracePeriod': {
|
||||||
|
const gracePeriodDays = features?.gracePeriodDays;
|
||||||
|
if (!gracePeriodDays) { return 'Document limits exceeded.'; }
|
||||||
|
|
||||||
|
return `Document limits exceeded. In ${gracePeriodDays} days, this document will be read-only.`;
|
||||||
|
}
|
||||||
|
case 'deleteOnly': {
|
||||||
|
return 'This document exceeded free plan limits and is now read-only, but you can delete rows.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') {
|
||||||
|
if (!isOwner) { 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 [
|
||||||
|
variant === 'short' ? null : 'For higher limits, ',
|
||||||
|
buildUpgradeLink(variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUpgradeLink(linkText: string) {
|
||||||
|
return cssUnderlinedLink(linkText, {
|
||||||
|
href: commonUrls.plans,
|
||||||
|
target: '_blank',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cssLightlyBoldedText = styled('div', `
|
const cssLightlyBoldedText = styled('div', `
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
`);
|
`);
|
||||||
@ -93,7 +202,7 @@ const cssIconAndText = styled('div', `
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssLimitWarning = styled(cssIconAndText, `
|
const cssWarningMessage = styled(cssIconAndText, `
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -112,7 +221,6 @@ const cssHeader = styled(docListHeader, `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssUnderlinedLink = styled(cssLink, `
|
const cssUnderlinedLink = styled(cssLink, `
|
||||||
display: inline-block;
|
|
||||||
color: unset;
|
color: unset;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
||||||
@ -161,3 +269,9 @@ const cssProgressBarFill = styled(cssProgressBarContainer, `
|
|||||||
background: ${colors.error};
|
background: ${colors.error};
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssSpinner = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
`);
|
||||||
|
@ -24,17 +24,34 @@ export function getStorage(): Storage {
|
|||||||
return _storage || (_storage = createStorage());
|
return _storage || (_storage = createStorage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to `getStorage`, but always returns sessionStorage (or an in-memory equivalent).
|
||||||
|
*/
|
||||||
|
export function getSessionStorage(): Storage {
|
||||||
|
return _sessionStorage || (_sessionStorage = createSessionStorage());
|
||||||
|
}
|
||||||
|
|
||||||
let _storage: Storage|undefined;
|
let _storage: Storage|undefined;
|
||||||
|
let _sessionStorage: Storage|undefined;
|
||||||
|
|
||||||
function createStorage(): Storage {
|
function createStorage(): Storage {
|
||||||
if (typeof localStorage !== 'undefined' && testStorage(localStorage)) {
|
if (typeof localStorage !== 'undefined' && testStorage(localStorage)) {
|
||||||
return localStorage;
|
return localStorage;
|
||||||
|
} else {
|
||||||
|
return createSessionStorage();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionStorage(): Storage {
|
||||||
if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) {
|
if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) {
|
||||||
return sessionStorage;
|
return sessionStorage;
|
||||||
|
} else {
|
||||||
|
// Fall back to a Map-based implementation of (non-persistent) sessionStorage.
|
||||||
|
return createInMemoryStorage();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to a Map-based implementation of (non-persistent) localStorage.
|
function createInMemoryStorage(): Storage {
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
return {
|
return {
|
||||||
setItem(key: string, val: string) { values.set(key, val); },
|
setItem(key: string, val: string) { values.set(key, val); },
|
||||||
@ -46,6 +63,13 @@ function createStorage(): Storage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStorageBoolObs(store: Storage, key: string, defValue: boolean) {
|
||||||
|
const storedNegation = defValue ? 'false' : 'true';
|
||||||
|
const obs = Observable.create(null, store.getItem(key) === storedNegation ? !defValue : defValue);
|
||||||
|
obs.addListener((val) => val === defValue ? store.removeItem(key) : store.setItem(key, storedNegation));
|
||||||
|
return obs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create a boolean observable whose state is stored in localStorage.
|
* Helper to create a boolean observable whose state is stored in localStorage.
|
||||||
*
|
*
|
||||||
@ -53,11 +77,14 @@ function createStorage(): Storage {
|
|||||||
* same default value should be used for an observable every time it's created.
|
* same default value should be used for an observable every time it's created.
|
||||||
*/
|
*/
|
||||||
export function localStorageBoolObs(key: string, defValue = false): Observable<boolean> {
|
export function localStorageBoolObs(key: string, defValue = false): Observable<boolean> {
|
||||||
const store = getStorage();
|
return getStorageBoolObs(getStorage(), key, defValue);
|
||||||
const storedNegation = defValue ? 'false' : 'true';
|
}
|
||||||
const obs = Observable.create(null, store.getItem(key) === storedNegation ? !defValue : defValue);
|
|
||||||
obs.addListener((val) => val === defValue ? store.removeItem(key) : store.setItem(key, storedNegation));
|
/**
|
||||||
return obs;
|
* Similar to `localStorageBoolObs`, but always uses sessionStorage (or an in-memory equivalent).
|
||||||
|
*/
|
||||||
|
export function sessionStorageBoolObs(key: string, defValue = false): Observable<boolean> {
|
||||||
|
return getStorageBoolObs(getSessionStorage(), key, defValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,13 +14,13 @@ import {bigBasicButton} from 'app/client/ui2018/buttons';
|
|||||||
import {testId} from 'app/client/ui2018/cssVars';
|
import {testId} from 'app/client/ui2018/cssVars';
|
||||||
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {DataLimitStatus} from 'app/common/ActiveDocAPI';
|
|
||||||
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
||||||
import {delay} from 'app/common/delay';
|
import {delay} from 'app/common/delay';
|
||||||
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
||||||
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||||
import {getReconnectTimeout} from 'app/common/gutil';
|
import {getReconnectTimeout} from 'app/common/gutil';
|
||||||
import {canEdit} from 'app/common/roles';
|
import {canEdit} from 'app/common/roles';
|
||||||
|
import {DataLimitStatus, RowCount} from 'app/common/Usage';
|
||||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||||
import {Holder, Observable, subscribe} from 'grainjs';
|
import {Holder, Observable, subscribe} from 'grainjs';
|
||||||
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
||||||
@ -66,7 +66,7 @@ export interface DocPageModel {
|
|||||||
|
|
||||||
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
|
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
|
||||||
|
|
||||||
rowCount: Observable<number|undefined>;
|
rowCount: Observable<RowCount>;
|
||||||
dataLimitStatus: Observable<DataLimitStatus|undefined>;
|
dataLimitStatus: Observable<DataLimitStatus|undefined>;
|
||||||
|
|
||||||
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
|
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
|
||||||
@ -109,7 +109,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
// Observable set to the instance of GristDoc once it's created.
|
// Observable set to the instance of GristDoc once it's created.
|
||||||
public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
|
public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
|
||||||
|
|
||||||
public readonly rowCount = Observable.create<number|undefined>(this, undefined);
|
public readonly rowCount = Observable.create<RowCount>(this, 'pending');
|
||||||
public readonly dataLimitStatus = Observable.create<DataLimitStatus|undefined>(this, null);
|
public readonly dataLimitStatus = Observable.create<DataLimitStatus|undefined>(this, null);
|
||||||
|
|
||||||
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the
|
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
|
||||||
import {domAsync} from 'app/client/lib/domAsync';
|
import {domAsync} from 'app/client/lib/domAsync';
|
||||||
import {loadBillingPage} from 'app/client/lib/imports';
|
import {loadBillingPage} from 'app/client/lib/imports';
|
||||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||||
@ -149,6 +150,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
|
|||||||
contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()),
|
contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()),
|
||||||
onResize,
|
onResize,
|
||||||
testId,
|
testId,
|
||||||
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen)
|
contentTop: dom.create(DocUsageBanner, pageModel),
|
||||||
|
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ export interface PageContents {
|
|||||||
|
|
||||||
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
||||||
testId?: TestId;
|
testId?: TestId;
|
||||||
|
contentTop?: DomArg;
|
||||||
contentBottom?: DomArg;
|
contentBottom?: DomArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +63,8 @@ export function pagePanels(page: PageContents) {
|
|||||||
return cssPageContainer(
|
return cssPageContainer(
|
||||||
dom.autoDispose(sub1),
|
dom.autoDispose(sub1),
|
||||||
dom.autoDispose(sub2),
|
dom.autoDispose(sub2),
|
||||||
|
page.contentTop,
|
||||||
|
cssContentMain(
|
||||||
cssLeftPane(
|
cssLeftPane(
|
||||||
testId('left-panel'),
|
testId('left-panel'),
|
||||||
cssTopHeader(left.header),
|
cssTopHeader(left.header),
|
||||||
@ -176,6 +179,7 @@ export function pagePanels(page: PageContents) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,11 +194,10 @@ const cssVBox = styled('div', `
|
|||||||
const cssHBox = styled('div', `
|
const cssHBox = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
`);
|
`);
|
||||||
const cssPageContainer = styled(cssHBox, `
|
const cssPageContainer = styled(cssVBox, `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
isolation: isolate; /* Create a new stacking context */
|
isolation: isolate; /* Create a new stacking context */
|
||||||
z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */
|
z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */
|
||||||
overflow: hidden;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@ -212,7 +215,10 @@ const cssPageContainer = styled(cssHBox, `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
const cssContentMain = styled(cssHBox, `
|
||||||
|
flex: 1 1 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
`);
|
||||||
export const cssLeftPane = styled(cssVBox, `
|
export const cssLeftPane = styled(cssVBox, `
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: ${colors.lightGrey};
|
background-color: ${colors.lightGrey};
|
||||||
|
@ -365,6 +365,7 @@ export const cssModalTitle = styled('div', `
|
|||||||
color: ${colors.dark};
|
color: ${colors.dark};
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px 0;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const cssModalBody = styled('div', `
|
export const cssModalBody = styled('div', `
|
||||||
|
@ -153,8 +153,6 @@ export interface PermissionDataWithExtraUsers extends PermissionData {
|
|||||||
exampleUsers: UserAccessData[];
|
exampleUsers: UserAccessData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataLimitStatus = null | 'approachingLimit' | 'gracePeriod' | 'deleteOnly';
|
|
||||||
|
|
||||||
export interface ActiveDocAPI {
|
export interface ActiveDocAPI {
|
||||||
/**
|
/**
|
||||||
* Closes a document, and unsubscribes from its userAction events.
|
* Closes a document, and unsubscribes from its userAction events.
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import {MinimalActionGroup} from 'app/common/ActionGroup';
|
import {MinimalActionGroup} from 'app/common/ActionGroup';
|
||||||
import {DataLimitStatus} from 'app/common/ActiveDocAPI';
|
|
||||||
import {TableDataAction} from 'app/common/DocActions';
|
import {TableDataAction} from 'app/common/DocActions';
|
||||||
import {Role} from 'app/common/roles';
|
import {Role} from 'app/common/roles';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
|
import {DataLimitStatus, RowCount} from 'app/common/Usage';
|
||||||
import {FullUser} from 'app/common/UserAPI';
|
import {FullUser} from 'app/common/UserAPI';
|
||||||
|
|
||||||
// Possible flavors of items in a list of documents.
|
// Possible flavors of items in a list of documents.
|
||||||
@ -43,9 +43,9 @@ export interface OpenLocalDocResult {
|
|||||||
clientId: string; // the docFD is meaningful only in the context of this session
|
clientId: string; // the docFD is meaningful only in the context of this session
|
||||||
doc: {[tableId: string]: TableDataAction};
|
doc: {[tableId: string]: TableDataAction};
|
||||||
log: MinimalActionGroup[];
|
log: MinimalActionGroup[];
|
||||||
|
rowCount: RowCount;
|
||||||
recoveryMode?: boolean;
|
recoveryMode?: boolean;
|
||||||
userOverride?: UserOverride;
|
userOverride?: UserOverride;
|
||||||
rowCount?: number;
|
|
||||||
dataLimitStatus?: DataLimitStatus;
|
dataLimitStatus?: DataLimitStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
6
app/common/Usage.ts
Normal file
6
app/common/Usage.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type RowCount = number | 'hidden' | 'pending';
|
||||||
|
|
||||||
|
export type DataLimitStatus = null | 'approachingLimit' | 'gracePeriod' | 'deleteOnly';
|
||||||
|
|
||||||
|
// Ratio of the row/data size limit where we tell users that they're approaching the limit.
|
||||||
|
export const APPROACHING_LIMIT_RATIO = 0.9;
|
@ -50,6 +50,11 @@ export function capitalize(str: string): string {
|
|||||||
return str.replace(/\b[a-z]/gi, c => c.toUpperCase());
|
return str.replace(/\b[a-z]/gi, c => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capitalizes the first word in a string.
|
||||||
|
export function capitalizeFirstWord(str: string): string {
|
||||||
|
return str.replace(/\b[a-z]/i, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
// Returns whether the string n represents a valid number.
|
// Returns whether the string n represents a valid number.
|
||||||
// http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric
|
// http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric
|
||||||
export function isNumber(n: string): boolean {
|
export function isNumber(n: string): boolean {
|
||||||
|
@ -10,7 +10,6 @@ import {ActionSummary} from "app/common/ActionSummary";
|
|||||||
import {
|
import {
|
||||||
ApplyUAOptions,
|
ApplyUAOptions,
|
||||||
ApplyUAResult,
|
ApplyUAResult,
|
||||||
DataLimitStatus,
|
|
||||||
DataSourceTransformed,
|
DataSourceTransformed,
|
||||||
ForkResult,
|
ForkResult,
|
||||||
ImportOptions,
|
ImportOptions,
|
||||||
@ -41,9 +40,11 @@ import {Features} from 'app/common/Features';
|
|||||||
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
||||||
import {byteString, countIf, safeJsonParse} from 'app/common/gutil';
|
import {byteString, countIf, safeJsonParse} from 'app/common/gutil';
|
||||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||||
|
import {canEdit} from 'app/common/roles';
|
||||||
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
||||||
import {MetaRowRecord} from 'app/common/TableData';
|
import {MetaRowRecord} from 'app/common/TableData';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
|
import {APPROACHING_LIMIT_RATIO, DataLimitStatus, RowCount} from 'app/common/Usage';
|
||||||
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
||||||
import {convertFromColumn} from 'app/common/ValueConverter';
|
import {convertFromColumn} from 'app/common/ValueConverter';
|
||||||
import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
|
import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
|
||||||
@ -121,9 +122,6 @@ const REMOVE_UNUSED_ATTACHMENTS_INTERVAL_MS = 60 * 60 * 1000;
|
|||||||
// A hook for dependency injection.
|
// A hook for dependency injection.
|
||||||
export const Deps = {ACTIVEDOC_TIMEOUT};
|
export const Deps = {ACTIVEDOC_TIMEOUT};
|
||||||
|
|
||||||
// Ratio of the row/data size limit where we tell users that they're approaching the limit
|
|
||||||
const APPROACHING_LIMIT_RATIO = 0.9;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an active document with the given name. The document isn't actually open until
|
* Represents an active document with the given name. The document isn't actually open until
|
||||||
* either .loadDoc() or .createEmptyDoc() is called.
|
* either .loadDoc() or .createEmptyDoc() is called.
|
||||||
@ -172,7 +170,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured.
|
private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured.
|
||||||
private _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured.
|
private _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured.
|
||||||
private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);
|
private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);
|
||||||
private _rowCount?: number;
|
private _rowCount: RowCount = 'pending';
|
||||||
private _dataSize?: number;
|
private _dataSize?: number;
|
||||||
private _productFeatures?: Features;
|
private _productFeatures?: Features;
|
||||||
private _gracePeriodStart: Date|null = null;
|
private _gracePeriodStart: Date|null = null;
|
||||||
@ -237,11 +235,21 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
public get isShuttingDown(): boolean { return this._shuttingDown; }
|
public get isShuttingDown(): boolean { return this._shuttingDown; }
|
||||||
|
|
||||||
public get rowLimitRatio() {
|
public get rowLimitRatio() {
|
||||||
return this._rowLimit && this._rowCount ? this._rowCount / this._rowLimit : 0;
|
if (!this._rowLimit || this._rowLimit <= 0 || typeof this._rowCount !== 'number') {
|
||||||
|
// Invalid row limits are currently treated as if they are undefined.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._rowCount / this._rowLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get dataSizeLimitRatio() {
|
public get dataSizeLimitRatio() {
|
||||||
return this._dataSizeLimit && this._dataSize ? this._dataSize / this._dataSizeLimit : 0;
|
if (!this._dataSizeLimit || this._dataSizeLimit <= 0 || !this._dataSize) {
|
||||||
|
// Invalid data size limits are currently treated as if they are undefined.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._dataSize / this._dataSizeLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get dataLimitRatio() {
|
public get dataLimitRatio() {
|
||||||
@ -264,15 +272,15 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRowCount(docSession: OptDocSession): Promise<number | undefined> {
|
public async getRowCount(docSession: OptDocSession): Promise<RowCount> {
|
||||||
if (await this._granularAccess.canReadEverything(docSession)) {
|
const hasFullReadAccess = await this._granularAccess.canReadEverything(docSession);
|
||||||
return this._rowCount;
|
const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession));
|
||||||
}
|
return hasFullReadAccess && hasEditRole ? this._rowCount : 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDataLimitStatus(): Promise<DataLimitStatus> {
|
public async getDataLimitStatus(docSession: OptDocSession): Promise<DataLimitStatus> {
|
||||||
// TODO filter based on session permissions
|
const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession));
|
||||||
return this.dataLimitStatus;
|
return hasEditRole ? this.dataLimitStatus : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUserOverride(docSession: OptDocSession) {
|
public async getUserOverride(docSession: OptDocSession) {
|
||||||
|
@ -318,7 +318,7 @@ export class DocManager extends EventEmitter {
|
|||||||
activeDoc.getRecentMinimalActions(docSession),
|
activeDoc.getRecentMinimalActions(docSession),
|
||||||
activeDoc.getUserOverride(docSession),
|
activeDoc.getUserOverride(docSession),
|
||||||
activeDoc.getRowCount(docSession),
|
activeDoc.getRowCount(docSession),
|
||||||
activeDoc.getDataLimitStatus(),
|
activeDoc.getDataLimitStatus(docSession),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
|
@ -244,7 +244,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
// An alternative to this check would be to sandwich user-defined access rules
|
// An alternative to this check would be to sandwich user-defined access rules
|
||||||
// between some defaults. Currently the defaults have lower priority than
|
// between some defaults. Currently the defaults have lower priority than
|
||||||
// user-defined access rules.
|
// user-defined access rules.
|
||||||
if (!canEdit(await this._getNominalAccess(docSession))) {
|
if (!canEdit(await this.getNominalAccess(docSession))) {
|
||||||
throw new ErrorWithCode('ACL_DENY', 'Only owners or editors can modify documents');
|
throw new ErrorWithCode('ACL_DENY', 'Only owners or editors can modify documents');
|
||||||
}
|
}
|
||||||
if (this._ruler.haveRules()) {
|
if (this._ruler.haveRules()) {
|
||||||
@ -578,7 +578,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
* permissions.
|
* permissions.
|
||||||
*/
|
*/
|
||||||
public async canReadEverything(docSession: OptDocSession): Promise<boolean> {
|
public async canReadEverything(docSession: OptDocSession): Promise<boolean> {
|
||||||
const access = await this._getNominalAccess(docSession);
|
const access = await this.getNominalAccess(docSession);
|
||||||
if (!canView(access)) { return false; }
|
if (!canView(access)) { return false; }
|
||||||
const permInfo = await this._getAccess(docSession);
|
const permInfo = await this._getAccess(docSession);
|
||||||
return this.getReadPermission(permInfo.getFullAccess()) === 'allow';
|
return this.getReadPermission(permInfo.getFullAccess()) === 'allow';
|
||||||
@ -621,7 +621,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
* Check whether user has owner-level access to the document.
|
* Check whether user has owner-level access to the document.
|
||||||
*/
|
*/
|
||||||
public async isOwner(docSession: OptDocSession): Promise<boolean> {
|
public async isOwner(docSession: OptDocSession): Promise<boolean> {
|
||||||
const access = await this._getNominalAccess(docSession);
|
const access = await this.getNominalAccess(docSession);
|
||||||
return access === 'owners';
|
return access === 'owners';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -769,6 +769,23 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the role the session user has for this document. User may be overridden,
|
||||||
|
* in which case the role of the override is returned.
|
||||||
|
* The forkingAsOwner flag of docSession should not be respected for non-owners,
|
||||||
|
* so that the pseudo-ownership it offers is restricted to granular access within a
|
||||||
|
* document (as opposed to document-level operations).
|
||||||
|
*/
|
||||||
|
public async getNominalAccess(docSession: OptDocSession): Promise<Role|null> {
|
||||||
|
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
|
||||||
|
const baseAccess = getDocSessionAccess(docSession);
|
||||||
|
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
|
||||||
|
const info = await this._getUser(docSession);
|
||||||
|
return info.Access;
|
||||||
|
}
|
||||||
|
return baseAccess;
|
||||||
|
}
|
||||||
|
|
||||||
// AddOrUpdateRecord requires broad read access to a table.
|
// AddOrUpdateRecord requires broad read access to a table.
|
||||||
// But tables can be renamed, and access can be granted and removed
|
// But tables can be renamed, and access can be granted and removed
|
||||||
// within a bundle.
|
// within a bundle.
|
||||||
@ -824,23 +841,6 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the role the session user has for this document. User may be overridden,
|
|
||||||
* in which case the role of the override is returned.
|
|
||||||
* The forkingAsOwner flag of docSession should not be respected for non-owners,
|
|
||||||
* so that the pseudo-ownership it offers is restricted to granular access within a
|
|
||||||
* document (as opposed to document-level operations).
|
|
||||||
*/
|
|
||||||
private async _getNominalAccess(docSession: OptDocSession): Promise<Role> {
|
|
||||||
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
|
|
||||||
const baseAccess = getDocSessionAccess(docSession);
|
|
||||||
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
|
|
||||||
const info = await this._getUser(docSession);
|
|
||||||
return info.Access as Role;
|
|
||||||
}
|
|
||||||
return baseAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that user has schema access.
|
* Asserts that user has schema access.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user