diff --git a/frontend/src/index.ts b/frontend/src/index.ts index b856823b..8bc08464 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -14,7 +14,7 @@ import AuthService from "./utils/AuthService"; import type { LoggedInEvent } from "./utils/AuthService"; import type { ViewState } from "./utils/APIRouter"; import type { CurrentUser } from "./types/user"; -import type { AuthState } from "./utils/AuthService"; +import type { AuthStorageEventData } from "./utils/AuthService"; import theme from "./theme"; import { ROUTES, DASHBOARD_ROUTE } from "./routes"; import "./shoelace"; @@ -64,45 +64,37 @@ export class App extends LiteElement { @state() private isRegistrationEnabled?: boolean; - constructor() { - super(); - - const authState = this.authService.retrieve(); - + async connectedCallback() { + const authState = await AuthService.initSessionStorage(); if (authState) { - if ( - window.location.pathname === "/log-in" || - window.location.pathname === "/reset-password" - ) { - // Redirect to logged in home page - this.viewState = this.router.match(ROUTES.myAccount); - window.history.replaceState( - this.viewState, - "", - this.viewState.pathname - ); - } + this.authService.saveLogin(authState); } - this.syncViewState(); - } - - private syncViewState() { - this.viewState = this.router.match( - `${window.location.pathname}${window.location.search}` - ); - } - - connectedCallback() { super.connectedCallback(); - window.addEventListener("popstate", (event) => { + window.addEventListener("popstate", () => { this.syncViewState(); }); this.startSyncBrowserTabs(); } + 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}` + ); + } + } + async firstUpdated() { if (this.authService.authState) { this.updateUserInfo(); @@ -605,7 +597,7 @@ export class App extends LiteElement { onLoggedIn(event: LoggedInEvent) { const { detail } = event; - this.authService.startPersist({ + this.authService.saveLogin({ username: detail.username, headers: detail.headers, tokenExpiresAt: detail.tokenExpiresAt, @@ -728,52 +720,23 @@ export class App extends LiteElement { } private startSyncBrowserTabs() { - // TODO remove this line after this change has been deployed - // for more than 24 hours - window.localStorage.removeItem(AuthService.storageKey); - - // Sync local auth state across window/tabs - // Notify any already open windows that new window is open - AuthService.broadcastChannel.postMessage({ name: "need_auth" }); - AuthService.broadcastChannel.addEventListener("message", ({ data }) => { - if (data.name === "need_auth") { - // Share auth with newly opened tab - const auth = AuthService.storage.getItem(); - if (auth) { - AuthService.broadcastChannel.postMessage({ - name: "storage", - key: AuthService.storageKey, - oldValue: auth, - newValue: auth, - }); - } - } - if (data.name === "storage") { - const { key, oldValue, newValue } = data; - if (key === AuthService.storageKey && newValue !== oldValue) { - if (oldValue && newValue === null) { - // Logged out from another tab - this.onLogOut( - new CustomEvent("log-out", { detail: { redirect: true } }) - ); - } else if (!oldValue && newValue) { - // Logged in from another tab - const auth = JSON.parse(newValue); - this.onLoggedIn(AuthService.createLoggedInEvent(auth)); + 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.authService.logout(); + this.navigate(ROUTES.login); + } } } } - }); - - // Only have freshness check run in visible tab(s) - document.addEventListener("visibilitychange", () => { - if (!this.authService.authState) return; - if (document.visibilityState === "visible") { - this.authService.startFreshnessCheck(); - } else { - this.authService.cancelFreshnessCheck(); - } - }); + ); } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 1ff0d76a..9ea1e755 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -8,7 +8,6 @@ export const ROUTES = { loginWithRedirect: "/log-in?redirectUrl", forgotPassword: "/log-in/forgot-password", resetPassword: "/reset-password?token", - myAccount: "/my-account", accountSettings: "/account/settings", archives: "/archives", archive: "/archives/:id/:tab", diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index 8392a034..919e4462 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -27,6 +27,24 @@ export interface LoggedInEvent extends CustomEvent { readonly detail: T; } +type HasAuthStorageData = { + auth: boolean; +}; + +type AuthRequestEventData = { + name: "requesting_auth"; +}; + +type AuthResponseEventData = { + name: "responding_auth"; + auth: AuthState; +}; + +export type AuthStorageEventData = { + name: "auth_storage"; + value: string | null; +}; + // Check for token freshness every 5 minutes const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5; @@ -45,22 +63,20 @@ export default class AuthService { }, setItem(newValue: string) { const oldValue = AuthService.storage.getItem(); + if (oldValue === newValue) return; window.sessionStorage.setItem(AuthService.storageKey, newValue); - AuthService.broadcastChannel.postMessage({ - name: "storage", - key: AuthService.storageKey, - oldValue, - newValue, + AuthService.broadcastChannel.postMessage({ + name: "auth_storage", + value: newValue, }); }, removeItem() { const oldValue = AuthService.storage.getItem(); + if (!oldValue) return; window.sessionStorage.removeItem(AuthService.storageKey); - AuthService.broadcastChannel.postMessage({ - name: "storage", - key: AuthService.storageKey, - oldValue, - newValue: null, + AuthService.broadcastChannel.postMessage({ + name: "auth_storage", + value: null, }); }, }; @@ -133,41 +149,108 @@ export default class AuthService { throw new Error(AuthService.unsupportedAuthErrorCode); } - retrieve(): AuthState { + /** + * Retrieve or set auth data from shared session + * and set up session syncing + */ + static async initSessionStorage(): Promise { + const authState = + AuthService.getCurrentTabAuth() || + (await AuthService.getSharedSessionAuth()); + + AuthService.broadcastChannel.addEventListener( + "message", + ({ data }: { data: AuthRequestEventData | AuthStorageEventData }) => { + if (data.name === "requesting_auth") { + // A new tab/window opened and is requesting shared auth + AuthService.broadcastChannel.postMessage({ + name: "responding_auth", + auth: AuthService.getCurrentTabAuth(), + }); + } + } + ); + + window.addEventListener("beforeunload", () => { + window.localStorage.removeItem(AuthService.storageKey); + }); + + return authState; + } + + private static getCurrentTabAuth(): AuthState { const auth = AuthService.storage.getItem(); if (auth) { - this._authState = JSON.parse(auth); - this.checkFreshness(); + return JSON.parse(auth); } - return this._authState; + return null; } - startPersist(auth: Auth) { - if (auth) { - this.persist(auth); - this.startFreshnessCheck(); - } else { - console.warn("No authState to persist"); - } + /** + * Retrieve shared session from another tab/window + **/ + private static async getSharedSessionAuth(): Promise { + return new Promise((resolve) => { + // Check if there's any authenticated tabs + const value = window.localStorage.getItem(AuthService.storageKey); + if (value && (JSON.parse(value) as HasAuthStorageData).auth) { + // Ask for auth + AuthService.broadcastChannel.postMessage({ + name: "requesting_auth", + }); + // Wait for another tab to respond + const cb = ({ data }: any) => { + if (data.name === "responding_auth") { + AuthService.broadcastChannel.removeEventListener("message", cb); + resolve(data.auth); + } + }; + AuthService.broadcastChannel.addEventListener("message", cb); + } else { + resolve(null); + } + }); } - startFreshnessCheck() { + constructor() { + // Only have freshness check run in visible tab(s) + document.addEventListener("visibilitychange", () => { + if (!this._authState) return; + if (document.visibilityState === "visible") { + this.startFreshnessCheck(); + } else { + this.cancelFreshnessCheck(); + } + }); + } + + saveLogin(auth: Auth) { + window.localStorage.setItem( + AuthService.storageKey, + JSON.stringify({ auth: true }) + ); + this.persist(auth); + this.startFreshnessCheck(); + } + + logout() { + window.localStorage.removeItem(AuthService.storageKey); + this.cancelFreshnessCheck(); + this.revoke(); + } + + private startFreshnessCheck() { window.clearTimeout(this.timerId); this.checkFreshness(); } - cancelFreshnessCheck() { + private cancelFreshnessCheck() { window.clearTimeout(this.timerId); this.timerId = undefined; } - logout() { - this.cancelFreshnessCheck(); - this.revoke(); - } - private revoke() { this._authState = null; AuthService.storage.removeItem(); @@ -179,7 +262,6 @@ export default class AuthService { headers: auth.headers, tokenExpiresAt: auth.tokenExpiresAt, }; - AuthService.storage.setItem(JSON.stringify(this._authState)); }