browsertrix/frontend/src/index.ts
Ilya Kreymer 9a2787f9c4
User refactor + remove fastapi_users dependency + update fastapi (#1290)
Fixes #1050 

Major refactor of the user/auth system to remove fastapi_users
dependency. Refactors users.py to be standalone
and adds new auth.py module for handling auth. UserManager now works
similar to other ops classes.

The auth should be fully backwards compatible with fastapi_users auth,
including accepting previous JWT tokens w/o having to re-login. The User
data model in mongodb is also unchanged.

Additional fixes:
- allows updating fastapi to latest
- add webhook docs to openapi (follow up to #1041)

API changes:
- Removing the`GET, PATCH, DELETE /users/<id>` endpoints, which were not
in used before, as users are scoped to orgs. For deletion, probably
auto-delete when user is removed from last org (to be implemented).
- Rename `/users/me-with-orgs` is renamed to just `/users/me/`
- New `PUT /users/me/change-password` endpoint with password required to update password, fixes  #1269, supersedes #1272 

Frontend changes:
- Fixes from #1272 to support new change password endpoint.

---------
Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
Co-authored-by: sua yoo <sua@suayoo.com>
2023-10-18 10:49:23 -07:00

942 lines
28 KiB
TypeScript

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 { ifDefined } from "lit/directives/if-defined.js";
import type { SlDialog } from "@shoelace-style/shoelace";
import "broadcastchannel-polyfill";
import "tailwindcss/tailwind.css";
import "./utils/polyfills";
import appState, { use, AppStateService } from "./utils/state";
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, NeedLoginEvent } from "./utils/AuthService";
import type { ViewState } from "./utils/APIRouter";
import type { CurrentUser, UserOrg } from "./types/user";
import type { AuthStorageEventData } from "./utils/AuthService";
import theme from "./theme";
import { ROUTES } 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[];
};
/**
* @event navigate
* @event notify
* @event need-login
* @event logged-in
* @event log-out
* @event user-info-change
* @event update-user-info
*/
@localized()
export class App extends LiteElement {
@property({ type: String })
version?: string;
private router: APIRouter = new APIRouter(ROUTES);
authService: 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: any) {
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();
window.addEventListener("need-login", this.onNeedLogin);
window.addEventListener("popstate", () => {
this.syncViewState();
});
this.startSyncBrowserTabs();
this.fetchAppSettings();
}
willUpdate(changedProperties: Map<string, any>) {
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);
}
} 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
}`
);
}
navLink(event: Event) {
event.preventDefault();
this.navigate((event.currentTarget as HTMLAnchorElement).href);
}
render() {
return html`
<style>
${theme}
</style>
<div class="min-w-screen min-h-screen flex flex-col">
${this.renderNavBar()}
<main class="relative flex-auto flex">${this.renderPage()}</main>
<div class="border-t border-neutral-100">${this.renderFooter()}</div>
</div>
<sl-dialog
id="globalDialog"
?no-header=${this.globalDialogContent?.noHeader === true}
label=${this.globalDialogContent?.label || msg("Message")}
@sl-after-hide=${() => (this.globalDialogContent = {})}
>${this.globalDialogContent?.body}</sl-dialog
>
`;
}
private renderNavBar() {
const isAdmin = this.appState.userInfo?.isAdmin;
let homeHref = "/";
if (!isAdmin && this.appState.orgSlug) {
homeHref = `/orgs/${this.appState.orgSlug}`;
}
return html`
<div class="border-b">
<nav
class="max-w-screen-lg mx-auto pl-3 box-border h-12 flex items-center justify-between"
>
<div>
<a
class="text-sm hover:text-neutral-400 font-medium"
href=${homeHref}
@click=${(e: any) => {
if (isAdmin) {
this.clearSelectedOrg();
}
this.navLink(e);
}}
>
${msg("Browsertrix Cloud")}
</a>
</div>
${isAdmin
? html`
<div
class="text-xs md:text-sm grid grid-flow-col gap-3 md:gap-5 items-center"
>
<a
class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/"
@click=${(e: any) => {
this.clearSelectedOrg();
this.navLink(e);
}}
>${msg("Dashboard")}</a
>
<a
class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/crawls"
@click=${this.navLink}
>${msg("Running Crawls")}</a
>
<div class="hidden md:block">${this.renderFindCrawl()}</div>
</div>
`
: ""}
<div class="grid grid-flow-col auto-cols-max gap-3 items-center">
${this.authService.authState
? html` ${this.renderOrgs()}
<sl-dropdown placement="bottom-end">
<sl-icon-button
slot="trigger"
name="person-circle"
label=${msg("Open user menu")}
style="font-size: 1.5rem;"
></sl-icon-button>
<sl-menu class="w-60 min-w-min max-w-full">
<div class="px-7 py-2">${this.renderMenuUserInfo()}</div>
<sl-divider></sl-divider>
<sl-menu-item
@click=${() => this.navigate(ROUTES.accountSettings)}
>
<sl-icon slot="prefix" name="gear"></sl-icon>
${msg("Account Settings")}
</sl-menu-item>
${this.appState.userInfo?.isAdmin
? html` <sl-menu-item
@click=${() => this.navigate(ROUTES.usersInvite)}
>
<sl-icon slot="prefix" name="person-plus"></sl-icon>
${msg("Invite Users")}
</sl-menu-item>`
: ""}
<sl-divider></sl-divider>
<sl-menu-item @click="${this.onLogOut}">
<sl-icon slot="prefix" name="door-open"></sl-icon>
${msg("Log Out")}
</sl-menu-item>
</sl-menu>
</sl-dropdown>`
: html`
<a href="/log-in"> ${msg("Log In")} </a>
${this.isRegistrationEnabled
? html`
<sl-button
variant="text"
@click="${() => this.navigate("/sign-up")}"
>
${msg("Sign up")}
</sl-button>
`
: html``}
`}
</div>
</nav>
</div>
`;
}
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`
<sl-dropdown placement="bottom-end">
<sl-button slot="trigger" variant="text" size="small" caret
>${selectedOption.name.slice(0, orgNameLength)}</sl-button
>
<sl-menu
@sl-select=${(e: CustomEvent) => {
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`
<sl-menu-item
type="checkbox"
value=""
?checked=${!selectedOption.slug}
>${msg("All Organizations")}</sl-menu-item
>
<sl-divider></sl-divider>
`
)}
${this.appState.userInfo?.orgs.map(
(org) => html`
<sl-menu-item
type="checkbox"
value=${org.slug}
?checked=${org.slug === selectedOption.slug}
>${org.name.slice(0, orgNameLength)}</sl-menu-item
>
`
)}
</sl-menu>
</sl-dropdown>
`;
}
private renderMenuUserInfo() {
if (!this.appState.userInfo) return;
if (this.appState.userInfo.isAdmin) {
return html`
<div class="mb-2">
<sl-tag class="uppercase" variant="primary" size="small"
>${msg("admin")}</sl-tag
>
</div>
<div class="font-medium text-neutral-700">
${this.appState.userInfo?.name}
</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.appState.userInfo?.email}
</div>
`;
}
const orgs = this.appState.userInfo?.orgs;
if (orgs?.length === 1) {
return html`
<div class="font-medium text-neutral-700 my-1">${orgs[0].name}</div>
<div class="text-neutral-500">${this.appState.userInfo?.name}</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.appState.userInfo?.email}
</div>
`;
}
return html`
<div class="font-medium text-neutral-700">
${this.appState.userInfo?.name}
</div>
<div class="text-xs text-neutral-500 whitespace-nowrap">
${this.appState.userInfo?.email}
</div>
`;
}
private renderFooter() {
return html`
<footer
class="w-full max-w-screen-lg mx-auto p-3 box-border flex flex-col gap-4 md:flex-row justify-between"
>
<!-- <div> -->
<!-- TODO re-enable when translations are added -->
<!-- <btrix-locale-picker></btrix-locale-picker> -->
<!-- </div> -->
<div class="flex items-center justify-center">
<a
class="text-neutral-400 flex items-center gap-2 hover:text-primary"
href="https://github.com/webrecorder/browsertrix-cloud"
target="_blank"
rel="noopener"
>
<sl-icon
name="github"
class="inline-block align-middle text-base"
></sl-icon>
Source Code
</a>
</div>
<div class="flex items-center justify-center">
<a
class="text-neutral-400 flex items-center gap-2 hover:text-primary"
href="https://docs.browsertrix.cloud"
target="_blank"
rel="noopener"
>
<sl-icon
name="book-half"
class="inline-block align-middle text-base"
></sl-icon>
Documentation
</a>
</div>
<div class="flex items-center justify-center">
${this.version
? html`
<btrix-copy-button
class="text-neutral-400"
.getValue=${() => this.version}
content=${msg("Copy Version Code")}
></btrix-copy-button>
<span
class="inline-block align-middle font-monostyle text-xs text-neutral-400"
>
${this.version}
</span>
`
: ""}
</div>
</footer>
`;
}
private renderPage() {
switch (this.viewState.route) {
case "signUp": {
if (!this.isAppSettingsLoaded) {
return html`<div
class="w-full md:bg-neutral-50 flex items-center justify-center"
></div>`;
}
if (this.isRegistrationEnabled) {
return html`<btrix-sign-up
class="w-full md:bg-neutral-50 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
@log-out="${this.onLogOut}"
.authState="${this.authService.authState}"
></btrix-sign-up>`;
} else {
return this.renderNotFoundPage();
}
}
case "verify":
return html`<btrix-verify
class="w-full md:bg-neutral-50 flex items-center justify-center"
token="${this.viewState.params.token}"
@navigate="${this.onNavigateTo}"
@notify="${this.onNotify}"
@log-out="${this.onLogOut}"
@user-info-change="${this.onUserInfoChange}"
.authState="${this.authService.authState}"
></btrix-verify>`;
case "join":
return html`<btrix-join
class="w-full md:bg-neutral-50 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
token="${this.viewState.params.token}"
email="${this.viewState.params.email}"
></btrix-join>`;
case "acceptInvite":
return html`<btrix-accept-invite
class="w-full md:bg-neutral-50 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
@notify="${this.onNotify}"
.authState="${this.authService.authState}"
token="${this.viewState.params.token}"
email="${this.viewState.params.email}"
></btrix-accept-invite>`;
case "login":
case "loginWithRedirect":
case "forgotPassword":
return html`<btrix-log-in
class="w-full md:bg-neutral-50 flex items-center justify-center"
@navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn}
.authState=${this.authService.authState}
.viewState=${this.viewState}
redirectUrl=${this.viewState.params.redirectUrl ||
this.viewState.data?.redirectUrl}
></btrix-log-in>`;
case "resetPassword":
return html`<btrix-reset-password
class="w-full md:bg-neutral-50 flex items-center justify-center"
@navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn}
.authState=${this.authService.authState}
.viewState=${this.viewState}
></btrix-reset-password>`;
case "home":
return html`<btrix-home
class="w-full md:bg-neutral-50"
@navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn}
@update-user-info=${(e: CustomEvent) => {
e.stopPropagation();
this.updateUserInfo();
}}
@notify="${this.onNotify}"
.authState=${this.authService.authState}
.userInfo=${ifDefined(this.appState.userInfo || undefined)}
slug=${ifDefined(this.appState.orgSlug || undefined)}
></btrix-home>`;
case "orgs":
return html`<btrix-orgs
class="w-full md:bg-neutral-50"
@navigate="${this.onNavigateTo}"
.authState="${this.authService.authState}"
.userInfo="${this.appState.userInfo}"
></btrix-orgs>`;
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`<btrix-org
class="w-full"
@navigate=${this.onNavigateTo}
@update-user-info=${(e: CustomEvent) => {
e.stopPropagation();
this.updateUserInfo();
}}
@notify="${this.onNotify}"
.authState=${this.authService.authState}
.userInfo=${this.appState.userInfo}
.viewStateData=${this.viewState.data}
.params=${this.viewState.params}
slug=${slug}
orgPath=${orgPath.split(slug)[1]}
orgTab=${orgTab as OrgTab}
></btrix-org>`;
}
case "accountSettings":
return html`<btrix-account-settings
class="w-full max-w-screen-lg mx-auto p-2 md:py-8 box-border"
@navigate="${this.onNavigateTo}"
@logged-in=${this.onLoggedIn}
@update-user-info=${(e: CustomEvent) => {
e.stopPropagation();
this.updateUserInfo();
}}
@notify="${this.onNotify}"
.authState="${this.authService.authState}"
.userInfo="${this.appState.userInfo}"
></btrix-account-settings>`;
case "usersInvite": {
if (this.appState.userInfo) {
if (this.appState.userInfo.isAdmin) {
return html`<btrix-users-invite
class="w-full max-w-screen-lg mx-auto p-2 md:py-8 box-border"
@navigate="${this.onNavigateTo}"
@logged-in=${this.onLoggedIn}
.authState="${this.authService.authState}"
.userInfo="${this.appState.userInfo}"
></btrix-users-invite>`;
} else {
return this.renderNotFoundPage();
}
} else {
return this.renderSpinner();
}
}
case "crawls":
case "crawl": {
if (this.appState.userInfo) {
if (this.appState.userInfo.isAdmin) {
return html`<btrix-crawls
class="w-full"
@navigate=${this.onNavigateTo}
@notify=${this.onNotify}
.authState=${this.authService.authState}
crawlId=${this.viewState.params.crawlId}
></btrix-crawls>`;
} 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`
<div class="w-full flex items-center justify-center text-3xl">
<sl-spinner></sl-spinner>
</div>
`;
}
private renderNotFoundPage() {
return html`<btrix-not-found
class="w-full md:bg-neutral-50 flex items-center justify-center"
></btrix-not-found>`;
}
private renderFindCrawl() {
return html`
<sl-dropdown
@sl-after-show=${(e: any) => {
e.target.querySelector("sl-input").focus();
}}
@sl-after-hide=${(e: any) => {
e.target.querySelector("sl-input").value = "";
}}
hoist
>
<button
slot="trigger"
class="text-primary hover:text-indigo-400 font-medium"
>
${msg("Jump to Crawl")}
</button>
<div class="p-2">
<form
@submit=${(e: any) => {
e.preventDefault();
const id = new FormData(e.target).get("crawlId") as string;
this.navigate(`/crawls/crawl/${id}#watch`);
e.target.closest("sl-dropdown").hide();
}}
>
<div class="flex flex-wrap items-center">
<div class="mr-2 w-90">
<sl-input
size="small"
name="crawlId"
placeholder=${msg("Enter Crawl ID")}
required
></sl-input>
</div>
<div class="grow-0">
<sl-button size="small" variant="neutral" type="submit">
<sl-icon slot="prefix" name="arrow-right-circle"></sl-icon>
${msg("Go")}</sl-button
>
</div>
</div>
</form>
</div>
</sl-dropdown>
`;
}
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: LoggedInEvent) {
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: Event) => {
e.stopPropagation();
this.clearUser();
const redirectUrl = (e as NeedLoginEvent).detail?.redirectUrl;
this.navigate(ROUTES.login, {
redirectUrl,
});
this.onNotify(
new CustomEvent("notify", {
detail: {
message: msg("Please log in to continue."),
variant: "warning" as any,
icon: "exclamation-triangle",
},
})
);
};
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<Partial<CurrentUser>>) {
AppStateService.updateUserInfo({
...this.appState.userInfo,
...event.detail,
} as CurrentUser);
}
/**
* 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`
<sl-icon name="${icon}" slot="icon"></sl-icon>
${title ? html`<strong>${title}</strong>` : ""}
${message ? html`<div>${message}</div>` : ""}
`,
container
);
document.body.append(alert);
alert.toast();
}
getUserInfo(): Promise<APIUser> {
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`
<div class="grid gap-4 text-center">
<p class="mt-8 text-xl font-medium">
${msg("Welcome to Browsertrix Cloud!")}
</p>
<p>
${msg(html`A confirmation email was sent to: <br />
<strong>${email}</strong>.`)}
</p>
<p class="max-w-xs mx-auto">
${msg(
"Click the link in your email to confirm your email address."
)}
</p>
</div>
<div class="mb-4 mt-8 text-center">
<sl-button variant="primary" @click=${() => this.closeDialog()}
>${msg("Got it, go to dashboard")}</sl-button
>
</div>
`,
});
}
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 clearSelectedOrg() {
AppStateService.updateOrgSlug(null);
}
}
customElements.define("browsertrix-app", App);