@salesforcedevs/dx-components 1.3.0 → 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 +6 -1
- package/package.json +12 -4
- package/src/assets/svg/login-widget-bg.png +0 -0
- package/src/modules/dx/footer/links.ts +5 -1
- package/src/modules/dx/header/header.html +7 -0
- package/src/modules/dx/header/header.ts +4 -0
- package/src/modules/dx/logo/logo.ts +1 -1
- package/src/modules/dx/popover/popover.css +22 -0
- package/src/modules/dx/popover/popover.html +1 -0
- package/src/modules/dx/popover/popover.ts +76 -30
- package/src/modules/dx/tbidAvatarButton/tbidAvatarButton.css +33 -0
- package/src/modules/dx/tbidAvatarButton/tbidAvatarButton.html +53 -0
- package/src/modules/dx/tbidAvatarButton/tbidAvatarButton.ts +439 -0
- package/src/modules/dxHelpers/commonHeader/commonHeader.css +4 -2
- package/src/modules/dxUtils/async/async.ts +45 -0
package/lwc.config.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"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.
|
|
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/
|
|
35
|
+
"@types/uuid": "^8.3.4",
|
|
36
|
+
"@types/vimeo__player": "^2.16.2",
|
|
37
|
+
"eventsourcemock": "^2.0.0"
|
|
30
38
|
},
|
|
31
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "6bce82b465b57812c5de5e80abbdb291e3809be1"
|
|
32
40
|
}
|
|
Binary file
|
|
@@ -150,9 +150,13 @@ const openOneTrustInfoDisplay = () => {
|
|
|
150
150
|
export const termsLinks = [
|
|
151
151
|
{ href: privacyHref, label: "Privacy Statement" },
|
|
152
152
|
{
|
|
153
|
-
href: "https://
|
|
153
|
+
href: "https://www.salesforce.com/company/program-agreement/",
|
|
154
154
|
label: "Terms of Use"
|
|
155
155
|
},
|
|
156
|
+
{
|
|
157
|
+
href: "https://www.salesforce.com/company/legal/",
|
|
158
|
+
label: "Legal"
|
|
159
|
+
},
|
|
156
160
|
{
|
|
157
161
|
href: "https://www.salesforce.com/company/privacy/full_privacy/#nav_info",
|
|
158
162
|
label: "Use of Cookies"
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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:
|
|
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
|
|
241
|
-
slotted
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
+
}
|