@salesforcedevs/dx-components 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lwc.config.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
- "modules": [{ "dir": "src/modules" }],
2
+ "modules": [
3
+ { "dir": "src/modules" },
4
+ { "npm": "@salesforcedevs/dw-components" }
5
+ ],
3
6
  "expose": [
4
7
  "dx/banner",
5
8
  "dx/brandThemeProvider",
@@ -70,6 +73,7 @@
70
73
  "dx/spinner",
71
74
  "dx/tabPanel",
72
75
  "dx/tabPanelList",
76
+ "dx/tbidAvatarButton",
73
77
  "dx/toc",
74
78
  "dx/tooltip",
75
79
  "dx/tree",
@@ -90,6 +94,7 @@
90
94
  "dxHelpers/table",
91
95
  "dxHelpers/text",
92
96
  "dxUtils/analytics",
97
+ "dxUtils/async",
93
98
  "dxUtils/constants",
94
99
  "dxUtils/coveo",
95
100
  "dxUtils/dates",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/dx-components",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "DX Lightning web components",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -17,16 +17,24 @@
17
17
  "classnames": "^2.2.6",
18
18
  "coveo-search-ui": "^2.10082.5",
19
19
  "debounce": "^1.2.0",
20
+ "js-cookie": "^3.0.1",
21
+ "lodash.defaults": "^4.2.0",
20
22
  "lodash.get": "^4.4.2",
21
23
  "lodash.kebabcase": "^4.1.1",
22
- "microtip": "0.2.2"
24
+ "microtip": "0.2.2",
25
+ "salesforce-oauth2": "^0.2.0",
26
+ "uuid": "^9.0.0"
23
27
  },
24
28
  "devDependencies": {
25
29
  "@types/classnames": "^2.2.10",
26
30
  "@types/debounce": "^1.2.0",
31
+ "@types/js-cookie": "^3.0.2",
32
+ "@types/lodash.defaults": "^4.2.7",
27
33
  "@types/lodash.get": "^4.4.6",
28
34
  "@types/lodash.kebabcase": "^4.1.7",
29
- "@types/vimeo__player": "^2.16.2"
35
+ "@types/uuid": "^8.3.4",
36
+ "@types/vimeo__player": "^2.16.2",
37
+ "eventsourcemock": "^2.0.0"
30
38
  },
31
- "gitHead": "7f8775b406aa6593269d6fc0b0a71f0423e9d88b"
39
+ "gitHead": "6bce82b465b57812c5de5e80abbdb291e3809be1"
32
40
  }
@@ -19,6 +19,13 @@
19
19
  onstatechange={handleStateChange}
20
20
  ></dx-header-search>
21
21
  </div>
22
+ <div
23
+ if:true={showTbidLogin}
24
+ class="header-tbid-login"
25
+ onclick={closeMobileNavMenu}
26
+ >
27
+ <dw-tbid-login-menu></dw-tbid-login-menu>
28
+ </div>
22
29
  <div if:true={showSignup} class="header-login-signup">
23
30
  <dx-button
24
31
  aria-label="Browse Trials"
@@ -1,6 +1,10 @@
1
1
  import { HeaderBase } from "dxBaseElements/headerBase";
2
2
 
3
3
  export default class Header extends HeaderBase {
4
+ private get showTbidLogin(): boolean {
5
+ return this.showSignup;
6
+ }
7
+
4
8
  private get showSignup(): boolean {
5
9
  return this.signupLink
6
10
  ? (this.mobile && !this.isSearchOpen) || !this.mobile
@@ -3,6 +3,6 @@ import { LightningElement, api } from "lwc";
3
3
  export default class Logo extends LightningElement {
4
4
  @api href: string = "/";
5
5
  @api imgSrc: string = "/assets/svg/salesforce-cloud.svg";
6
- @api imgAlt: string = "Salesforce log";
6
+ @api imgAlt: string = "Salesforce logo";
7
7
  @api label!: string;
8
8
  }
@@ -64,3 +64,25 @@
64
64
  .popover-small {
65
65
  width: 136px;
66
66
  }
67
+
68
+ .popover-arrow {
69
+ position: absolute;
70
+
71
+ /* Weird spacing here to satisfy our linter */
72
+ background: linear-gradient(
73
+ 315deg,
74
+ transparent 4px,
75
+ white 4px
76
+ ); /* make only top half of rotated square visible */
77
+
78
+ width: 8px;
79
+ height: 8px;
80
+ transform: rotate(45deg);
81
+ border: var(--popover-border);
82
+ border-bottom: transparent;
83
+ border-right: transparent;
84
+ box-shadow: var(--dx-g-box-shadow-lg);
85
+ transition: opacity 0.2s linear, transform 0.2s linear;
86
+ transition-delay: 0.02s;
87
+ z-index: var(--dx-c-popover-z-index, var(--dx-g-z-index-max));
88
+ }
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <slot name="control" onslotchange={onSlotChange}></slot>
3
3
  <div class={containerClassName}>
4
+ <div class="popover-arrow" if:true={showArrow}></div>
4
5
  <div
5
6
  class={className}
6
7
  style={style}
@@ -5,7 +5,7 @@ import {
5
5
  PopperPlacement
6
6
  } from "typings/custom";
7
7
 
8
- import { computePosition, flip, size, shift } from "@floating-ui/dom";
8
+ import { computePosition, flip, size, shift, arrow } from "@floating-ui/dom";
9
9
  import cx from "classnames";
10
10
  import debounce from "debounce";
11
11
  import { isPrerender } from "dxUtils/seo";
@@ -31,6 +31,7 @@ export default class Popover extends LightningElement {
31
31
  @api openOnHover?: boolean = false; // dropdown opens/closes with hover
32
32
  @api width?: string | null = null;
33
33
  @api fullWidth?: boolean | null;
34
+ @api showArrow?: boolean | null;
34
35
 
35
36
  @api
36
37
  get open() {
@@ -103,9 +104,11 @@ export default class Popover extends LightningElement {
103
104
  private _open: boolean = false;
104
105
  private _rendered: boolean = isPrerender();
105
106
  private _role: string | null = null;
107
+ private arrow: HTMLElement | null = null;
106
108
  private control: (HTMLElement & { disabled?: boolean }) | null = null;
107
109
  private focusedValue: string | null = null;
108
110
  private popover: HTMLElement | null = null;
111
+ private popoverContent: HTMLElement | null = null;
109
112
 
110
113
  // LIFECYCLE METHODS
111
114
 
@@ -120,6 +123,12 @@ export default class Popover extends LightningElement {
120
123
  if (!this.popover) {
121
124
  this.popover = this.template.querySelector(".popover-container");
122
125
  }
126
+ if (!this.popoverContent) {
127
+ this.popoverContent = this.template.querySelector(".popover");
128
+ }
129
+ if (!this.arrow && this.showArrow) {
130
+ this.arrow = this.template.querySelector(".popover-arrow");
131
+ }
123
132
  }
124
133
 
125
134
  // GETTERS
@@ -232,23 +241,27 @@ export default class Popover extends LightningElement {
232
241
  this.openPopover();
233
242
  };
234
243
 
235
- private onSlotChange(e: LightningSlotElement) {
236
- const slot = e.target;
244
+ private onSlotChange(e: Event) {
245
+ const slot = e.target as LightningSlotElement;
237
246
  const elements = slot.assignedElements();
238
247
  const slotted = elements.length === 0 ? null : elements[0];
239
248
  // allows dropdown/select to compose popover
240
- const control =
241
- slotted.tagName === "SLOT" ? slotted.firstChild : slotted;
242
- const onboardControl =
243
- (!this.control || (control && !control.isSameNode(this.control))) &&
244
- control;
245
- if (onboardControl) {
246
- control.setAttribute("aria-haspopup", "true");
247
- control.style.cursor = this.openOnHover ? "default" : "cursor";
248
- this.control = control;
249
- this.addControlListeners();
250
- this.setPosition();
249
+ const slotElement = (
250
+ slotted?.tagName === "SLOT" ? slotted.firstChild : slotted
251
+ ) as HTMLElement | null;
252
+ const isWorkToDo =
253
+ slotElement &&
254
+ (!this.control || !slotElement.isSameNode(this.control));
255
+
256
+ if (!isWorkToDo) {
257
+ return;
251
258
  }
259
+
260
+ slotElement.setAttribute("aria-haspopup", "true");
261
+ slotElement.style.cursor = this.openOnHover ? "default" : "cursor";
262
+ this.control = slotElement;
263
+ this.addControlListeners();
264
+ this.setPosition();
252
265
  }
253
266
 
254
267
  private onKeyDown(e: KeyboardEvent): void {
@@ -277,29 +290,62 @@ export default class Popover extends LightningElement {
277
290
  if (this.popover && this.control) {
278
291
  await Promise.resolve();
279
292
  const popoverEl = this.popover;
293
+ const middleware = [flip(), shift({ padding: this.pagePadding })];
294
+
295
+ if (this.fullWidth) {
296
+ middleware.unshift(
297
+ size({
298
+ apply({ rects }) {
299
+ Object.assign(popoverEl.style, {
300
+ width: `${rects.reference.width}px`
301
+ });
302
+ }
303
+ })
304
+ );
305
+ }
306
+
307
+ if (this.arrow) {
308
+ middleware.push(arrow({ element: this.arrow, padding: 24 }));
309
+ }
280
310
 
281
311
  computePosition(this.control, popoverEl, {
282
312
  placement: this.placement,
283
- middleware: [
284
- ...(this.fullWidth
285
- ? [
286
- size({
287
- apply({ rects }) {
288
- Object.assign(popoverEl.style, {
289
- width: `${rects.reference.width}px`
290
- });
291
- }
292
- })
293
- ]
294
- : []),
295
- flip(),
296
- shift({ padding: this.pagePadding })
297
- ]
298
- }).then(({ x, y }) => {
313
+ middleware
314
+ }).then(({ x, y, placement, middlewareData }) => {
299
315
  Object.assign(popoverEl.style, {
300
316
  left: `${x}px`,
301
317
  top: `${y}px`
302
318
  });
319
+
320
+ if (this.arrow && middlewareData.arrow) {
321
+ const { x: arrowX, y: arrowY } = middlewareData.arrow;
322
+ const popoverPlacementSide = placement.split("-")[0] as
323
+ | "top"
324
+ | "bottom"
325
+ | "left"
326
+ | "right";
327
+ const staticSide = {
328
+ top: "bottom",
329
+ right: "left",
330
+ bottom: "top",
331
+ left: "right"
332
+ }[popoverPlacementSide];
333
+ const arrowStyles = {
334
+ left: arrowX != null ? `${arrowX}px` : "",
335
+ top: arrowY != null ? `${arrowY}px` : "",
336
+ right: "",
337
+ bottom: "",
338
+ [staticSide]: "-4px"
339
+ };
340
+
341
+ if (this.offset && this.popoverContent) {
342
+ arrowStyles.marginTop = getComputedStyle(
343
+ this.popoverContent
344
+ ).marginTop;
345
+ }
346
+
347
+ Object.assign(this.arrow.style, arrowStyles);
348
+ }
303
349
  });
304
350
  }
305
351
  };
@@ -0,0 +1,33 @@
1
+ @import "dxHelpers/reset";
2
+
3
+ :host {
4
+ --dx-c-button-custom-color: var(--dx-g-blue-vibrant-50);
5
+ --dx-c-button-custom-background: transparent;
6
+ --dx-c-button-custom-border: 1px solid transparent;
7
+ --dx-c-button-custom-color-hover: var(--dx-g-blue-vibrant-50);
8
+ --dx-c-button-custom-background-hover: var(--dx-g-cloud-blue-vibrant-90);
9
+ --dx-c-button-custom-border-hover: var(--dx-g-cloud-blue-vibrant-90);
10
+ --dx-c-slot-empty-width: max-content;
11
+ }
12
+
13
+ .avatar-control {
14
+ height: var(--dx-g-spacing-xl);
15
+ width: calc(var(--dx-g-spacing-lg) + 4px);
16
+ }
17
+
18
+ .avatar-small-container {
19
+ align-items: center;
20
+ display: flex;
21
+ padding: 2px 0;
22
+ }
23
+
24
+ .avatar {
25
+ border-radius: 100%;
26
+ border: 2px solid #0d9dda;
27
+ }
28
+
29
+ .avatar.avatar-small {
30
+ display: block;
31
+ height: calc(var(--dx-g-spacing-lg) + 4px);
32
+ width: calc(var(--dx-g-spacing-lg) + 4px);
33
+ }
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <div if:true={isLoggedIn}>
3
+ <dx-popover
4
+ offset="small"
5
+ width="320px"
6
+ placement="bottom-end"
7
+ open-on-hover
8
+ show-arrow
9
+ >
10
+ <div slot="control" class="avatar-small-container login-control">
11
+ <button
12
+ class="avatar-control"
13
+ aria-labelledby="avatar-control-label"
14
+ >
15
+ <img
16
+ src={userInfo.avatarImgSrc}
17
+ class="avatar avatar-small"
18
+ alt="Small user avatar image"
19
+ />
20
+ <span id="avatar-control-label" hidden>
21
+ {userInfo.fullName}
22
+ </span>
23
+ </button>
24
+ </div>
25
+ <div slot="content">
26
+ <slot name="popover-content"></slot>
27
+ </div>
28
+ </dx-popover>
29
+ </div>
30
+ <div if:false={isLoggedIn}>
31
+ <dx-popover
32
+ offset="small"
33
+ width="320px"
34
+ placement="bottom-end"
35
+ open-on-hover
36
+ show-arrow
37
+ >
38
+ <dx-button
39
+ slot="control"
40
+ class="login-control"
41
+ icon-symbol="user"
42
+ icon-position="left"
43
+ loading={isLoading}
44
+ variant="custom"
45
+ >
46
+ Login
47
+ </dx-button>
48
+ <div slot="content">
49
+ <slot name="popover-content"></slot>
50
+ </div>
51
+ </dx-popover>
52
+ </div>
53
+ </template>
@@ -0,0 +1,439 @@
1
+ import { api, LightningElement } from "lwc";
2
+ import Cookies from "js-cookie";
3
+ import defaults from "lodash.defaults";
4
+ import { pollUntil } from "dxUtils/async";
5
+
6
+ declare global {
7
+ interface Window {
8
+ SFIDWidget?: {
9
+ isAlive({ callback }: { callback: (...args: any[]) => any }): void;
10
+ logout(): void;
11
+ login(): void;
12
+ openid_response: any;
13
+ disabled: boolean;
14
+ };
15
+ }
16
+ }
17
+
18
+ interface UserInfo {
19
+ avatarImgSrc?: string;
20
+ company?: string;
21
+ firstName?: string;
22
+ id?: string;
23
+ lastName?: string;
24
+ orgId?: string;
25
+ relationship?: string;
26
+ role?: string;
27
+ username?: string;
28
+ fullName?: string;
29
+ }
30
+
31
+ interface EventSourceEvent extends Event {
32
+ id: string;
33
+ retry?: number;
34
+ data: string;
35
+ event?: string;
36
+ }
37
+
38
+ const enum PlatformEventsType {
39
+ UserDataChange = "UserDataChange",
40
+ UserPhotoChange = "UserPhotoChange",
41
+ UserMerge = "UserMerge"
42
+ }
43
+
44
+ // This component handles the core Salesforce OAuth 2.0 flow used by TBID for login, rendering a
45
+ // simple Login button or avatar icon depending on login status. It should be reusable in any case
46
+ // where TBID login is needed.
47
+ export default class TbidAvatarButton extends LightningElement {
48
+ @api tbidBaseUrl = "";
49
+ @api tbidApiBaseUrl = "";
50
+
51
+ @api login = (event: Event) => this.handleComponentLogin(event);
52
+ @api logout = () => this.handleComponentLogout();
53
+
54
+ private userInfo: UserInfo = {};
55
+ private isLoading = false;
56
+ private eventSource?: EventSource;
57
+ private _isRequestingUserInfo = false;
58
+ private _didReceiveUserInfo = false;
59
+
60
+ // _didReceiveUserInfo is a proxy for "isLoggedIn" because we only want to show the UI corresponding
61
+ // to logged-in state when user info has been received (avoids flashes of weird content)
62
+ private get isLoggedIn() {
63
+ return this._didReceiveUserInfo;
64
+ }
65
+ private get tbidIframeUrl() {
66
+ return `${this.tbidBaseUrl}/secur/logout.jsp`;
67
+ }
68
+ private get tbidApiLogoutUrl() {
69
+ return `${this.tbidApiBaseUrl}/logout`;
70
+ }
71
+ private get tbidApiUserInfoUrl() {
72
+ return `${this.tbidApiBaseUrl}/userinfo`;
73
+ }
74
+ private get tbidApiTokenUrl() {
75
+ return `${this.tbidApiBaseUrl}/token`;
76
+ }
77
+ private get tbidApiPlatformEventsUrl() {
78
+ return `${this.tbidApiBaseUrl}/platform-events`;
79
+ }
80
+
81
+ async connectedCallback() {
82
+ // Backend API for TBID login sets this state cookie if successful login occurs.
83
+ const isLoginSuccessRedirect =
84
+ Cookies.get("tbidLoginSuccess") === "true";
85
+
86
+ // The SFIDWidget enables seamless SSO in Chrome, so we will defer to it for login in cases where it is present and enabled.
87
+ const isWidgetDisabled = window.SFIDWidget?.disabled;
88
+ const isWidgetLoggedIn = window.SFIDWidget?.openid_response;
89
+ const isWidgetMaybeLoading =
90
+ !isWidgetDisabled &&
91
+ !isWidgetLoggedIn &&
92
+ Boolean(
93
+ // eslint-disable-next-line @lwc/lwc/no-document-query
94
+ document.querySelector(
95
+ 'script[src*="authProviderEmbeddedLogin"]'
96
+ )
97
+ );
98
+
99
+ window.addEventListener("tbid-login", this.handleSsoLogin);
100
+ window.addEventListener("tbid-logout", this.handleSsoLogout);
101
+
102
+ this.isLoading = true;
103
+
104
+ if (!isLoginSuccessRedirect) {
105
+ // Not a login redirect, no further work to do right now except try to request userInfo (in case a session already exists)
106
+ await this.requestUserInfo();
107
+
108
+ if (
109
+ !this._didReceiveUserInfo &&
110
+ isWidgetMaybeLoading &&
111
+ !window.SFIDWidget?.openid_response &&
112
+ window.SFIDWidget?.isAlive
113
+ ) {
114
+ // The initial userinfo request failed, but SFIDWidget was maybe loading, and it
115
+ // still hasn't completed login, so we want to check whether it's alive and wait
116
+ // (up to a maximum of 1 second) for it to possibly finish logging in before we
117
+ // turn off the "loading" indicator (this prevents odd flashes of content).
118
+ await this.waitForSFIDWidget();
119
+ }
120
+
121
+ this.isLoading = false;
122
+ } else {
123
+ // We got here from a login success redirect
124
+ Cookies.remove("tbidLoginSuccess"); // first, cleanup
125
+
126
+ if (isWidgetMaybeLoading) {
127
+ // This is a successful login and we have an SFIDWidget present, so it *should*
128
+ // handle finishing up login itself. But it has a maximum of 1 second to do so.
129
+ const didWidgetLoad = await this.waitForSFIDWidget();
130
+ if (!didWidgetLoad) {
131
+ // SFIDWidget not responding in time; complete login ourselves.
132
+ await this.requestUserInfo();
133
+ this.dispatchLoginSuccess();
134
+ }
135
+ } else {
136
+ // No SFIDWidget loading, proceed immediately to completing normal initialization.
137
+ await this.requestUserInfo();
138
+ this.dispatchLoginSuccess();
139
+ }
140
+
141
+ this.isLoading = false;
142
+ }
143
+ }
144
+
145
+ disconnectedCallback() {
146
+ window.removeEventListener("tbid-login", this.handleSsoLogin);
147
+ window.removeEventListener("tbid-logout", this.handleSsoLogout);
148
+ this.teardownEventSource();
149
+ }
150
+
151
+ // The SFIDWidget requires third party cookies to function. In browsers with third party
152
+ // cookies disabled, the widget will load but never log in, resulting in delayed UI updates
153
+ // in some cases. To mitigate this, this method calls the widget's "isAlive" method, which
154
+ // should respond very quickly if third party cookies are enabled. If it does respond quickly,
155
+ // then this method waits a maximum of 1 second for the widget to finish logging in. If it does
156
+ // *not* respond quickly, the method resolves early so that we can stop waiting pointlessly.
157
+ private waitForSFIDWidget = async () => {
158
+ const widgetNotAlivePromise = new Promise<boolean>((resolve) => {
159
+ const timeoutId = setTimeout(() => resolve(false), 100);
160
+ window.SFIDWidget?.isAlive({
161
+ // If the widget responds as expected, this promise will fail to resolve and thus
162
+ // we will continue to wait on the other promise, below.
163
+ callback: () => clearTimeout(timeoutId)
164
+ });
165
+ });
166
+ const widgetLoggedInPromise = pollUntil(
167
+ () => Boolean(window.SFIDWidget?.openid_response),
168
+ 100,
169
+ 1000
170
+ );
171
+ const success = await Promise.race([
172
+ widgetNotAlivePromise,
173
+ widgetLoggedInPromise
174
+ ]);
175
+ return success;
176
+ };
177
+
178
+ private setupEventSource = () => {
179
+ // subscribe to platform events
180
+ this.eventSource = new EventSource(this.tbidApiPlatformEventsUrl);
181
+ this.eventSource.addEventListener(
182
+ PlatformEventsType.UserDataChange,
183
+ // eslint-disable-next-line no-undef
184
+ this.handleUserDataChange as EventListener
185
+ );
186
+ this.eventSource.addEventListener(
187
+ PlatformEventsType.UserMerge,
188
+ this.handleSsoLogout
189
+ );
190
+ };
191
+
192
+ private teardownEventSource = () => {
193
+ if (!this.eventSource) {
194
+ return;
195
+ }
196
+
197
+ this.eventSource.removeEventListener(
198
+ PlatformEventsType.UserDataChange,
199
+ // eslint-disable-next-line no-undef
200
+ this.handleUserDataChange as EventListener
201
+ );
202
+ this.eventSource.removeEventListener(
203
+ PlatformEventsType.UserMerge,
204
+ this.handleSsoLogout
205
+ );
206
+ this.eventSource.close();
207
+ this.eventSource = undefined;
208
+ };
209
+
210
+ private handleLogout = (isSsoLogout: boolean) => {
211
+ // Reset state
212
+ this._didReceiveUserInfo = false;
213
+ this.isLoading = false;
214
+ this.updateUserInfo({}); // clear old info
215
+ this.teardownEventSource();
216
+ this.dispatchLogoutSuccess();
217
+
218
+ if (!isSsoLogout && window.SFIDWidget?.openid_response) {
219
+ // If the SFIDWidget is around and has an access token, and if logout was not *already*
220
+ // triggered by SSO logout, defer to the SFIDWidget. This will ensure that the SSO
221
+ // session is logged out as well.
222
+ window.SFIDWidget.logout();
223
+ } else {
224
+ // Always clear the session token; if not SSO logout, this will also revoke the token with
225
+ // TBID; if SSO logout, that step is already taken care of. No need to await this.
226
+ fetch(`${this.tbidApiLogoutUrl}?isSsoLogout=${isSsoLogout}`, {
227
+ method: "DELETE",
228
+ credentials: "same-origin"
229
+ });
230
+
231
+ if (!isSsoLogout) {
232
+ // Dropping an iframe is required to fully get TBID to destroy the session; this is
233
+ // a TBID issue and they have requested that we do this for now.
234
+ const ifr = document.createElement("iframe");
235
+ ifr.setAttribute("src", this.tbidIframeUrl);
236
+ document.body.appendChild(ifr);
237
+ }
238
+ }
239
+ };
240
+
241
+ // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists.
242
+ private handleSsoLogout = this.handleLogout.bind(this, true);
243
+
244
+ // This handles logout from _within_ this component ("Logout" click), rather than from SSO via the SFIDWidget.
245
+ private handleComponentLogout = () => {
246
+ this.handleLogout(false);
247
+ };
248
+
249
+ // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists.
250
+ private handleSsoLogin = async (event: Event) => {
251
+ const { userInfo } = (event as CustomEvent).detail;
252
+ const token = userInfo?.access_token;
253
+
254
+ // `token` should always be defined if an SSO login occurs, but we'll be extra safe.
255
+ if (!token) {
256
+ return;
257
+ }
258
+
259
+ // If an SSO login occurred and we have an SSO access token, we want to start using
260
+ // it rather than some other non-linked access token, for the "seamless SSO"
261
+ // experience. The `token` endpoint will invalidate any other existing token.
262
+ try {
263
+ const tokenResponse = await fetch(this.tbidApiTokenUrl, {
264
+ method: "POST",
265
+ headers: {
266
+ "Content-Type": "application/json"
267
+ },
268
+ body: JSON.stringify({
269
+ token
270
+ })
271
+ });
272
+
273
+ if (tokenResponse.ok) {
274
+ // With a new token, we want to re-establish the eventSource connection and treat as new login.
275
+ this.teardownEventSource();
276
+ this.setupEventSource();
277
+ // TODO: It sucks that we have to re-request user info here, since it should come with the token
278
+ // on SSO login. For some reason, the user info on SSO login lacks the user's photo URLs, so we
279
+ // have to re-request it. Maybe something to bring up with the TBID team.
280
+ await this.requestUserInfo();
281
+ this.dispatchLoginSuccess();
282
+ } else if (
283
+ tokenResponse.status === 409 &&
284
+ !this._didReceiveUserInfo
285
+ ) {
286
+ // Valid token received, but we still need to grab userInfo. This could happen in certain edge cases
287
+ // involving "seamless SSO" (e.g., you might be sitting on our page in more than one tab, then log in
288
+ // on one of them; in that case, our back-end will receive the token from one tab, end up _rejecting_ it
289
+ // as a duplicate on other tabs where the login event occurs, and yet still need to request user info
290
+ // on those other tabs).
291
+ await this.requestUserInfo();
292
+ }
293
+ } catch (ex) {
294
+ console.error(`Attempt to update token failed: ${ex}`);
295
+ }
296
+ };
297
+
298
+ // This handles logout from _within_ this component ("Trailblazer.me" click), rather than from SSO via the SFIDWidget.
299
+ private handleComponentLogin = (event: Event) => {
300
+ if (window.SFIDWidget) {
301
+ // If the SFIDWidget is around, defer to it for login. This will ensure that the SSO
302
+ // session is used if possible.
303
+ event.preventDefault(); // don't navigate; the widget will handle that instead.
304
+ window.SFIDWidget.login();
305
+ }
306
+ };
307
+
308
+ private handleUserDataChange = ({ data }: EventSourceEvent) => {
309
+ let parsedEventData: any;
310
+
311
+ try {
312
+ parsedEventData = JSON.parse(data);
313
+ } catch (ex) {
314
+ console.error(
315
+ `Unparseable ${PlatformEventsType.UserDataChange} data received`
316
+ );
317
+ return;
318
+ }
319
+
320
+ this.updateUserInfo(parsedEventData);
321
+ this.dispatchUserInfo();
322
+ };
323
+
324
+ // This is the only method that should manipulate this.userInfo.
325
+ private updateUserInfo = (userInfo: any) => {
326
+ if (!userInfo) {
327
+ return;
328
+ }
329
+
330
+ if (Object.keys(userInfo).length === 0) {
331
+ // This is a "clear" call.
332
+ this.userInfo = {};
333
+ return;
334
+ }
335
+
336
+ const dirtyNextUserInfo: UserInfo = {
337
+ avatarImgSrc: userInfo.photos?.thumbnail,
338
+ firstName: userInfo.given_name,
339
+ lastName: userInfo.family_name,
340
+ username: userInfo.preferred_username,
341
+ id: userInfo.user_id,
342
+ orgId: userInfo.organization_id,
343
+ fullName:
344
+ userInfo.given_name && userInfo.family_name
345
+ ? `${userInfo.given_name} ${userInfo.family_name}`
346
+ : userInfo.given_name || userInfo.family_name || ""
347
+ };
348
+
349
+ if (userInfo.custom_attributes) {
350
+ // ProfilePictureUrl updates earlier than photos.thumbnail, so we prefer it if it's available here.
351
+ dirtyNextUserInfo.avatarImgSrc =
352
+ userInfo.custom_attributes.ProfilePictureUrl ||
353
+ dirtyNextUserInfo.avatarImgSrc;
354
+ dirtyNextUserInfo.company = userInfo.custom_attributes.CompanyName;
355
+ dirtyNextUserInfo.relationship =
356
+ userInfo.custom_attributes.RelationshipToSalesforce;
357
+ dirtyNextUserInfo.role = userInfo.custom_attributes.Role;
358
+ }
359
+
360
+ const cleanNextUserInfo: UserInfo = {};
361
+
362
+ Object.entries(dirtyNextUserInfo).forEach(([key, val]) => {
363
+ // uncleanUserInfo may have undefined values which we don't want in the final product
364
+ if (!val) {
365
+ return;
366
+ }
367
+
368
+ let cleanVal = val;
369
+
370
+ // Design requests that we not show the @trailblazer.me part of the username
371
+ if (key === "username" && typeof val === "string") {
372
+ const regExpMatch = val.match("(.*)@trailblazer.me$");
373
+ if (regExpMatch && regExpMatch[1]) {
374
+ cleanVal = regExpMatch[1];
375
+ }
376
+ }
377
+
378
+ cleanNextUserInfo[key as keyof UserInfo] = cleanVal;
379
+ });
380
+
381
+ // Keep the old info for any values _not_ defined on `cleanUserInfo`
382
+ this.userInfo = defaults(cleanNextUserInfo, this.userInfo);
383
+ };
384
+
385
+ private requestUserInfo = async () => {
386
+ if (this._isRequestingUserInfo) {
387
+ // prevent duplicate requests
388
+ return;
389
+ }
390
+
391
+ this._isRequestingUserInfo = true;
392
+
393
+ try {
394
+ const userInfoRes = await fetch(this.tbidApiUserInfoUrl);
395
+
396
+ if (userInfoRes.ok) {
397
+ if (!this.eventSource) {
398
+ this.setupEventSource();
399
+ }
400
+ const userInfo = await userInfoRes.json();
401
+ this.updateUserInfo(userInfo);
402
+ this._didReceiveUserInfo = true;
403
+ this.dispatchUserInfo();
404
+ }
405
+ } catch (ex) {
406
+ // Something not directly related to auth went wrong. Unsure what to do here.
407
+ console.error(`Could not request user info: ${ex}`);
408
+ }
409
+
410
+ this._isRequestingUserInfo = false;
411
+ };
412
+
413
+ private dispatchUserInfo = () => {
414
+ this.dispatchEvent(
415
+ new CustomEvent("tbiduserinfo", {
416
+ bubbles: true,
417
+ detail: {
418
+ userInfo: this.userInfo
419
+ }
420
+ })
421
+ );
422
+ };
423
+
424
+ private dispatchLoginSuccess = () => {
425
+ this.dispatchEvent(
426
+ new CustomEvent("tbidloginsuccess", {
427
+ bubbles: true
428
+ })
429
+ );
430
+ };
431
+
432
+ private dispatchLogoutSuccess = () => {
433
+ this.dispatchEvent(
434
+ new CustomEvent("tbidlogoutsuccess", {
435
+ bubbles: true
436
+ })
437
+ );
438
+ };
439
+ }
@@ -108,12 +108,14 @@ header.state-show-mobile-nav .header_l2_group-nav_overflow {
108
108
  margin-left: var(--dx-g-spacing-sm);
109
109
  }
110
110
 
111
- .header-login-signup {
111
+ .header-login-signup,
112
+ .header-tbid-login {
112
113
  display: flex;
113
114
  align-items: center;
114
115
  }
115
116
 
116
- .header-login-signup dx-button {
117
+ .header-login-signup dx-button,
118
+ .header-tbid-login dw-tbid-login-menu {
117
119
  margin-left: var(--dx-g-spacing-smd);
118
120
  }
119
121
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Checks whether a condition is satisfied at a specified interval, stopping after a maximum amount
3
+ * of time (if specified). Returns a Promise that resolves with `true` if the condition was
4
+ * satisfied during polling (if a maximum time is specified, it can resolve with false if the
5
+ * condition was not satisfied within the maximum time).
6
+ *
7
+ * Usage:
8
+ * await pollUntil(() => something.is.true, 200, 1000);
9
+ *
10
+ * @param {Function} condition the "check" callback that will be called to determine whether the
11
+ * condition has been satisfied (satisfied === callback returns truthy value)
12
+ * @param {number} atIntervalMs value in milliseconds specifying how often to poll
13
+ * @param {number} [forMaximumMs] value in milliseconds specifying the maximum time to poll
14
+ * @returns {Promise}
15
+ */
16
+ export function pollUntil(
17
+ condition: () => boolean,
18
+ atIntervalMs: number,
19
+ forMaximumMs?: number
20
+ ) {
21
+ return new Promise<boolean>((resolve) => {
22
+ let timeoutId: ReturnType<typeof setTimeout>;
23
+ const intervalId = setInterval(() => {
24
+ if (condition()) {
25
+ resolveWithCleanup(true);
26
+ }
27
+ }, atIntervalMs);
28
+ if (typeof forMaximumMs === "number") {
29
+ timeoutId = setTimeout(() => {
30
+ // One last check in case the condition was satisfied right before the end:
31
+ resolveWithCleanup(condition() || false);
32
+ }, forMaximumMs);
33
+ }
34
+ if (condition()) {
35
+ // Condition already fulfilled before intervals, no need to keep waiting!
36
+ resolveWithCleanup(true);
37
+ }
38
+
39
+ function resolveWithCleanup(success: boolean) {
40
+ clearInterval(intervalId);
41
+ clearTimeout(timeoutId);
42
+ resolve(success);
43
+ }
44
+ });
45
+ }