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:
parent
42b4768b59
commit
f7a675ea2d
@ -30,6 +30,7 @@
|
|||||||
"color": "^4.0.1",
|
"color": "^4.0.1",
|
||||||
"copy-webpack-plugin": "^12.0.2",
|
"copy-webpack-plugin": "^12.0.2",
|
||||||
"css-loader": "^6.3.0",
|
"css-loader": "^6.3.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"del-cli": "^4.0.1",
|
"del-cli": "^4.0.1",
|
||||||
"diff": "^5.2.0",
|
"diff": "^5.2.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
|
|||||||
@ -3,4 +3,5 @@ import "./archived-items";
|
|||||||
import "./browser-profiles";
|
import "./browser-profiles";
|
||||||
import "./collections";
|
import "./collections";
|
||||||
import "./crawl-workflows";
|
import "./crawl-workflows";
|
||||||
|
import "./org";
|
||||||
import "./qa";
|
import "./qa";
|
||||||
|
|||||||
1
frontend/src/features/org/index.ts
Normal file
1
frontend/src/features/org/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import("./org-status-banner");
|
||||||
241
frontend/src/features/org/org-status-banner.ts
Normal file
241
frontend/src/features/org/org-status-banner.ts
Normal 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.`,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -116,6 +116,9 @@ export class Org extends LiteElement {
|
|||||||
@state()
|
@state()
|
||||||
private orgStorageQuotaReached = false;
|
private orgStorageQuotaReached = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private showReadOnlyAlert = false;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private showStorageQuotaAlert = false;
|
private showStorageQuotaAlert = false;
|
||||||
|
|
||||||
@ -238,6 +241,11 @@ export class Org extends LiteElement {
|
|||||||
if (!this.userInfo || !this.orgId) return;
|
if (!this.userInfo || !this.orgId) return;
|
||||||
try {
|
try {
|
||||||
this.org = await this.getOrg(this.orgId);
|
this.org = await this.getOrg(this.orgId);
|
||||||
|
|
||||||
|
this.showReadOnlyAlert = Boolean(
|
||||||
|
this.org?.readOnly || this.org?.subscription?.futureCancelDate,
|
||||||
|
);
|
||||||
|
|
||||||
this.checkStorageQuota();
|
this.checkStorageQuota();
|
||||||
this.checkExecutionMinutesQuota();
|
this.checkExecutionMinutesQuota();
|
||||||
} catch {
|
} catch {
|
||||||
@ -320,7 +328,7 @@ export class Org extends LiteElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="flex min-h-full flex-col">
|
<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()}
|
${this.renderOrgNavBar()}
|
||||||
<main
|
<main
|
||||||
class="${noMaxWidth
|
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() {
|
private renderOrgNavBar() {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import type { Range } from "./utils";
|
|||||||
// From UserRole in backend
|
// From UserRole in backend
|
||||||
export type UserRole = "viewer" | "crawler" | "owner" | "superadmin";
|
export type UserRole = "viewer" | "crawler" | "owner" | "superadmin";
|
||||||
|
|
||||||
|
export enum OrgReadOnlyReason {
|
||||||
|
SubscriptionPaused = "subscriptionPaused",
|
||||||
|
SubscriptionCancelled = "subscriptionCancelled",
|
||||||
|
}
|
||||||
|
|
||||||
export const AccessCode: Record<UserRole, number> = {
|
export const AccessCode: Record<UserRole, number> = {
|
||||||
superadmin: 100,
|
superadmin: 100,
|
||||||
viewer: 10,
|
viewer: 10,
|
||||||
@ -50,7 +55,8 @@ export type OrgData = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
readOnly: boolean | null;
|
readOnly: boolean | null;
|
||||||
readOnlyReason: string | null;
|
readOnlyReason: OrgReadOnlyReason | string | null;
|
||||||
|
readOnlyOnCancel: boolean;
|
||||||
subscription: null | Subscription;
|
subscription: null | Subscription;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
|
||||||
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
|
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:
|
debounce@^1.2.0, debounce@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user