import { state, property, customElement } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; import { when } from "lit/directives/when.js"; import { ifDefined } from "lit/directives/if-defined.js"; import type { ViewState } from "../../utils/APIRouter"; import type { AuthState } from "../../utils/AuthService"; import type { CurrentUser } from "../../types/user"; import type { Crawl, JobType } from "../../types/crawler"; import type { OrgData } from "../../utils/orgs"; import { isAdmin, isCrawler } from "../../utils/orgs"; import LiteElement, { html } from "../../utils/LiteElement"; import { needLogin } from "../../utils/auth"; import "./workflow-detail"; import "./workflows-list"; import "./workflows-new"; import "./crawl-detail"; import "./crawls-list"; import "./collections-list"; import "./collections-new"; import "./collection-edit"; import "./collection-detail"; import "./browser-profiles-detail"; import "./browser-profiles-list"; import "./browser-profiles-new"; import "./settings"; import "./dashboard"; import "./components/file-uploader"; import "./components/new-browser-profile-dialog"; import "./components/new-collection-dialog"; import "./components/new-workflow-dialog"; import type { Member, OrgInfoChangeEvent, UserRoleChangeEvent, OrgRemoveMemberEvent, } from "./settings"; import type { Tab as CollectionTab } from "./collection-detail"; import type { SelectJobTypeEvent } from "./components/new-workflow-dialog"; const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"]; type ResourceName = (typeof RESOURCE_NAMES)[number]; export type SelectNewDialogEvent = CustomEvent; export type OrgTab = | "home" | "crawls" | "workflows" | "items" | "browser-profiles" | "collections" | "settings"; type Params = { workflowId?: string; browserProfileId?: string; browserId?: string; itemId?: string; collectionId?: string; collectionTab?: string; itemType?: Crawl["type"]; jobType?: JobType; settingsTab?: "information" | "members"; new?: ResourceName; }; const defaultTab = "home"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @needLogin @localized() @customElement("btrix-org") export class Org extends LiteElement { @property({ type: Object }) authState?: AuthState; @property({ type: Object }) userInfo?: CurrentUser; @property({ type: Object }) viewStateData?: ViewState["data"]; @property({ type: String }) slug!: string; // Path after `/orgs/:orgId/` @property({ type: String }) orgPath!: string; @property({ type: Object }) params!: Params; @property({ type: String }) orgTab: OrgTab = defaultTab; @state() private orgStorageQuotaReached = false; @state() private showStorageQuotaAlert = false; @state() private orgExecutionMinutesQuotaReached = false; @state() private showExecutionMinutesQuotaAlert = false; @state() private openDialogName?: ResourceName; @state() private isCreateDialogVisible = false; @state() private org?: OrgData | null; @state() private isSavingOrgInfo = false; get userOrg() { if (!this.userInfo) return null; return this.userInfo.orgs.find(({ slug }) => slug === this.slug)!; } get orgId() { return this.userOrg?.id || ""; } get isAdmin() { const userOrg = this.userOrg; if (userOrg) return isAdmin(userOrg.role); return false; } get isCrawler() { const userOrg = this.userOrg; if (userOrg) return isCrawler(userOrg.role); return false; } async willUpdate(changedProperties: Map) { if ( (changedProperties.has("userInfo") && this.userInfo) || (changedProperties.has("slug") && this.slug) ) { this.updateOrg(); } if (changedProperties.has("openDialogName")) { // Sync URL to create dialog const url = new URL(window.location.href); if (this.openDialogName) { if (url.searchParams.get("new") !== this.openDialogName) { url.searchParams.set("new", this.openDialogName); this.navTo(`${url.pathname}${url.search}`); } } else { const prevOpenDialogName = changedProperties.get("openDialogName"); if ( prevOpenDialogName && prevOpenDialogName === url.searchParams.get("new") ) { url.searchParams.delete("new"); this.navTo(`${url.pathname}${url.search}`); } } } } private async updateOrg() { if (!this.userInfo || !this.orgId) return; try { this.org = await this.getOrg(this.orgId); this.checkStorageQuota(); this.checkExecutionMinutesQuota(); } catch { // TODO handle 404 this.org = null; this.notify({ message: msg("Sorry, couldn't retrieve organization at this time."), variant: "danger", icon: "exclamation-octagon", }); } } async firstUpdated() { // if slug is actually an orgId (UUID), attempt to lookup the slug // and redirect to the slug url if (UUID_REGEX.test(this.slug)) { const org = await this.getOrg(this.slug); const actualSlug = org && org.slug; if (actualSlug) { this.navTo( window.location.href .slice(window.location.origin.length) .replace(this.slug, actualSlug) ); return; } } // Sync URL to create dialog const url = new URL(window.location.href); const dialogName = url.searchParams.get("new"); if (dialogName && RESOURCE_NAMES.includes(dialogName)) { this.openDialogName = dialogName; this.isCreateDialogVisible = true; } } render() { if (this.org === null) { // TODO handle 404 and 500s return ""; } if (!this.org || !this.userInfo) { // TODO combine loading state with tab panel content return ""; } let tabPanelContent = "" as any; switch (this.orgTab) { case "home": tabPanelContent = this.renderDashboard(); break; case "items": tabPanelContent = this.renderArchive(); break; case "workflows": tabPanelContent = this.renderWorkflows(); break; case "browser-profiles": tabPanelContent = this.renderBrowserProfiles(); break; case "collections": tabPanelContent = this.renderCollections(); break; case "settings": { if (this.isAdmin) { tabPanelContent = this.renderOrgSettings(); break; } } default: tabPanelContent = html``; break; } return html` ${this.renderStorageAlert()} ${this.renderExecutionMinutesAlert()} ${this.renderOrgNavBar()}
${tabPanelContent}
${this.renderNewResourceDialogs()} `; } private renderStorageAlert() { return html`
(this.showStorageQuotaAlert = false)} > ${msg("Your org has reached its storage limit")}
${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." )}
`; } private renderExecutionMinutesAlert() { return html`
(this.showExecutionMinutesQuotaAlert = false)} > ${msg( "Your org has reached its monthly execution minutes limit" )}
${msg( "To purchase additional monthly execution minutes, contact us to upgrade your plan." )}
`; } private renderOrgNavBar() { return html`

`; } private renderNavTab({ tabName, label, path, }: { tabName: OrgTab; label: string; path: string; }) { const isActive = this.orgTab === tabName; return html`
${label}
`; } private renderNewResourceDialogs() { if (!this.authState || !this.orgId || !this.isCrawler) { return; } if (!this.isCreateDialogVisible) { return; } return html`
{ e.stopPropagation(); this.openDialogName = undefined; }} @sl-after-hide=${(e: CustomEvent) => { e.stopPropagation(); this.isCreateDialogVisible = false; }} > (this.openDialogName = undefined)} @uploaded=${() => { if (this.orgTab === "home") { this.navTo(`${this.orgBasePath}/items/upload`); } }} > { this.openDialogName = undefined; this.navTo(`${this.orgBasePath}/workflows?new&jobType=${e.detail}`); }} >
`; } private renderDashboard() { return html` `; } private renderArchive() { if (this.params.itemId) { return html` `; } return html``; } private renderWorkflows() { const isEditing = this.params.hasOwnProperty("edit"); const isNewResourceTab = this.params.hasOwnProperty("new") && this.params.jobType; const workflowId = this.params.workflowId; if (workflowId) { return html` `; } if (isNewResourceTab) { const { workflow, seeds } = this.viewStateData || {}; return html` `; } return html``; } private renderBrowserProfiles() { if (this.params.browserProfileId) { return html``; } if (this.params.browserId) { return html``; } return html``; } private renderCollections() { if (this.params.collectionId) { if (this.orgPath.includes(`/edit/${this.params.collectionId}`)) { return html``; } return html``; } return html``; } private renderOrgSettings() { if (!this.userInfo || !this.org) return; const activePanel = this.params.settingsTab || "information"; const isAddingMember = this.params.hasOwnProperty("invite"); return html``; } private async onSelectNewDialog(e: SelectNewDialogEvent) { e.stopPropagation(); this.isCreateDialogVisible = true; await this.updateComplete; this.openDialogName = e.detail; } private async getOrg(orgId: string): Promise { const data = await this.apiFetch( `/orgs/${orgId}`, this.authState! ); return data; } private async onOrgInfoChange(e: OrgInfoChangeEvent) { this.isSavingOrgInfo = true; try { await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, { method: "POST", body: JSON.stringify(e.detail), }); this.notify({ message: msg("Updated organization."), variant: "success", icon: "check2-circle", }); await this.dispatchEvent( new CustomEvent("update-user-info", { bubbles: true }) ); const newSlug = e.detail.slug; if (newSlug) { this.navTo(`/orgs/${newSlug}${this.orgPath}`); } } catch (e: any) { this.notify({ message: e.isApiError ? e.message : msg("Sorry, couldn't update organization at this time."), variant: "danger", icon: "exclamation-octagon", }); } this.isSavingOrgInfo = false; } private async onOrgRemoveMember(e: OrgRemoveMemberEvent) { this.removeMember(e.detail.member); } private async onStorageQuotaUpdate(e: CustomEvent) { e.stopPropagation(); const { reached } = e.detail; this.orgStorageQuotaReached = reached; if (reached) { this.showStorageQuotaAlert = true; } } private async onExecutionMinutesQuotaUpdate(e: CustomEvent) { e.stopPropagation(); const { reached } = e.detail; this.orgExecutionMinutesQuotaReached = reached; if (reached) { this.showExecutionMinutesQuotaAlert = true; } } private async onUserRoleChange(e: UserRoleChangeEvent) { const { user, newRole } = e.detail; try { await this.apiFetch(`/orgs/${this.orgId}/user-role`, this.authState!, { method: "PATCH", body: JSON.stringify({ email: user.email, role: newRole, }), }); this.notify({ message: msg( str`Successfully updated role for ${user.name || user.email}.` ), variant: "success", icon: "check2-circle", }); this.org = await this.getOrg(this.orgId); } catch (e: any) { console.debug(e); this.notify({ message: e.isApiError ? e.message : msg( str`Sorry, couldn't update role for ${ user.name || user.email } at this time.` ), variant: "danger", icon: "exclamation-octagon", }); } } private async removeMember(member: Member) { if (!this.org) return; const isSelf = member.email === this.userInfo!.email; if ( isSelf && !window.confirm( msg( str`Are you sure you want to remove yourself from ${this.org.name}?` ) ) ) { return; } try { await this.apiFetch(`/orgs/${this.orgId}/remove`, this.authState!, { method: "POST", body: JSON.stringify({ email: member.email, }), }); this.notify({ message: msg( str`Successfully removed ${member.name || member.email} from ${ this.org.name }.` ), variant: "success", icon: "check2-circle", }); if (isSelf) { // FIXME better UX, this is the only page currently that doesn't require org... this.navTo("/account/settings"); } else { this.org = await this.getOrg(this.orgId); } } catch (e: any) { console.debug(e); this.notify({ message: e.isApiError ? e.message : msg( str`Sorry, couldn't remove ${ member.name || member.email } at this time.` ), variant: "danger", icon: "exclamation-octagon", }); } } checkStorageQuota() { this.orgStorageQuotaReached = !!this.org?.storageQuotaReached; this.showStorageQuotaAlert = this.orgStorageQuotaReached; } checkExecutionMinutesQuota() { this.orgExecutionMinutesQuotaReached = !!this.org?.execMinutesQuotaReached; this.showExecutionMinutesQuotaAlert = this.orgExecutionMinutesQuotaReached; } }