import type { TemplateResult } from "lit"; import { render } from "lit"; import { property, state, query, customElement } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import { msg, localized } from "@lit/localize"; import { ifDefined } from "lit/directives/if-defined.js"; import type { SlDialog, SlInput } from "@shoelace-style/shoelace"; import "broadcastchannel-polyfill"; import "./utils/polyfills"; import appState, { use, AppStateService } from "./utils/state"; import type { OrgTab } from "./pages/org"; import type { NavigateEventDetail } from "@/controllers/navigate"; import type { NotifyEventDetail } from "@/controllers/notify"; import LiteElement, { html } from "./utils/LiteElement"; import APIRouter from "./utils/APIRouter"; import AuthService from "./utils/AuthService"; import type { LoggedInEventDetail, NeedLoginEventDetail, AuthState, } from "./utils/AuthService"; import type { ViewState } from "./utils/APIRouter"; import type { CurrentUser, UserOrg } from "./types/user"; import type { AuthStorageEventDetail } from "./utils/AuthService"; import { ROUTES } from "./routes"; import "./shoelace"; import "./components"; import "./features"; import "./pages"; import "./assets/fonts/Inter/inter.css"; import "./assets/fonts/Recursive/recursive.css"; import "./styles.css"; import { theme } from "@/theme"; // Make theme CSS available in document document.adoptedStyleSheets = [theme]; type DialogContent = { label?: TemplateResult | string; body?: TemplateResult | string; noHeader?: boolean; }; export type APIUser = { id: string; email: string; name: string; is_verified: boolean; is_superuser: boolean; orgs: UserOrg[]; }; @localized() @customElement("browsertrix-app") export class App extends LiteElement { @property({ type: String }) version?: string; private router = new APIRouter(ROUTES); authService = new AuthService(); @use() appState = appState; @state() private viewState!: ViewState; @state() private globalDialogContent: DialogContent = {}; @query("#globalDialog") private globalDialog!: SlDialog; @state() private isAppSettingsLoaded: boolean = false; @state() private isRegistrationEnabled?: boolean; async connectedCallback() { let authState: AuthState = null; try { authState = await AuthService.initSessionStorage(); } catch (e) { console.debug(e); } this.syncViewState(); if (this.viewState.route === "org") { AppStateService.updateOrgSlug(this.viewState.params.slug || null); } if (authState) { this.authService.saveLogin(authState); this.updateUserInfo(); } super.connectedCallback(); this.addEventListener("btrix-navigate", this.onNavigateTo); this.addEventListener("btrix-notify", this.onNotify); this.addEventListener("btrix-need-login", this.onNeedLogin); this.addEventListener("btrix-logged-in", this.onLoggedIn); this.addEventListener("btrix-log-out", this.onLogOut); window.addEventListener("popstate", () => { this.syncViewState(); }); this.startSyncBrowserTabs(); this.fetchAppSettings(); } willUpdate(changedProperties: Map) { if (changedProperties.get("viewState") && this.viewState.route === "org") { AppStateService.updateOrgSlug(this.viewState.params.slug || null); } } private syncViewState() { if ( this.authService.authState && (window.location.pathname === "/log-in" || window.location.pathname === "/reset-password") ) { // Redirect to logged in home page this.viewState = this.router.match(ROUTES.home); window.history.replaceState(this.viewState, "", this.viewState.pathname); } else { this.viewState = this.router.match( `${window.location.pathname}${window.location.search}` ); } } private async fetchAppSettings() { const settings = await this.getAppSettings(); if (settings) { this.isRegistrationEnabled = settings.registrationEnabled; } this.isAppSettingsLoaded = true; } private async updateUserInfo() { try { const userInfo = await this.getUserInfo(); AppStateService.updateUserInfo({ id: userInfo.id, email: userInfo.email, name: userInfo.name, isVerified: userInfo.is_verified, isAdmin: userInfo.is_superuser, orgs: userInfo.orgs, }); const orgs = userInfo.orgs; if ( orgs.length && !this.appState.userInfo!.isAdmin && !this.appState.orgSlug ) { const firstOrg = orgs[0].slug; AppStateService.updateOrgSlug(firstOrg); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (err?.message === "Unauthorized") { console.debug( "Unauthorized with authState:", this.authService.authState ); this.clearUser(); this.navigate(ROUTES.login); } } } async getAppSettings(): Promise<{ registrationEnabled: boolean } | void> { const resp = await fetch("/api/settings", { headers: { "Content-Type": "application/json" }, }); if (resp.status === 200) { const body = await resp.json(); return body; } else { console.debug(resp); } } navigate(newViewPath: string, state?: object) { let url; if (newViewPath.startsWith("http")) { url = new URL(newViewPath); } else { url = new URL( `${window.location.origin}/${newViewPath.replace(/^\//, "")}` ); } // Remove hash from path for matching newViewPath = `${url.pathname}${url.search}`; if (newViewPath === "/log-in" && this.authService.authState) { // Redirect to logged in home page this.viewState = this.router.match(ROUTES.home); } else { this.viewState = this.router.match(newViewPath); } this.viewState.data = state; window.history.pushState( this.viewState, "", `${this.viewState.pathname.replace(url.search, "")}${url.hash}${ url.search }` ); } render() { return html`
${this.renderNavBar()}
${this.renderPage()}
${this.renderFooter()}
(this.globalDialogContent = {})} >${this.globalDialogContent?.body} `; } private renderNavBar() { const isAdmin = this.appState.userInfo?.isAdmin; let homeHref = "/"; if (!isAdmin && this.appState.orgSlug) { homeHref = `/orgs/${this.appState.orgSlug}`; } return html`
`; } private renderOrgs() { const orgs = this.appState.userInfo?.orgs; if (!orgs || orgs.length < 2 || !this.appState.userInfo) return; const selectedOption = this.appState.orgSlug ? orgs.find(({ slug }) => slug === this.appState.orgSlug) : { slug: "", name: msg("All Organizations") }; if (!selectedOption) { console.debug( `Could't find organization with slug ${this.appState.orgSlug}`, orgs ); return; } // Limit org name display for orgs created before org name max length restriction const orgNameLength = 50; return html` ${selectedOption.name.slice(0, orgNameLength)} { const { value } = e.detail.item; if (value) { this.navigate(`/orgs/${value}`); } else { if (this.appState.userInfo) { this.clearSelectedOrg(); } this.navigate(`/`); } }} > ${when( this.appState.userInfo.isAdmin, () => html` ${msg("All Organizations")} ` )} ${this.appState.userInfo?.orgs.map( (org) => html` ${org.name.slice(0, orgNameLength)} ` )} `; } private renderMenuUserInfo() { if (!this.appState.userInfo) return; if (this.appState.userInfo.isAdmin) { return html`
${msg("admin")}
${this.appState.userInfo?.name}
${this.appState.userInfo?.email}
`; } const orgs = this.appState.userInfo?.orgs; if (orgs?.length === 1) { return html`
${orgs[0].name}
${this.appState.userInfo?.name}
${this.appState.userInfo?.email}
`; } return html`
${this.appState.userInfo?.name}
${this.appState.userInfo?.email}
`; } private renderFooter() { return html` `; } private renderPage() { switch (this.viewState.route) { case "signUp": { if (!this.isAppSettingsLoaded) { return html`
`; } if (this.isRegistrationEnabled) { return html``; } else { return this.renderNotFoundPage(); } } case "verify": return html``; case "join": return html``; case "acceptInvite": return html``; case "login": case "loginWithRedirect": case "forgotPassword": return html``; case "resetPassword": return html``; case "home": return html` { e.stopPropagation(); this.updateUserInfo(); }} .authState=${this.authService.authState} .userInfo=${this.appState.userInfo ?? undefined} slug=${ifDefined(this.appState.orgSlug ?? undefined)} >`; case "orgs": return html``; case "org": { const slug = this.viewState.params.slug; const orgPath = this.viewState.pathname; const orgTab = window.location.pathname .slice(window.location.pathname.indexOf(slug) + slug.length) .replace(/(^\/|\/$)/, "") .split("/")[0] || "home"; return html` { e.stopPropagation(); this.updateUserInfo(); }} .authState=${this.authService.authState} .userInfo=${this.appState.userInfo ?? undefined} .viewStateData=${this.viewState.data} .params=${this.viewState.params} slug=${slug} orgPath=${orgPath.split(slug)[1]} orgTab=${orgTab as OrgTab} >`; } case "accountSettings": return html` { e.stopPropagation(); this.updateUserInfo(); }} .authState="${this.authService.authState}" .userInfo="${this.appState.userInfo ?? undefined}" >`; case "usersInvite": { if (this.appState.userInfo) { if (this.appState.userInfo.isAdmin) { return html``; } else { return this.renderNotFoundPage(); } } else { return this.renderSpinner(); } } case "crawls": case "crawl": { if (this.appState.userInfo) { if (this.appState.userInfo.isAdmin) { return html``; } else { return this.renderNotFoundPage(); } } else { return this.renderSpinner(); } } case "awpUploadRedirect": { const { orgId, uploadId } = this.viewState.params; if (this.appState.slugLookup) { const slug = this.appState.slugLookup[orgId]; if (slug) { this.navigate(`/orgs/${slug}/items/upload/${uploadId}`); return; } } } default: return this.renderNotFoundPage(); } } private renderSpinner() { return html`
`; } private renderNotFoundPage() { return html``; } private renderFindCrawl() { return html` { (e.target as HTMLElement).querySelector("sl-input")?.focus(); }} @sl-after-hide=${(e: Event) => { ( (e.target as HTMLElement).querySelector("sl-input") as SlInput ).value = ""; }} hoist >
{ e.preventDefault(); const id = new FormData(e.target as HTMLFormElement).get( "crawlId" ) as string; this.navigate(`/crawls/crawl/${id}#watch`); (e.target as HTMLFormElement).closest("sl-dropdown")?.hide(); }} >
${msg("Go")}
`; } onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) { const detail = event.detail || {}; const redirect = detail.redirect !== false; this.clearUser(); if (redirect) { this.navigate(ROUTES.login); } } onLoggedIn(event: CustomEvent) { const { detail } = event; this.authService.saveLogin({ username: detail.username, headers: detail.headers, tokenExpiresAt: detail.tokenExpiresAt, }); if (!detail.api) { this.navigate(detail.redirectUrl || ROUTES.home); } if (detail.firstLogin) { this.onFirstLogin({ email: detail.username }); } this.updateUserInfo(); } onNeedLogin = (e: CustomEvent) => { e.stopPropagation(); this.clearUser(); const redirectUrl = e.detail?.redirectUrl; this.navigate(ROUTES.login, { redirectUrl, }); this.notify({ message: msg("Please log in to continue."), variant: "warning", icon: "exclamation-triangle", }); }; onNavigateTo = (event: CustomEvent) => { event.stopPropagation(); this.navigate(event.detail.url, event.detail.state); // Scroll to top of page window.scrollTo({ top: 0 }); }; onUserInfoChange(event: CustomEvent>) { AppStateService.updateUserInfo({ ...this.appState.userInfo, ...event.detail, } as CurrentUser); } /** * Show global toast alert */ onNotify = (event: CustomEvent) => { event.stopPropagation(); const { title, message, variant = "primary", icon = "info-circle", duration = 5000, } = event.detail; const container = document.createElement("sl-alert"); const alert = Object.assign(container, { variant, closable: true, duration: duration, style: [ "--sl-panel-background-color: var(--sl-color-neutral-1000)", "--sl-color-neutral-700: var(--sl-color-neutral-0)", // "--sl-panel-border-width: 0px", "--sl-spacing-large: var(--sl-spacing-medium)", ].join(";"), }); render( html` ${title ? html`${title}` : ""} ${message ? html`
${message}
` : ""} `, container ); document.body.append(alert); alert.toast(); }; getUserInfo(): Promise { return this.apiFetch("/users/me", this.authService.authState!); } private clearUser() { this.authService.logout(); this.authService = new AuthService(); AppStateService.reset(); } private showDialog(content: DialogContent) { this.globalDialogContent = content; this.globalDialog.show(); } private closeDialog() { this.globalDialog.hide(); } private onFirstLogin({ email }: { email: string }) { this.showDialog({ label: "Welcome to Browsertrix Cloud", noHeader: true, body: html`

${msg("Welcome to Browsertrix Cloud!")}

${msg(html`A confirmation email was sent to:
${email}.`)}

${msg( "Click the link in your email to confirm your email address." )}

this.closeDialog()} >${msg("Got it, go to dashboard")}
`, }); } private startSyncBrowserTabs() { AuthService.broadcastChannel.addEventListener( "message", ({ data }: { data: AuthStorageEventDetail }) => { if (data.name === "auth_storage") { if (data.value !== AuthService.storage.getItem()) { if (data.value) { this.authService.saveLogin(JSON.parse(data.value)); this.updateUserInfo(); this.syncViewState(); } else { this.clearUser(); this.navigate(ROUTES.login); } } } } ); } private clearSelectedOrg() { AppStateService.updateOrgSlug(null); } }