import { localized, msg } from "@lit/localize"; import type { SlDialog } from "@shoelace-style/shoelace"; import { render, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import sortBy from "lodash/fp/sortBy"; import "broadcastchannel-polyfill"; import "./utils/polyfills"; import type { OrgTab } from "./pages/org"; import { ROUTES } from "./routes"; import type { CurrentUser, UserOrg } from "./types/user"; import APIRouter, { type ViewState } from "./utils/APIRouter"; import AuthService, { type Auth, type AuthEventDetail, type AuthState, type LoggedInEventDetail, type NeedLoginEventDetail, } from "./utils/AuthService"; import { DEFAULT_MAX_SCALE } from "./utils/crawler"; import LiteElement, { html } from "./utils/LiteElement"; import appState, { AppStateService, use } from "./utils/state"; import { formatAPIUser } from "./utils/user"; import type { NavigateEventDetail } from "@/controllers/navigate"; import type { NotifyEventDetail } from "@/controllers/notify"; import { theme } from "@/theme"; import brandLockupColor from "~assets/brand/browsertrix-lockup-color.svg"; import "./shoelace"; import "./components"; import "./features"; import "./pages"; import "./assets/fonts/Inter/inter.css"; import "./assets/fonts/Recursive/recursive.css"; import "./styles.css"; // 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 readonly router = new APIRouter(ROUTES); authService = new AuthService(); @use() appState = appState; @state() private viewState!: ViewState; @state() private globalDialogContent: DialogContent = {}; @query("#globalDialog") private readonly globalDialog!: SlDialog; @state() private isAppSettingsLoaded = false; @state() private isRegistrationEnabled?: boolean; private maxScale = DEFAULT_MAX_SCALE; 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); void 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(); void 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.maxScale = settings.maxScale; } this.isAppSettingsLoaded = true; } /** * @deprecate Components should update user info directly through `AppStateService` */ private async updateUserInfo(e?: CustomEvent) { if (e) { e.stopPropagation(); } try { const userInfo = await this.getUserInfo(); AppStateService.updateUserInfo(formatAPIUser(userInfo)); const orgs = userInfo.orgs; if ( orgs.length && !this.appState.userInfo!.isAdmin && !this.appState.orgSlug ) { const firstOrg = orgs[0].slug; AppStateService.updateOrgSlug(firstOrg); } } catch (err) { if ((err as Error | null | undefined)?.message === "Unauthorized") { console.debug( "Unauthorized with authState:", this.authService.authState, ); this.clearUser(); this.navigate(ROUTES.login); } } } async getAppSettings(): Promise<{ registrationEnabled: boolean; maxScale: number; } | void> { const resp = await fetch("/api/settings", { headers: { "Content-Type": "application/json" }, }); if (resp.status === 200) { const body = (await resp.json()) as { registrationEnabled: boolean; maxScale: number; }; return body; } else { console.debug(resp); } } navigate( newViewPath: string, state?: { [key: string]: unknown }, replace?: boolean, ) { 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; const urlStr = `${this.viewState.pathname.replace(url.search, "")}${url.hash}${ url.search }`; if (replace) { window.history.replaceState(this.viewState, "", urlStr); } else { window.history.pushState(this.viewState, "", urlStr); } } 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 sortedOrgs = sortBy("name")(orgs); const selectedOption = this.appState.orgSlug ? orgs.find(({ slug }) => slug === this.appState.orgSlug) : { slug: "", name: msg("All Organizations") }; if (!selectedOption) { console.debug( `Couldn'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")} `, )} ${sortedOrgs.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`
${this.version ? html` this.version} content=${msg("Copy Version Code")} size="x-small" > ${this.version} ` : ""}
`; } 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``; 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``; } case "accountSettings": return html``; 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; } } // falls through } case "components": return html``; 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")!.value = ""; }} hoist >
{ e.preventDefault(); const id = new FormData(e.target as HTMLFormElement).get( "crawlId", ) as string; this.navigate(`/crawls/crawl/${id}#watch`); void (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 }); } void 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(); const { url, state, resetScroll, replace } = event.detail; this.navigate(url, state, replace); if (resetScroll) { // 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); void alert.toast(); }; async 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; void this.globalDialog.show(); } private closeDialog() { void this.globalDialog.hide(); } private onFirstLogin({ email }: { email: string }) { this.showDialog({ label: "Welcome to Browsertrix", noHeader: true, body: html`

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

${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: AuthEventDetail }) => { if (data.name === "auth_storage") { if (data.value !== AuthService.storage.getItem()) { if (data.value) { this.authService.saveLogin(JSON.parse(data.value) as Auth); void this.updateUserInfo(); this.syncViewState(); } else { this.clearUser(); this.navigate(ROUTES.login); } } } }, ); } private clearSelectedOrg() { AppStateService.updateOrgSlug(null); } }