@salesforcedevs/dx-components 1.2.6 → 1.2.7-avatar-button-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.
@@ -0,0 +1,386 @@
1
+ import { api, LightningElement } from "lwc";
2
+ import Cookies from "js-cookie";
3
+ import { track } from "dxUtils/analytics";
4
+ import defaults from "lodash.defaults";
5
+ import escapeHtml from "escape-html";
6
+
7
+ // TBID Page URLs:
8
+ // TODO: Switch this to env variable once we're out of dev
9
+ const TBID_BASE_URL = "https://dev1-trailblazer-identity.cs192.force.com";
10
+ const TBID_SETTINGS_URL = `${TBID_BASE_URL}/settings`;
11
+ const TBID_PROFILE_URL = `${TBID_BASE_URL}/id`;
12
+ const TBID_IFRAME_URL = `${TBID_BASE_URL}/secur/logout.jsp`;
13
+
14
+ // API URLs:
15
+ // TODO: Switch this to env variable once we're out of dev
16
+ const TBID_API_BASE_URL = "https://development.developer.salesforce.com/tbid";
17
+ const TBID_API_LOGOUT_URL = `${TBID_API_BASE_URL}/logout`;
18
+ const TBID_API_USERINFO_URL = `${TBID_API_BASE_URL}/userinfo`;
19
+ const TBID_API_LOGIN_URL = `${TBID_API_BASE_URL}/dologin`;
20
+ const TBID_API_TOKEN_URL = `${TBID_API_BASE_URL}/token`;
21
+ const TBID_API_PLATFORM_EVENTS_URL = `${TBID_API_BASE_URL}/platform-events`;
22
+
23
+ declare global {
24
+ interface Window {
25
+ SFIDWidget?: {
26
+ logout(): void;
27
+ login(): void;
28
+ openid_response: any;
29
+ disabled: boolean;
30
+ };
31
+ }
32
+ }
33
+
34
+ export interface UserInfo {
35
+ avatarImgSrc?: string;
36
+ company?: string;
37
+ firstName?: string;
38
+ id?: string;
39
+ lastName?: string;
40
+ orgId?: string;
41
+ relationship?: string;
42
+ role?: string;
43
+ username?: string;
44
+ fullName?: string;
45
+ }
46
+
47
+ interface EventSourceEvent extends Event {
48
+ id: string;
49
+ retry?: number;
50
+ data: string;
51
+ event?: string;
52
+ }
53
+
54
+ const enum PlatformEventsType {
55
+ UserDataChange = "UserDataChange",
56
+ UserPhotoChange = "UserPhotoChange",
57
+ UserMerge = "UserMerge"
58
+ }
59
+
60
+ const trackableUserInfo = new Set([
61
+ "company",
62
+ "id",
63
+ "orgId",
64
+ "relationship",
65
+ "role"
66
+ ]);
67
+
68
+ export default class AvatarButton extends LightningElement {
69
+ @api size: "small" | "medium" | "large" = "medium";
70
+
71
+ private userInfo: UserInfo = {};
72
+ private isLoading = false;
73
+ private settingsUrl = TBID_SETTINGS_URL;
74
+ private profileUrl = TBID_PROFILE_URL;
75
+ private eventSource?: EventSource;
76
+ private _hasRendered = false;
77
+ private _didReceiveUserInfo = false;
78
+
79
+ private get loginUrl() {
80
+ return `${TBID_API_LOGIN_URL}?startUrl=${encodeURIComponent(
81
+ window.location.href
82
+ )}`;
83
+ }
84
+
85
+ private get isLoggedIn() {
86
+ return this._didReceiveUserInfo;
87
+ }
88
+
89
+ connectedCallback() {
90
+ const isLoginSuccessRedirect =
91
+ Cookies.get("tbidLoginSuccess") === "true";
92
+
93
+ if (isLoginSuccessRedirect) {
94
+ this.isLoading = true;
95
+ Cookies.remove("tbidLoginSuccess"); // cleanup
96
+ }
97
+
98
+ window.addEventListener("tbid-login", this.handleSsoLogin);
99
+ window.addEventListener("tbid-logout", this.handleSsoLogout);
100
+
101
+ const isWidgetDisabled = window.SFIDWidget?.disabled;
102
+ const isWidgetLoggedIn = window.SFIDWidget?.openid_response;
103
+
104
+ // If the component loads and (1) we are logging in and (2) the embedded login widget script
105
+ // is in the DOM but (3) the widget has either not loaded or not completed login yet, we
106
+ // defer the userInfo request in order to allow the SFIDWidget the time to trigger it (via
107
+ // it's own login handler) after SSO login. If any of (1) - (3) are false, then we request
108
+ // user info immediately. The check prevents duplicate requests.
109
+ if (
110
+ this.isLoading &&
111
+ (!window.SFIDWidget || (!isWidgetDisabled && !isWidgetLoggedIn)) &&
112
+ document.querySelector('script[src*="authProviderEmbeddedLogin"]')
113
+ ) {
114
+ setTimeout(() => {
115
+ if (!window.SFIDWidget?.openid_response) {
116
+ // SFIDWidget not responding in time; handle login ourselves.
117
+ this.trackLoginSuccess();
118
+ this.requestUserInfo();
119
+ }
120
+ }, 1000);
121
+ } else {
122
+ if (this.isLoading) {
123
+ // This is a successful login; track it.
124
+ this.trackLoginSuccess();
125
+ }
126
+ this.requestUserInfo();
127
+ }
128
+ }
129
+
130
+ disconnectedCallback() {
131
+ window.removeEventListener("tbid-login", this.handleSsoLogin);
132
+ window.removeEventListener("tbid-logout", this.handleSsoLogout);
133
+ this.teardownEventSource();
134
+ }
135
+
136
+ private handleUserDataChange = ({ data }: EventSourceEvent) => {
137
+ let parsedEventData: any;
138
+
139
+ try {
140
+ parsedEventData = JSON.parse(data);
141
+ } catch (ex) {
142
+ console.error(
143
+ `Unparseable ${PlatformEventsType.UserDataChange} data received`
144
+ );
145
+ return;
146
+ }
147
+
148
+ this.updateAvatarWithUserInfo(parsedEventData);
149
+ };
150
+
151
+ private setupEventSource = () => {
152
+ // subscribe to platform events
153
+ this.eventSource = new EventSource(TBID_API_PLATFORM_EVENTS_URL);
154
+ this.eventSource.addEventListener(
155
+ PlatformEventsType.UserDataChange,
156
+ // eslint-disable-next-line no-undef
157
+ this.handleUserDataChange as EventListener
158
+ );
159
+ this.eventSource.addEventListener(
160
+ PlatformEventsType.UserMerge,
161
+ this.handleSsoLogout
162
+ );
163
+ };
164
+
165
+ private teardownEventSource = () => {
166
+ if (!this.eventSource) {
167
+ return;
168
+ }
169
+
170
+ this.eventSource.removeEventListener(
171
+ PlatformEventsType.UserDataChange,
172
+ // eslint-disable-next-line no-undef
173
+ this.handleUserDataChange as EventListener
174
+ );
175
+ this.eventSource.removeEventListener(
176
+ PlatformEventsType.UserMerge,
177
+ this.handleSsoLogout
178
+ );
179
+ this.eventSource.close();
180
+ this.eventSource = undefined;
181
+ };
182
+
183
+ private platformEventsSubscribe = () => {
184
+ this.teardownEventSource();
185
+ this.setupEventSource();
186
+ };
187
+
188
+ // This handles logout from within this component, rather than from SSO via the SFIDWidget.
189
+ private handleLogout = (isSsoLogout: boolean) => {
190
+ this._didReceiveUserInfo = false;
191
+ this.isLoading = false;
192
+ this.updateAvatarWithUserInfo({}); // clear old info
193
+ this.teardownEventSource();
194
+
195
+ if (!isSsoLogout && window.SFIDWidget?.openid_response) {
196
+ // If the SFIDWidget is around and has an access token, and if logout was not *already*
197
+ // triggered by SSO logout, defer to the SFIDWidget. This will ensure that the SSO
198
+ // session is logged out as well.
199
+ window.SFIDWidget.logout();
200
+ } else {
201
+ // Always clear the session token; if not SSO logout, this will also revoke the token with
202
+ // TBID; if SSO logout, that step is already taken care of. No need to await this.
203
+ fetch(`${TBID_API_LOGOUT_URL}?isSsoLogout=${isSsoLogout}`, {
204
+ method: "DELETE",
205
+ credentials: "same-origin"
206
+ });
207
+
208
+ if (!isSsoLogout) {
209
+ // Dropping an iframe is required to fully get TBID to destroy the session; this is
210
+ // a TBID issue and they have requested that we do this for now.
211
+ const ifr = document.createElement("iframe");
212
+ ifr.setAttribute("src", TBID_IFRAME_URL);
213
+ document.body.appendChild(ifr);
214
+ }
215
+ }
216
+ };
217
+
218
+ // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists.
219
+ private handleSsoLogout = this.handleLogout.bind(this, true);
220
+
221
+ private handleComponentLogout = () => {
222
+ this.trackLogoutClick();
223
+ this.handleLogout(false);
224
+ };
225
+
226
+ // This will only be called for "seamless SSO" login by the embedded login widget (SFIDWidget) on the website, if it exists.
227
+ private handleSsoLogin = async (event: Event) => {
228
+ const { userInfo } = (event as CustomEvent).detail;
229
+ const token = userInfo?.access_token;
230
+
231
+ // `token` should always be defined if an SSO login occurs, but we check for extra safety
232
+ if (token) {
233
+ this.isLoading = true;
234
+
235
+ // If an SSO login occurred and we have an SSO access token, we want to start using
236
+ // it rather than some other non-linked access token, for the "seamless SSO"
237
+ // experience
238
+ try {
239
+ const tokenResponse = await fetch(TBID_API_TOKEN_URL, {
240
+ method: "POST",
241
+ headers: {
242
+ "Content-Type": "application/json"
243
+ },
244
+ body: JSON.stringify({
245
+ token
246
+ })
247
+ });
248
+ if (tokenResponse.ok) {
249
+ this.platformEventsSubscribe();
250
+ this.trackLoginSuccess();
251
+ }
252
+ } catch (ex) {
253
+ console.error(`Attempt to update token failed: ${ex}`);
254
+ }
255
+ }
256
+
257
+ if (userInfo) {
258
+ this.updateAvatarWithUserInfo(userInfo);
259
+ this._didReceiveUserInfo = true;
260
+ }
261
+
262
+ this.isLoading = false;
263
+ };
264
+
265
+ private handleComponentLogin = (event: MouseEvent) => {
266
+ if (window.SFIDWidget) {
267
+ // If the SFIDWidget is around, defer to it for login. This will ensure that the SSO
268
+ // session is used if possible.
269
+ event.preventDefault();
270
+ window.SFIDWidget.login();
271
+ }
272
+
273
+ this.trackLoginClick();
274
+ };
275
+
276
+ private trackLoginClick = () => {
277
+ track(document, "custEv_userLogin", {
278
+ clickText: "trailblazer.me",
279
+ clickUrl: this.loginUrl,
280
+ itemTitle: "TBID Login Link",
281
+ elementType: "link"
282
+ });
283
+ };
284
+
285
+ private trackLogoutClick = () => {
286
+ track(document, "custEv_userLogout", {
287
+ clickText: "logout",
288
+ clickUrl: TBID_API_LOGOUT_URL,
289
+ itemTitle: "TBID Logout Link",
290
+ elementType: "link"
291
+ });
292
+ };
293
+
294
+ private trackLoginSuccess = () => {
295
+ track(document, "custEv_userLogin", {
296
+ authenticationMethod: "tbid",
297
+ id: {
298
+ tb: Object.entries(this.userInfo)
299
+ .filter(([key]) => trackableUserInfo.has(key))
300
+ .reduce(
301
+ (infoToTrack, [key, value]) => ({
302
+ ...infoToTrack,
303
+ [key]: value
304
+ }),
305
+ {} as Record<string, string>
306
+ )
307
+ }
308
+ });
309
+ };
310
+
311
+ private updateAvatarWithUserInfo = (userInfo: any) => {
312
+ if (!userInfo) {
313
+ return;
314
+ }
315
+
316
+ const uncleanNextUserInfo: UserInfo = {};
317
+ uncleanNextUserInfo.avatarImgSrc = userInfo.photos?.thumbnail;
318
+ uncleanNextUserInfo.firstName = userInfo.given_name;
319
+ uncleanNextUserInfo.lastName = userInfo.family_name;
320
+ uncleanNextUserInfo.username = userInfo.preferred_username;
321
+ uncleanNextUserInfo.id = userInfo.user_id;
322
+ uncleanNextUserInfo.orgId = userInfo.organization_id;
323
+ uncleanNextUserInfo.fullName =
324
+ uncleanNextUserInfo.firstName && uncleanNextUserInfo.lastName
325
+ ? `${uncleanNextUserInfo.firstName} ${uncleanNextUserInfo.lastName}`
326
+ : uncleanNextUserInfo.firstName ||
327
+ uncleanNextUserInfo.lastName ||
328
+ "";
329
+
330
+ if (userInfo.custom_attributes) {
331
+ // ProfilePictureUrl updates earlier than photos.thumbnail, so we prefer it if it's available here.
332
+ uncleanNextUserInfo.avatarImgSrc =
333
+ userInfo.custom_attributes.ProfilePictureUrl ||
334
+ uncleanNextUserInfo.avatarImgSrc;
335
+ uncleanNextUserInfo.company =
336
+ userInfo.custom_attributes.CompanyName;
337
+ uncleanNextUserInfo.relationship =
338
+ userInfo.custom_attributes.RelationshipToSalesforce;
339
+ uncleanNextUserInfo.role = userInfo.custom_attributes.Role;
340
+ }
341
+
342
+ const cleanUserInfo: UserInfo = {};
343
+ Object.entries(uncleanNextUserInfo).forEach(([key, val]) => {
344
+ // uncleanUserInfo may have undefined values which we don't want in the final product
345
+ if (val && val !== "undefined") {
346
+ // Also a little extra safety to make sure we aren't injecting anything unsafe...
347
+ let cleanVal = escapeHtml(val);
348
+
349
+ if (key === "username" && typeof val === "string") {
350
+ // Design requests that we not show the @trailblazer.me part of the username
351
+ const regExpMatch = val.match("(.*)@trailblazer.me$");
352
+ if (regExpMatch && regExpMatch[1]) {
353
+ cleanVal = regExpMatch[1];
354
+ }
355
+ }
356
+
357
+ cleanUserInfo[key as keyof UserInfo] = cleanVal;
358
+ }
359
+ });
360
+
361
+ // Keep the old info for any values _not_ defined on `cleanUserInfo`
362
+ this.userInfo = defaults(cleanUserInfo, this.userInfo);
363
+ };
364
+
365
+ private requestUserInfo = async () => {
366
+ this.isLoading = true;
367
+
368
+ try {
369
+ const userInfoRes = await fetch(TBID_API_USERINFO_URL);
370
+
371
+ if (userInfoRes.ok) {
372
+ if (!this.eventSource) {
373
+ this.setupEventSource();
374
+ }
375
+ const userInfo = await userInfoRes.json();
376
+ this.updateAvatarWithUserInfo(userInfo);
377
+ this._didReceiveUserInfo = true;
378
+ }
379
+ } catch (ex) {
380
+ // Something not directly related to auth went wrong. Unsure what to do here.
381
+ console.error(`Could not request user info: ${ex}`);
382
+ }
383
+
384
+ this.isLoading = false;
385
+ };
386
+ }
@@ -19,6 +19,9 @@
19
19
  onstatechange={handleStateChange}
20
20
  ></dx-header-search>
21
21
  </div>
22
+ <div if:true={showTbidLogin} class="header-tbid-login">
23
+ <dx-avatar-button></dx-avatar-button>
24
+ </div>
22
25
  <div if:true={showSignup} class="header-login-signup">
23
26
  <dx-button
24
27
  aria-label="Sign Up For Salesforce Developer Edition"
@@ -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
  }
@@ -63,3 +63,18 @@
63
63
  .popover-small {
64
64
  width: 136px;
65
65
  }
66
+
67
+ .popover-arrow {
68
+ position: absolute;
69
+ background: white;
70
+ width: 8px;
71
+ height: 8px;
72
+ transform: rotate(45deg);
73
+ border: var(--popover-border);
74
+ border-bottom: transparent;
75
+ border-right: transparent;
76
+ box-shadow: var(--dx-g-box-shadow-lg);
77
+ transition: opacity 0.2s linear, transform 0.2s linear;
78
+ transition-delay: 0.02s;
79
+ z-index: var(--dx-c-popover-z-index, var(--dx-g-z-index-max));
80
+ }
@@ -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 = (slotted?.tagName === "SLOT"
250
+ ? slotted.firstChild
251
+ : slotted) 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
  };
@@ -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 dx-avatar-button {
117
119
  margin-left: var(--dx-g-spacing-smd);
118
120
  }
119
121