import { api, LightningElement } from "lwc"; import Cookies from "js-cookie"; import defaults from "lodash.defaults"; import { pollUntil } from "dxUtils/async"; declare global { interface Window { SFIDWidget?: { isAlive({ callback }: { callback: (...args: any[]) => any }): void; logout(): void; login(): void; openid_response: any; disabled: boolean; }; } } interface UserInfo { avatarImgSrc?: string; company?: string; firstName?: string; id?: string; lastName?: string; orgId?: string; relationship?: string; role?: string; username?: string; fullName?: string; } interface EventSourceEvent extends Event { id: string; retry?: number; data: string; event?: string; } const enum PlatformEventsType { UserDataChange = "UserDataChange", UserPhotoChange = "UserPhotoChange", UserMerge = "UserMerge" } // TODO: We probably should rewrite the logic of this component once the new identity stuff is released, // especially because SSO should work in all browsers at that point, and we won't need to do any weird // checking for the presence of SFIDWidget (and all the associated pain from trying to "time" the triggering of login // events due to logins coming via two distinct routes in Chrome). // This component handles the core Salesforce OAuth 2.0 flow used by TBID for login, rendering a // simple Login button or avatar icon depending on login status. It should be reusable in any case // where TBID login is needed. export default class TbidAvatarButton extends LightningElement { @api tbidBaseUrl = ""; @api tbidApiBaseUrl = ""; @api login = (event: Event) => this.handleComponentLogin(event); @api logout = (event: Event) => this.handleComponentLogout(event); private userInfo: UserInfo = {}; private isLoading = false; private eventSource?: EventSource; private _isRequestingUserInfo = false; private _didReceiveUserInfo = false; private _didDispatchLoginSuccess = false; // _didReceiveUserInfo is a proxy for "isLoggedIn" because we only want to show the UI corresponding // to logged-in state when user info has been received (avoids flashes of weird content) private get isLoggedIn() { return this._didReceiveUserInfo; } private get tbidIframeUrl() { return `${this.tbidBaseUrl}/secur/logout.jsp`; } private get tbidApiLogoutUrl() { return `${this.tbidApiBaseUrl}/logout`; } private get tbidApiUserInfoUrl() { return `${this.tbidApiBaseUrl}/userinfo`; } private get tbidApiTokenUrl() { return `${this.tbidApiBaseUrl}/token`; } private get tbidApiPlatformEventsUrl() { return `${this.tbidApiBaseUrl}/platform-events`; } async connectedCallback() { // Backend API for TBID login sets this state cookie if successful login occurs. const isLoginSuccessRedirect = Cookies.get("tbidLoginSuccess") === "true"; // The SFIDWidget enables seamless SSO in Chrome, so we will defer to it for login in cases where it is present and enabled. const isWidgetDisabled = window.SFIDWidget?.disabled; const isWidgetLoggedIn = window.SFIDWidget?.openid_response; const isWidgetMaybeLoading = !isWidgetDisabled && !isWidgetLoggedIn && Boolean( // eslint-disable-next-line @lwc/lwc/no-document-query document.querySelector( 'script[src*="authProviderEmbeddedLogin"]' ) ); window.addEventListener("tbid-login", this.handleSsoLogin); window.addEventListener("tbid-logout", this.handleSsoLogout); this.isLoading = true; if (!isLoginSuccessRedirect) { // Not a login redirect, no further work to do right now except try to request userInfo (in case a session already exists) await this.requestUserInfo(); if ( !this._didReceiveUserInfo && isWidgetMaybeLoading && !window.SFIDWidget?.openid_response && window.SFIDWidget?.isAlive ) { // The initial userinfo request failed, but SFIDWidget was maybe loading, and it // still hasn't completed login, so we want to check whether it's alive and wait // (up to a maximum of 1 second) for it to possibly finish logging in before we // turn off the "loading" indicator (this prevents odd flashes of content). await this.waitForSFIDWidget(); } this.isLoading = false; } else { // We got here from a login success redirect Cookies.remove("tbidLoginSuccess"); // first, cleanup if (isWidgetMaybeLoading) { // This is a successful login and we have an SFIDWidget present, so it *should* // handle finishing up login itself. But it has a maximum of 1 second to do so. const didWidgetLoad = await this.waitForSFIDWidget(); if (!didWidgetLoad) { // SFIDWidget not responding in time; complete login ourselves. await this.requestUserInfo(); this.dispatchLoginSuccess(); } } else { // No SFIDWidget loading, proceed immediately to completing normal initialization. await this.requestUserInfo(); this.dispatchLoginSuccess(); } this.isLoading = false; } } disconnectedCallback() { window.removeEventListener("tbid-login", this.handleSsoLogin); window.removeEventListener("tbid-logout", this.handleSsoLogout); this.teardownEventSource(); } // The SFIDWidget requires third party cookies to function. In browsers with third party // cookies disabled, the widget will load but never log in, resulting in delayed UI updates // in some cases. To mitigate this, this method calls the widget's "isAlive" method, which // should respond very quickly if third party cookies are enabled. If it does respond quickly, // then this method waits a maximum of 1 second for the widget to finish logging in. If it does // *not* respond quickly, the method resolves early so that we can stop waiting pointlessly. private waitForSFIDWidget = async () => { const millisecondsFor30fps = 33; const widgetNotAlivePromise = new Promise((resolve) => { const timeoutId = setTimeout( () => resolve(false), 5 * millisecondsFor30fps // give SFIDWidget roughly 5 chances to load ); window.SFIDWidget?.isAlive({ // If the widget responds as expected, this promise will fail to resolve and thus // we will continue to wait on the other promise, below. callback: () => clearTimeout(timeoutId) }); }); const widgetLoggedInPromise = pollUntil( () => Boolean(window.SFIDWidget?.openid_response), millisecondsFor30fps, 1000 ); const success = await Promise.race([ widgetNotAlivePromise, widgetLoggedInPromise ]); return success; }; private setupEventSource = () => { // subscribe to platform events this.eventSource = new EventSource(this.tbidApiPlatformEventsUrl); this.eventSource.addEventListener( PlatformEventsType.UserDataChange, // eslint-disable-next-line no-undef this.handleUserDataChange as EventListener ); this.eventSource.addEventListener( PlatformEventsType.UserMerge, this.handleSsoLogout ); }; private teardownEventSource = () => { if (!this.eventSource) { return; } this.eventSource.removeEventListener( PlatformEventsType.UserDataChange, // eslint-disable-next-line no-undef this.handleUserDataChange as EventListener ); this.eventSource.removeEventListener( PlatformEventsType.UserMerge, this.handleSsoLogout ); this.eventSource.close(); this.eventSource = undefined; }; private handleLogout = (isSsoLogout: boolean) => { // Reset state this._didReceiveUserInfo = false; this.isLoading = false; this.updateUserInfo({}); // clear old info this.teardownEventSource(); this.dispatchLogoutSuccess(); if (!isSsoLogout && window.SFIDWidget?.openid_response) { // If the SFIDWidget is around and has an access token, and if logout was not *already* // triggered by SSO logout, defer to the SFIDWidget. This will ensure that the SSO // session is logged out as well. window.SFIDWidget.logout(); } else { // Always clear the session token; if not SSO logout, this will also revoke the token with // TBID; if SSO logout, that step is already taken care of. No need to await this. fetch(`${this.tbidApiLogoutUrl}?isSsoLogout=${isSsoLogout}`, { method: "DELETE", credentials: "same-origin" }); if (!isSsoLogout) { // Dropping an iframe is required to fully get TBID to destroy the session; this is // a TBID issue and they have requested that we do this for now. const ifr = document.createElement("iframe"); ifr.setAttribute("src", this.tbidIframeUrl); document.body.appendChild(ifr); } } }; // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists. private handleSsoLogout = this.handleLogout.bind(this, true); // This handles logout from _within_ this component ("Logout" click), rather than from SSO via the SFIDWidget. private handleComponentLogout = (event: Event) => { event?.preventDefault(); this.handleLogout(false); }; // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists. private handleSsoLogin = async (event: Event) => { const { userInfo } = (event as CustomEvent).detail; const token = userInfo?.access_token; // `token` should always be defined if an SSO login occurs, but we'll be extra safe. if (!token) { return; } // If an SSO login occurred and we have an SSO access token, we want to start using // it rather than some other non-linked access token, for the "seamless SSO" // experience. The `token` endpoint will invalidate any other existing token. try { const tokenResponse = await fetch(this.tbidApiTokenUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); if (tokenResponse.ok) { // With a new token, we want to re-establish the eventSource connection and treat as new login. this.teardownEventSource(); this.setupEventSource(); // TODO: It sucks that we have to re-request user info here, since it should come with the token // on SSO login. For some reason, the user info on SSO login lacks the user's photo URLs, so we // have to re-request it. Maybe something to bring up with the TBID team. await this.requestUserInfo(); this.dispatchLoginSuccess(true); } else if ( tokenResponse.status === 409 && !this._didReceiveUserInfo && !this._isRequestingUserInfo // don't duplicate requests when we already have this token and a request is on-going ) { // Valid but duplicate token received, but we still need to grab userInfo. This could happen in certain edge cases // involving "seamless SSO" (e.g., you might be sitting on our page in more than one tab, then log in // on one of them; in that case, our back-end will receive the token from one tab, end up _rejecting_ it // as a duplicate on other tabs where the login event occurs, and yet still need to request user info // on those other tabs). await this.requestUserInfo(); } } catch (ex) { console.error(`Attempt to update token failed: ${ex}`); } }; // This handles logout from _within_ this component ("Trailblazer.me" click), rather than from SSO via the SFIDWidget. private handleComponentLogin = (event: Event) => { if (window.SFIDWidget) { // If the SFIDWidget is around, defer to it for login. This will ensure that the SSO // session is used if possible. event.preventDefault(); // don't navigate; the widget will handle that instead. window.SFIDWidget.login(); } }; private handleUserDataChange = ({ data }: EventSourceEvent) => { let parsedEventData: any; try { parsedEventData = JSON.parse(data); } catch (ex) { console.error( `Unparseable ${PlatformEventsType.UserDataChange} data received` ); return; } this.updateUserInfo(parsedEventData); this.dispatchUserInfo(); }; // This is the only method that should manipulate this.userInfo. private updateUserInfo = (userInfo: any) => { if (!userInfo) { return; } if (Object.keys(userInfo).length === 0) { // This is a "clear" call. this.userInfo = {}; return; } const dirtyNextUserInfo: UserInfo = { avatarImgSrc: userInfo.photos?.thumbnail, firstName: userInfo.given_name, lastName: userInfo.family_name, username: userInfo.preferred_username, id: userInfo.user_id, orgId: userInfo.organization_id, fullName: userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.given_name || userInfo.family_name || "" }; if (userInfo.custom_attributes) { // ProfilePictureUrl updates earlier than photos.thumbnail, so we prefer it if it's available here. dirtyNextUserInfo.avatarImgSrc = userInfo.custom_attributes.ProfilePictureUrl || dirtyNextUserInfo.avatarImgSrc; dirtyNextUserInfo.company = userInfo.custom_attributes.CompanyName; dirtyNextUserInfo.relationship = userInfo.custom_attributes.RelationshipToSalesforce; dirtyNextUserInfo.role = userInfo.custom_attributes.Role; } const cleanNextUserInfo: UserInfo = {}; Object.entries(dirtyNextUserInfo).forEach(([key, val]) => { // uncleanUserInfo may have undefined values which we don't want in the final product if (!val) { return; } let cleanVal = val; // Design requests that we not show the @trailblazer.me part of the username if (key === "username" && typeof val === "string") { const regExpMatch = val.match("(.*)@trailblazer.me$"); if (regExpMatch && regExpMatch[1]) { cleanVal = regExpMatch[1]; } } cleanNextUserInfo[key as keyof UserInfo] = cleanVal; }); // Keep the old info for any values _not_ defined on `cleanUserInfo` this.userInfo = defaults(cleanNextUserInfo, this.userInfo); }; private requestUserInfo = async () => { this._isRequestingUserInfo = true; try { const userInfoRes = await fetch(this.tbidApiUserInfoUrl); if (userInfoRes.ok) { if (!this.eventSource) { this.setupEventSource(); } const userInfo = await userInfoRes.json(); this.updateUserInfo(userInfo); this._didReceiveUserInfo = true; this.dispatchUserInfo(); } } catch (ex) { // Something not directly related to auth went wrong. Unsure what to do here. console.error(`Could not request user info: ${ex}`); } this._isRequestingUserInfo = false; }; private dispatchUserInfo = () => { this.dispatchEvent( new CustomEvent("tbiduserinfo", { bubbles: true, detail: { userInfo: this.userInfo } }) ); }; private dispatchLoginSuccess = (receivedNewToken = false) => { // Receiving a new token should be treated as a new (SSO) login. if (!this._didDispatchLoginSuccess || receivedNewToken) { this._didDispatchLoginSuccess = true; this.dispatchEvent( new CustomEvent("tbidloginsuccess", { bubbles: true }) ); } }; private dispatchLogoutSuccess = () => { this.dispatchEvent( new CustomEvent("tbidlogoutsuccess", { bubbles: true }) ); }; }