import { provide } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import { html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import isEqual from "lodash/fp/isEqual"; import type { QATab } from "./archived-item-qa/types"; import type { Tab as CollectionTab } from "./collection-detail"; import type { Member, OrgRemoveMemberEvent, UserRoleChangeEvent, } from "./settings/settings"; import { BtrixElement } from "@/classes/BtrixElement"; import { proxiesContext, type ProxiesContext } from "@/context/org"; import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { OrgTab, RouteNamespace } from "@/routes"; import type { ProxiesAPIResponse } from "@/types/crawler"; import type { UserOrg } from "@/types/user"; import { isApiError } from "@/utils/api"; import type { ViewState } from "@/utils/APIRouter"; import { DEFAULT_MAX_SCALE } from "@/utils/crawler"; import { type OrgData } from "@/utils/orgs"; import { AppStateService } from "@/utils/state"; import type { FormState as WorkflowFormState } from "@/utils/workflow"; import "./workflow-detail"; import "./workflows-list"; import "./archived-item-detail"; import "./archived-items"; import "./collections-list"; import "./collection-detail"; import "./browser-profiles-detail"; import "./browser-profiles-list"; import "./settings/settings"; import "./dashboard"; import "./profile"; import(/* webpackChunkName: "org" */ "./archived-item-qa/archived-item-qa"); import(/* webpackChunkName: "org" */ "./workflows-new"); import(/* webpackChunkName: "org" */ "./browser-profiles-new"); const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"]; type ResourceName = (typeof RESOURCE_NAMES)[number]; export type SelectNewDialogEvent = CustomEvent; type ArchivedItemPageParams = { itemId?: string; workflowId?: string; collectionId?: string; }; export type OrgParams = { [OrgTab.Dashboard]: Record; [OrgTab.Workflows]: ArchivedItemPageParams & { scopeType?: WorkflowFormState["scopeType"]; new?: ResourceName; itemPageId?: string; qaTab?: QATab; qaRunId?: string; }; [OrgTab.Items]: ArchivedItemPageParams & { itemType?: string; qaTab?: QATab; }; [OrgTab.BrowserProfiles]: { browserProfileId?: string; browserId?: string; new?: ResourceName; name?: string; url?: string; description?: string; crawlerChannel?: string; profileId?: string; navigateUrl?: string; proxyId?: string; }; [OrgTab.Collections]: ArchivedItemPageParams & { collectionTab?: string; }; [OrgTab.Settings]: { settingsTab?: "information" | "members"; }; }; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @customElement("btrix-org") @localized() @needLogin export class Org extends BtrixElement { @provide({ context: proxiesContext }) proxies: ProxiesContext = null; @property({ type: Object }) viewStateData?: ViewState["data"]; // Path after `/orgs/:orgId/` @property({ type: String }) orgPath!: string; @property({ type: Object }) params: OrgParams[OrgTab] = {}; @property({ type: String }) orgTab?: OrgTab | string; @property({ type: Number }) maxScale: number = DEFAULT_MAX_SCALE; @state() private openDialogName?: ResourceName; @state() private isCreateDialogVisible = false; connectedCallback() { if ( !this.orgTab || !Object.values(OrgTab).includes(this.orgTab as OrgTab) ) { this.navigate.to(`${this.navigate.orgBasePath}/${OrgTab.Dashboard}`); } super.connectedCallback(); this.addEventListener( "btrix-execution-minutes-quota-update", this.onExecutionMinutesQuotaUpdate, ); this.addEventListener( "btrix-storage-quota-update", this.onStorageQuotaUpdate, ); } disconnectedCallback() { this.removeEventListener( "btrix-execution-minutes-quota-update", this.onExecutionMinutesQuotaUpdate, ); this.removeEventListener( "btrix-storage-quota-update", this.onStorageQuotaUpdate, ); super.disconnectedCallback(); } async willUpdate(changedProperties: Map) { if ( changedProperties.has("appState.orgSlug") && this.userInfo && this.orgSlug ) { if (this.userOrg) { void this.updateOrg(); void this.updateOrgProxies(); } else { // Couldn't find org with slug, redirect to first org const org = this.userInfo.orgs[0] as UserOrg | undefined; if (org) { this.navigate.to( `/${RouteNamespace.PrivateOrgs}/${org.slug}/${OrgTab.Dashboard}`, ); } else { this.navigate.to(`/account/settings`); } return; } } else if (changedProperties.has("orgTab") && this.orgId) { // Get most up to date org data void 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.navigate.to(`${url.pathname}${url.search}`); } } else { const prevOpenDialogName = changedProperties.get("openDialogName"); if ( prevOpenDialogName && prevOpenDialogName === url.searchParams.get("new") ) { url.searchParams.delete("new"); this.navigate.to(`${url.pathname}${url.search}`); } } } else if (changedProperties.has("params")) { const dialogName = this.getDialogName(); if (dialogName && !this.openDialogName) { this.openDialog(dialogName); } } } private async updateOrg(e?: CustomEvent) { if (e) { e.stopPropagation(); } if (!this.userInfo || !this.orgId) return; try { const org = await this.getOrg(this.orgId); if (!isEqual(this.org, org)) { AppStateService.updateOrg(org); } } catch (e) { console.debug(e); this.notify.toast({ message: msg("Sorry, couldn't retrieve organization at this time."), variant: "danger", icon: "exclamation-octagon", id: "org-retrieve-error", }); } } private async updateOrgProxies() { try { this.proxies = await this.getOrgProxies(this.orgId); } catch (e) { console.debug(e); } } async firstUpdated() { // if slug is actually an orgId (UUID), attempt to lookup the slug // and redirect to the slug url if (this.orgSlug && UUID_REGEX.test(this.orgSlug)) { const org = await this.getOrg(this.orgSlug); const actualSlug = org?.slug; if (actualSlug) { this.navigate.to( window.location.href .slice(window.location.origin.length) .replace(this.orgSlug, actualSlug), ); return; } } // Sync URL to create dialog const dialogName = this.getDialogName(); if (dialogName) this.openDialog(dialogName); void this.updateOrgProxies(); } private getDialogName() { const url = new URL(window.location.href); return url.searchParams.get("new"); } private openDialog(dialogName: string) { if (dialogName && RESOURCE_NAMES.includes(dialogName)) { this.openDialogName = dialogName; this.isCreateDialogVisible = true; } } render() { const noMaxWidth = (this.params as OrgParams["workflows"]).qaTab; return html`
${this.renderOrgNavBar()}
${when(this.userOrg, (userOrg) => choose( this.orgTab, [ [OrgTab.Dashboard, this.renderDashboard], [ OrgTab.Items, () => html` ${this.renderArchivedItem()} `, ], [ OrgTab.Workflows, () => html` ${this.renderWorkflows()} `, ], [ OrgTab.BrowserProfiles, () => html` ${this.renderBrowserProfiles()} `, ], [ OrgTab.Collections, () => html` ${this.renderCollections()} `, ], [ OrgTab.Settings, () => this.appState.isAdmin ? html` ${this.renderOrgSettings()} ` : nothing, ], ], () => html``, ), )}
${this.renderNewResourceDialogs()}
`; } private renderOrgNavBar() { return html`

`; } private renderNavTab({ tabName, label }: { tabName: OrgTab; label: string }) { const isActive = this.orgTab === tabName; return html`
${label}
`; } private renderNewResourceDialogs() { if (!this.orgId || !this.appState.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 === OrgTab.Dashboard) { this.navigate.to(`${this.navigate.orgBasePath}/items/upload`); } }} > (this.openDialogName = undefined)} > (this.openDialogName = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { this.navigate.to( `${this.navigate.orgBasePath}/collections/view/${e.detail.id}/items`, ); }} >
`; } private readonly renderDashboard = () => { return html` `; }; private readonly renderArchivedItem = () => { const params = this.params as OrgParams["items"]; if (params.itemId) { return html` `; } return html``; }; private readonly renderWorkflows = () => { const params = this.params as OrgParams["workflows"]; const isEditing = Object.prototype.hasOwnProperty.call(params, "edit"); const workflowId = params.workflowId; if (workflowId) { if (params.itemId) { if (params.qaTab) { if (!this.appState.isCrawler) { return html``; } return html``; } return html` `; } return html` `; } if (this.orgPath.startsWith("/workflows/new")) { const { workflow, seeds, scopeType } = this.viewStateData || {}; return html` `; } return html` { this.openDialogName = undefined; if (e.detail !== this.appState.userPreferences?.newWorkflowScopeType) { AppStateService.partialUpdateUserPreferences({ newWorkflowScopeType: e.detail, }); } this.navigate.to(`${this.navigate.orgBasePath}/workflows/new`, { scopeType: e.detail, }); }} >`; }; private readonly renderBrowserProfiles = () => { const params = this.params as OrgParams["browser-profiles"]; if (params.browserProfileId) { return html``; } if (params.browserId) { return html``; } return html``; }; private readonly renderCollections = () => { const params = this.params as OrgParams["collections"]; if (params.collectionId) { return html``; } return html``; }; private readonly renderOrgSettings = () => { const params = this.params as OrgParams["settings"]; const activePanel = params.settingsTab || "information"; const isAddingMember = Object.prototype.hasOwnProperty.call( this.params, "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.api.fetch(`/orgs/${orgId}`); return data; } private async getOrgProxies(orgId: string): Promise { return this.api.fetch( `/orgs/${orgId}/crawlconfigs/crawler-proxies`, ); } private async onOrgRemoveMember(e: OrgRemoveMemberEvent) { void this.removeMember(e.detail.member); } private async onStorageQuotaUpdate(e: CustomEvent) { e.stopPropagation(); if (!this.org) return; const { reached } = e.detail; AppStateService.partialUpdateOrg({ id: this.orgId, storageQuotaReached: reached, }); } private async onExecutionMinutesQuotaUpdate( e: CustomEvent, ) { e.stopPropagation(); if (!this.org) return; const { reached } = e.detail; AppStateService.partialUpdateOrg({ id: this.orgId, execMinutesQuotaReached: reached, }); } private async onUserRoleChange(e: UserRoleChangeEvent) { const { user, newRole } = e.detail; try { await this.api.fetch(`/orgs/${this.orgId}/user-role`, { method: "PATCH", body: JSON.stringify({ email: user.email, role: newRole, }), }); this.notify.toast({ message: msg( str`Successfully updated role for ${user.name || user.email}.`, ), variant: "success", icon: "check2-circle", id: "user-updated-status", }); const org = await this.getOrg(this.orgId); if (org) { AppStateService.partialUpdateOrg({ id: org.id, users: org.users, }); } } catch (e) { console.debug(e); this.notify.toast({ message: isApiError(e) ? e.message : msg( str`Sorry, couldn't update role for ${ user.name || user.email } at this time.`, ), variant: "danger", icon: "exclamation-octagon", id: "user-updated-status", }); } } private async removeMember(member: Member) { if (!this.userOrg) return; const isSelf = member.email === this.userInfo!.email; if ( isSelf && !window.confirm( msg( str`Are you sure you want to remove yourself from ${this.userOrg.name}?`, ), ) ) { return; } try { await this.api.fetch(`/orgs/${this.orgId}/remove`, { method: "POST", body: JSON.stringify({ email: member.email, }), }); this.notify.toast({ message: msg( str`Successfully removed ${member.name || member.email} from ${ this.userOrg.name }.`, ), variant: "success", icon: "check2-circle", id: "user-updated-status", }); if (isSelf) { // FIXME better UX, this is the only page currently that doesn't require org... this.navigate.to("/account/settings"); } else { const org = await this.getOrg(this.orgId); if (org) { AppStateService.partialUpdateOrg({ id: org.id, users: org.users, }); } } } catch (e) { console.debug(e); this.notify.toast({ message: isApiError(e) ? e.message : msg( str`Sorry, couldn't remove ${ member.name || member.email } at this time.`, ), variant: "danger", icon: "exclamation-octagon", id: "user-updated-status", }); } } }