feat: Show single org status alert banner (#1937)

Resolves #1876

### Changes

Displays single banner for critical org alerts.

---------
Co-authored-by: Ilya Kreymer <ikreymer@users.noreply.github.com>
Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
This commit is contained in:
sua yoo 2024-07-17 18:50:53 -07:00 committed by GitHub
parent 42b4768b59
commit f7a675ea2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 265 additions and 58 deletions

View File

@ -30,6 +30,7 @@
"color": "^4.0.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.3.0",
"date-fns": "^3.6.0",
"del-cli": "^4.0.1",
"diff": "^5.2.0",
"dotenv": "^10.0.0",

View File

@ -3,4 +3,5 @@ import "./archived-items";
import "./browser-profiles";
import "./collections";
import "./crawl-workflows";
import "./org";
import "./qa";

View File

@ -0,0 +1 @@
import("./org-status-banner");

View File

@ -0,0 +1,241 @@
import { localized, msg, str } from "@lit/localize";
import { differenceInDays } from "date-fns/fp";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { NavigateController } from "@/controllers/navigate";
import { OrgReadOnlyReason, type OrgData } from "@/types/org";
import { formatISODateString } from "@/utils/localization";
import appState, { use } from "@/utils/state";
type Alert = {
test: () => boolean;
persist?: boolean;
content: () => {
title: string | TemplateResult;
detail: string | TemplateResult;
};
};
@localized()
@customElement("btrix-org-status-banner")
export class OrgStatusBanner extends TailwindElement {
@property({ type: Object })
org?: OrgData;
@use()
appState = appState;
@state()
isAlertOpen = false;
private readonly navigate = new NavigateController(this);
private alert?: Alert;
protected willUpdate(_changedProperties: PropertyValues): void {
if (_changedProperties.has("org") && this.org) {
this.alert = this.alerts.find(({ test }) => test());
if (this.alert) {
this.isAlertOpen = true;
}
}
}
render() {
if (!this.org) return;
return html`
<div
class="${this.isAlertOpen
? "bg-slate-100 border-b py-5"
: ""} transition-all"
>
<div class="mx-auto box-border w-full max-w-screen-desktop px-3">
<sl-alert
variant="danger"
?closable=${!this.alert?.persist}
?open=${this.isAlertOpen}
@sl-after-hide=${() => (this.isAlertOpen = false)}
>
<sl-icon slot="icon" name="exclamation-triangle-fill"></sl-icon>
${this.renderContent()}
</sl-alert>
</div>
</div>
`;
}
private renderContent() {
if (!this.alert || !this.org) return;
const content = this.alert.content();
return html`
<strong class="block font-semibold">${content.title}</strong>
${content.detail}
`;
}
/**
* Alerts ordered by priority
*/
private get alerts(): Alert[] {
if (!this.org) return [];
const billingTabLink = html`<a
class="underline hover:no-underline"
href=${`${this.navigate.orgBasePath}/settings/billing`}
@click=${this.navigate.link}
>${msg("billing settings")}</a
>`;
const {
readOnly,
readOnlyReason,
readOnlyOnCancel,
subscription,
storageQuotaReached,
execMinutesQuotaReached,
} = this.org;
return [
{
test: () =>
!readOnly && !readOnlyOnCancel && !!subscription?.futureCancelDate,
persist: true,
content: () => {
const daysDiff = differenceInDays(
new Date(),
new Date(subscription!.futureCancelDate!),
);
return {
title:
daysDiff > 1
? msg(
str`Your org will be deleted in
${daysDiff} days`,
)
: `Your org will be deleted within one day`,
detail: html`
<p>
${msg(
str`Your subscription ends on ${formatISODateString(
subscription!.futureCancelDate!,
{
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
},
)}. Your user account, org, and all associated data will be deleted.`,
)}
</p>
<p>
${msg(
html`We suggest downloading your archived items before they
are deleted. To keep your plan and data, see
${billingTabLink}.`,
)}
</p>
`,
};
},
},
{
test: () =>
!readOnly && readOnlyOnCancel && !!subscription?.futureCancelDate,
persist: true,
content: () => {
const daysDiff = differenceInDays(
new Date(),
new Date(subscription!.futureCancelDate!),
);
return {
title:
daysDiff > 1
? msg(
str`Your org will be set to read-only mode in ${daysDiff} days`,
)
: msg("Your org will be set to read-only mode within one day"),
detail: html`
<p>
${msg(
str`Your subscription ends on ${formatISODateString(
subscription!.futureCancelDate!,
{
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
},
)}. You will no longer be able to run crawls, upload files, create browser profiles, or create collections.`,
)}
</p>
<p>
${msg(
html`To keep your plan and continue crawling, see
${billingTabLink}.`,
)}
</p>
`,
};
},
},
{
test: () =>
!!readOnly && readOnlyReason === OrgReadOnlyReason.SubscriptionPaused,
persist: true,
content: () => ({
title: msg(str`Your org has been set to read-only mode`),
detail: msg(
html`Your subscription has been paused due to payment failure.
Please go to ${billingTabLink} to update your payment method.`,
),
}),
},
{
test: () =>
!!readOnly &&
readOnlyReason === OrgReadOnlyReason.SubscriptionCancelled,
persist: true,
content: () => ({
title: msg(str`This org has been set to read-only mode`),
detail: msg(
`Your subscription has been canceled. Please contact Browsertrix support to renew your plan.`,
),
}),
},
{
test: () => !!readOnly,
persist: true,
content: () => ({
title: msg(str`This org has been set to read-only mode`),
detail: msg(`Please contact Browsertrix support to renew your plan.`),
}),
},
{
test: () => !readOnly && !!storageQuotaReached,
content: () => ({
title: msg(str`Your org has reached its storage limit`),
detail: msg(
str`To add archived items again, delete unneeded items and unused browser profiles to free up space, or contact ${this.appState.settings?.salesEmail || msg("Browsertrix host administrator")} to upgrade your storage plan.`,
),
}),
},
{
test: () => !readOnly && !!execMinutesQuotaReached,
content: () => ({
title: msg(
str`Your org has reached its monthly execution minutes limit`,
),
detail: msg(
str`Contact ${this.appState.settings?.salesEmail || msg("Browsertrix host administrator")} to purchase additional monthly execution minutes or upgrade your plan.`,
),
}),
},
];
}
}

View File

@ -116,6 +116,9 @@ export class Org extends LiteElement {
@state()
private orgStorageQuotaReached = false;
@state()
private showReadOnlyAlert = false;
@state()
private showStorageQuotaAlert = false;
@ -238,6 +241,11 @@ export class Org extends LiteElement {
if (!this.userInfo || !this.orgId) return;
try {
this.org = await this.getOrg(this.orgId);
this.showReadOnlyAlert = Boolean(
this.org?.readOnly || this.org?.subscription?.futureCancelDate,
);
this.checkStorageQuota();
this.checkExecutionMinutesQuota();
} catch {
@ -320,7 +328,7 @@ export class Org extends LiteElement {
return html`
<div class="flex min-h-full flex-col">
${this.renderStorageAlert()} ${this.renderExecutionMinutesAlert()}
<btrix-org-status-banner .org=${this.org}></btrix-org-status-banner>
${this.renderOrgNavBar()}
<main
class="${noMaxWidth
@ -335,62 +343,6 @@ export class Org extends LiteElement {
`;
}
private renderStorageAlert() {
return html`
<div
class="${this.showStorageQuotaAlert
? "bg-slate-100 border-b py-5"
: ""} transition-all"
>
<div class="mx-auto box-border w-full max-w-screen-desktop px-3">
<sl-alert
variant="warning"
closable
?open=${this.showStorageQuotaAlert}
@sl-after-hide=${() => (this.showStorageQuotaAlert = false)}
>
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
<strong>${msg("Your org has reached its storage limit")}</strong
><br />
${msg(
"To add archived items again, delete unneeded items and unused browser profiles to free up space, or contact us to upgrade your storage plan.",
)}
</sl-alert>
</div>
</div>
`;
}
private renderExecutionMinutesAlert() {
return html`
<div
class="${this.showExecutionMinutesQuotaAlert
? "bg-slate-100 border-b py-5"
: ""} transition-all"
>
<div class="mx-auto box-border w-full max-w-screen-desktop px-3">
<sl-alert
variant="warning"
closable
?open=${this.showExecutionMinutesQuotaAlert}
@sl-after-hide=${() =>
(this.showExecutionMinutesQuotaAlert = false)}
>
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
<strong
>${msg(
"Your org has reached its monthly execution minutes limit",
)}</strong
><br />
${msg(
"To purchase additional monthly execution minutes, contact us to upgrade your plan.",
)}
</sl-alert>
</div>
</div>
`;
}
private renderOrgNavBar() {
return html`
<div

View File

@ -4,6 +4,11 @@ import type { Range } from "./utils";
// From UserRole in backend
export type UserRole = "viewer" | "crawler" | "owner" | "superadmin";
export enum OrgReadOnlyReason {
SubscriptionPaused = "subscriptionPaused",
SubscriptionCancelled = "subscriptionCancelled",
}
export const AccessCode: Record<UserRole, number> = {
superadmin: 100,
viewer: 10,
@ -50,7 +55,8 @@ export type OrgData = {
};
};
readOnly: boolean | null;
readOnlyReason: string | null;
readOnlyReason: OrgReadOnlyReason | string | null;
readOnlyOnCancel: boolean;
subscription: null | Subscription;
};

View File

@ -3501,6 +3501,11 @@ data-uri-to-buffer@^4.0.0:
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
date-fns@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
debounce@^1.2.0, debounce@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz"