import type { TemplateResult } from "lit"; import { render } from "lit"; import { property, state, query } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import { msg, localized } from "@lit/localize"; import type { SlDialog } from "@shoelace-style/shoelace"; import "broadcastchannel-polyfill"; import "tailwindcss/tailwind.css"; import "./utils/polyfills"; import type { OrgTab } from "./pages/org"; import type { NotifyEvent, NavigateEvent } from "./utils/LiteElement"; import LiteElement, { html } from "./utils/LiteElement"; import APIRouter from "./utils/APIRouter"; import AuthService, { AuthState } from "./utils/AuthService"; import type { LoggedInEvent } from "./utils/AuthService"; import type { ViewState } from "./utils/APIRouter"; import type { CurrentUser, UserOrg } from "./types/user"; import type { AuthStorageEventData } from "./utils/AuthService"; import type { OrgData } from "./utils/orgs"; import theme from "./theme"; import { ROUTES, DASHBOARD_ROUTE } from "./routes"; import "./shoelace"; import "./components"; import "./pages"; import "./assets/fonts/Inter/inter.css"; import "./assets/fonts/Recursive/recursive.css"; import "./styles.css"; type DialogContent = { label?: TemplateResult | string; body?: TemplateResult | string; noHeader?: boolean; }; type APIUser = { id: string; email: string; name: string; is_verified: boolean; is_superuser: boolean; orgs: UserOrg[]; }; type UserSettings = { orgId: string; }; /** * @event navigate * @event notify * @event need-login * @event logged-in * @event log-out * @event user-info-change */ @localized() export class App extends LiteElement { static storageKey = "btrix.app"; @property({ type: String }) version?: string; private router: APIRouter = new APIRouter(ROUTES); authService: AuthService = new AuthService(); @state() userInfo?: CurrentUser; @state() private viewState!: ViewState; @state() private globalDialogContent: DialogContent = {}; @query("#globalDialog") private globalDialog!: SlDialog; @state() private isAppSettingsLoaded: boolean = false; @state() private isRegistrationEnabled?: boolean; @state() private orgs?: OrgData[]; // Store selected org ID for when navigating from // pages without associated org (e.g. user account) @state() private selectedOrgId?: string; async connectedCallback() { let authState: AuthState = null; try { authState = await AuthService.initSessionStorage(); } catch (e: any) { console.debug(e); } this.syncViewState(); if (this.viewState.route === "org") { this.selectedOrgId = this.viewState.params.orgId; } if (authState) { this.authService.saveLogin(authState); this.updateUserInfo(); } super.connectedCallback(); window.addEventListener("popstate", () => { this.syncViewState(); }); this.startSyncBrowserTabs(); this.fetchAppSettings(); } willUpdate(changedProperties: Map) { if (changedProperties.get("viewState") && this.viewState.route === "org") { this.selectedOrgId = this.viewState.params.orgId; } } 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(DASHBOARD_ROUTE); 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(); this.userInfo = { id: userInfo.id, email: userInfo.email, name: userInfo.name, isVerified: userInfo.is_verified, isAdmin: userInfo.is_superuser, orgs: userInfo.orgs, }; const settings = this.getPersistedUserSettings(userInfo.id); if (settings) { this.selectedOrgId = settings.orgId; } const orgs = userInfo.orgs; this.orgs = orgs; if (orgs.length && !this.userInfo!.isAdmin && !this.selectedOrgId) { const firstOrg = orgs[0].id; if (orgs.length === 1) { // Persist selected org ID since there's no // user selection event to persist this.persistUserSettings(userInfo.id, { orgId: firstOrg, }); } this.selectedOrgId = firstOrg; } } 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(DASHBOARD_ROUTE); } 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 }` ); } navLink(event: Event) { event.preventDefault(); this.navigate((event.currentTarget as HTMLAnchorElement).href); } render() { return html`
${this.renderNavBar()}
${this.renderPage()}
${this.renderFooter()}
(this.globalDialogContent = {})} >${this.globalDialogContent?.body} `; } private renderNavBar() { const isAdmin = this.userInfo?.isAdmin; let homeHref = "/"; if (!isAdmin && this.selectedOrgId) { homeHref = `/orgs/${this.selectedOrgId}/workflows/crawls`; } return html`
`; } private renderOrgs() { if (!this.orgs || this.orgs.length < 2 || !this.userInfo) return; const selectedOption = this.selectedOrgId ? this.orgs.find(({ id }) => id === this.selectedOrgId) : { id: "", name: msg("All Organizations") }; if (!selectedOption) { console.debug( `Could't find organization with ID ${this.selectedOrgId}`, this.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}/workflows/crawls`); if (this.userInfo) { this.persistUserSettings(this.userInfo.id, { orgId: value }); } } else { if (this.userInfo) { this.clearSelectedOrg(); } this.navigate(`/`); } }} > ${when( this.userInfo.isAdmin, () => html` ${msg("All Organizations")} ` )} ${this.orgs.map( (org) => html` ${org.name.slice(0, orgNameLength)} ` )} `; } private renderMenuUserInfo() { if (!this.userInfo) return; if (this.userInfo.isAdmin) { return html`
${msg("admin")}
${this.userInfo?.name}
${this.userInfo?.email}
`; } if (this.orgs?.length === 1) { return html`
${this.orgs![0].name}
${this.userInfo?.name}
${this.userInfo?.email}
`; } return html`
${this.userInfo?.name}
${this.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(); }} @notify="${this.onNotify}" .authState=${this.authService.authState} .userInfo=${this.userInfo} .orgId=${this.selectedOrgId} >`; case "orgs": return html``; case "org": return html` { e.stopPropagation(); this.updateUserInfo(); }} @notify="${this.onNotify}" .authState=${this.authService.authState} .userInfo=${this.userInfo} .viewStateData=${this.viewState.data} .params=${this.viewState.params} orgId=${this.viewState.params.orgId} orgPath=${this.viewState.pathname.split( this.viewState.params.orgId )[1]} orgTab=${this.viewState.params.orgTab as OrgTab} >`; case "accountSettings": return html``; case "usersInvite": { if (this.userInfo) { if (this.userInfo.isAdmin) { return html``; } else { return this.renderNotFoundPage(); } } else { return this.renderSpinner(); } } case "crawls": case "crawl": { if (this.userInfo) { if (this.userInfo.isAdmin) { return html``; } else { return this.renderNotFoundPage(); } } else { return this.renderSpinner(); } } default: return this.renderNotFoundPage(); } } private renderSpinner() { return html`
`; } private renderNotFoundPage() { return html``; } private renderFindCrawl() { return html` { e.target.querySelector("sl-input").focus(); }} @sl-after-hide=${(e: any) => { e.target.querySelector("sl-input").value = ""; }} hoist >
{ e.preventDefault(); const id = new FormData(e.target).get("crawlId") as string; this.navigate(`/crawls/crawl/${id}#watch`); e.target.closest("sl-dropdown").hide(); }} >
${msg("Go")}
`; } onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) { const detail = event.detail || {}; const redirect = detail.redirect !== false; if (this.userInfo) { this.unpersistUserSettings(this.userInfo.id); } this.clearUser(); if (redirect) { this.navigate("/log-in"); } } onLoggedIn(event: LoggedInEvent) { const { detail } = event; this.authService.saveLogin({ username: detail.username, headers: detail.headers, tokenExpiresAt: detail.tokenExpiresAt, }); if (!detail.api) { this.navigate(detail.redirectUrl || DASHBOARD_ROUTE); } if (detail.firstLogin) { this.onFirstLogin({ email: detail.username }); } this.updateUserInfo(); } onNeedLogin() { this.clearUser(); this.navigate(ROUTES.login); } onNavigateTo(event: NavigateEvent) { event.stopPropagation(); this.navigate(event.detail.url, event.detail.state); // Scroll to top of page window.scrollTo({ top: 0 }); } onUserInfoChange(event: CustomEvent>) { // @ts-ignore this.userInfo = { ...this.userInfo, ...event.detail, }; } /** * Show global toast alert */ onNotify(event: NotifyEvent) { 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-with-orgs", this.authService.authState!); } private clearUser() { this.authService.logout(); this.authService = new AuthService(); this.userInfo = undefined; this.selectedOrgId = undefined; } 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: AuthStorageEventData }) => { 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 getPersistedUserSettings(userId: string): UserSettings | null { const value = window.localStorage.getItem(`${App.storageKey}.${userId}`); if (value) { return JSON.parse(value); } return null; } private persistUserSettings(userId: string, settings: UserSettings) { window.localStorage.setItem( `${App.storageKey}.${userId}`, JSON.stringify(settings) ); } private unpersistUserSettings(userId: string) { window.localStorage.removeItem(`${App.storageKey}.${userId}`); } private clearSelectedOrg() { this.selectedOrgId = undefined; if (this.userInfo) { this.persistUserSettings(this.userInfo.id, { orgId: "" }); } } } customElements.define("browsertrix-app", App);