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", | ||||
|     "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", | ||||
|  | ||||
| @ -3,4 +3,5 @@ import "./archived-items"; | ||||
| import "./browser-profiles"; | ||||
| import "./collections"; | ||||
| import "./crawl-workflows"; | ||||
| import "./org"; | ||||
| 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() | ||||
|   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 | ||||
|  | ||||
| @ -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; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user