@vtex/faststore-plugin-buyer-portal 1.3.53 → 1.3.55

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.
Files changed (22) hide show
  1. package/CHANGELOG.md +10 -48
  2. package/package.json +1 -1
  3. package/plugin.config.js +4 -0
  4. package/src/features/b2b-agent/layouts/B2BAgentLayout/B2BAgentLayout.tsx +100 -0
  5. package/src/features/b2b-agent/layouts/B2BAgentLayout/b2b-agent-layout.scss +15 -0
  6. package/src/features/b2b-agent/layouts/index.ts +1 -0
  7. package/src/features/shared/utils/buyerPortalRoutes.ts +2 -0
  8. package/src/features/shared/utils/constants.ts +1 -1
  9. package/src/features/users/clients/UsersClient.ts +4 -0
  10. package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +9 -2
  11. package/src/features/users/components/UpdateUserDrawerSelector/UpdateUserDrawerSelector.tsx +20 -0
  12. package/src/features/users/components/UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername.tsx +342 -0
  13. package/src/features/users/components/UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss +31 -0
  14. package/src/features/users/components/UserDropdownMenu/UserDropdownMenu.tsx +2 -2
  15. package/src/features/users/components/UserDropdownMenu/user-dropdown-menu.scss +1 -0
  16. package/src/features/users/components/index.ts +5 -0
  17. package/src/features/users/layouts/UserDetailsLayout/UserDetailsLayout.tsx +3 -2
  18. package/src/features/users/layouts/UserDetailsLayout/user-details-layout.scss +1 -0
  19. package/src/features/users/services/get-user-by-id.service.ts +3 -5
  20. package/src/features/users/services/update-user.service.ts +6 -0
  21. package/src/pages/b2b-agent.tsx +94 -0
  22. package/src/themes/layouts.scss +4 -0
package/CHANGELOG.md CHANGED
@@ -7,56 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [1.3.53] - 2026-01-06
11
-
12
- ### Fixed
13
-
14
- - Solved typo on import of add menu dropdown
15
-
16
- ## [1.3.52] - 2026-01-06
17
-
18
- ### Fixed
19
-
20
- - Removed settings object from LoadingTabs
21
-
22
- ## [1.3.51] - 2026-01-05
23
-
24
- ### Added
25
-
26
- - Add Settings Drawers for Credit Cards, Payment Methods and Collections
27
- - Implement `CreditCardSettingsDrawer` component for credit cards scope configuration
28
- - Implement `PaymentMethodSettingsDrawer` component for payment methods scope configuration
29
- - Implement `CollectionsSettingsDrawer` component for product assortment scope configuration
30
- - Integrate settings drawers with respective layout pages
31
-
32
- ## [1.3.50] - 2025-12-19
33
-
34
- ### Changed
35
-
36
- - Introduces several improvements and refactorings to the budget notification drawer, focusing on user experience.
37
-
38
- ## [1.3.49] - 2025-12-19
10
+ ## [1.3.55] - 2026-01-08
39
11
 
40
12
  ### Added
41
13
 
42
- - Add component of Criteria Selection of Custom Fields on Buying Policies
43
-
44
- ## [1.3.48] - 2025-12-19
45
-
46
- - Adjustment from merge to Collections to Products Assortment
47
- - Change Products Assortment to client side
48
-
49
- ## [1.3.47] - 2025-12-19
50
-
51
- - Alternative Login Keys:
52
- - Add auth setup drawer
53
- - Update AddUserDrawer to support username
54
-
55
- ## [1.3.46] - 2025-12-19
56
-
57
- ### Fixed
58
-
59
- - Organizational unit deletion now redirects to parent org unit instead of staying on deleted entity's page
14
+ - Enable Bulk Ops agent in the storefront via /b2b-agent route
15
+ - Add src/pages/b2b-agent.tsx protected by withAuthLoader and withLoaderErrorBoundary
16
+ - Render external agent app inside an iframe (B2BAgentLayout)
17
+ - Implement secure postMessage handshake (B2B_AGENT_READY / AUTH_TOKEN_UPDATE) sending VTEX auth token and customer/user context
18
+ - Add origin/source validation, targetOrigin enforcement, and fallback resend logic for resilience
19
+ - Add full-height layout styles for the agent iframe
60
20
 
61
21
  ## [1.3.45] - 2025-12-17
62
22
 
@@ -451,7 +411,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
451
411
  - Add CHANGELOG file
452
412
  - Add README file
453
413
 
454
- [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...HEAD
414
+ [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.55...HEAD
415
+ [1.3.55]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.54...v1.3.55
416
+ [1.3.54]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...v1.3.54
455
417
  [1.3.53]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.52...v1.3.53
456
418
  [1.3.52]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.51...v1.3.52
457
419
  [1.3.51]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.50...v1.3.51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "1.3.53",
3
+ "version": "1.3.55",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/plugin.config.js CHANGED
@@ -88,6 +88,10 @@ module.exports = {
88
88
  path: "/pvt/organization-account/org-unit/[orgUnitId]",
89
89
  appLayout: false,
90
90
  },
91
+ "b2b-agent": {
92
+ path: "/pvt/organization-account/b2b-agent",
93
+ appLayout: false,
94
+ },
91
95
  },
92
96
 
93
97
  apis: {
@@ -0,0 +1,100 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+
3
+ import { isDevelopment } from "../../../shared/utils/environment";
4
+
5
+ const B2B_AGENT_URL = isDevelopment()
6
+ ? "http://localhost:3001/app/buyer-bulk-ops-agent"
7
+ : "https://buyer-bulk-ops-agent.vercel.app/app/buyer-bulk-ops-agent";
8
+
9
+ interface B2BAgentLayoutProps {
10
+ vtexIdclientAutCookie: string;
11
+ account: string;
12
+ locale?: string;
13
+ customerId?: string;
14
+ userId?: string;
15
+ }
16
+
17
+ export const B2BAgentLayout = ({
18
+ vtexIdclientAutCookie,
19
+ account,
20
+ locale,
21
+ customerId,
22
+ userId,
23
+ }: B2BAgentLayoutProps) => {
24
+ const iframeRef = useRef<HTMLIFrameElement>(null);
25
+
26
+ const agentOrigin = useMemo(() => new URL(B2B_AGENT_URL).origin, []);
27
+
28
+ useEffect(() => {
29
+ function postAuthToIframe() {
30
+ const targetWindow = iframeRef.current?.contentWindow;
31
+ if (!targetWindow) return;
32
+
33
+ // We send only the token (not the full Cookie header) to reduce exposure.
34
+ targetWindow.postMessage(
35
+ {
36
+ type: "AUTH_TOKEN_UPDATE",
37
+ vtexIdclientAutCookie: vtexIdclientAutCookie,
38
+ account,
39
+ locale,
40
+ customerId: customerId,
41
+ userId: userId,
42
+ },
43
+ agentOrigin
44
+ );
45
+ }
46
+
47
+ function onMessage(event: MessageEvent) {
48
+ // Only accept messages coming from the iframe's origin.
49
+ if (event.origin !== agentOrigin) return;
50
+
51
+ // Ensure the message is from the iframe window we embedded.
52
+ if (event.source !== iframeRef.current?.contentWindow) return;
53
+
54
+ // Handshake: child tells it is ready; parent responds by sending auth.
55
+ if (event.data?.type === "B2B_AGENT_READY") {
56
+ postAuthToIframe();
57
+ }
58
+ }
59
+
60
+ window.addEventListener("message", onMessage);
61
+
62
+ // Fallback: try once after iframe load in case READY was missed.
63
+ const iframe = iframeRef.current;
64
+ const onLoad = () => {
65
+ // Small delay to allow the iframe app to attach its message listener.
66
+ setTimeout(() => {
67
+ postAuthToIframe();
68
+ }, 150);
69
+ };
70
+
71
+ if (iframe) iframe.addEventListener("load", onLoad);
72
+
73
+ // If token changes while iframe is already up, proactively resend.
74
+ // This is safe because we still constrain by agentOrigin.
75
+ if (iframeRef.current?.contentWindow && vtexIdclientAutCookie) {
76
+ // Delay to avoid racing the initial mount.
77
+ setTimeout(() => {
78
+ postAuthToIframe();
79
+ }, 1);
80
+ }
81
+
82
+ return () => {
83
+ window.removeEventListener("message", onMessage);
84
+ if (iframe) iframe.removeEventListener("load", onLoad);
85
+ };
86
+ }, [vtexIdclientAutCookie, customerId, userId, agentOrigin]);
87
+
88
+ return (
89
+ <section data-fs-bp-b2b-agent>
90
+ <iframe
91
+ ref={iframeRef}
92
+ data-fs-bp-b2b-agent-iframe
93
+ src={B2B_AGENT_URL}
94
+ title="B2B Agent"
95
+ allow="clipboard-read; clipboard-write"
96
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
97
+ />
98
+ </section>
99
+ );
100
+ };
@@ -0,0 +1,15 @@
1
+ [data-fs-bp-b2b-agent] {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ height: 100vh;
6
+ overflow: hidden;
7
+
8
+ [data-fs-bp-b2b-agent-iframe] {
9
+ flex: 1;
10
+ width: 100%;
11
+ height: 100%;
12
+ border: none;
13
+ }
14
+ }
15
+
@@ -0,0 +1 @@
1
+ export { B2BAgentLayout } from "./B2BAgentLayout/B2BAgentLayout";
@@ -15,6 +15,8 @@ export const buyerPortalRoutes = {
15
15
  profileDetails: (params: { orgUnitId: string; contractId: string }) =>
16
16
  replaceParams(`${base}/profile/[orgUnitId]/[contractId]`, params),
17
17
 
18
+ b2bAgent: `${base}/b2b-agent`,
19
+
18
20
  addresses: (params: { orgUnitId: string; contractId: string }) =>
19
21
  replaceParams(`${base}/addresses/[orgUnitId]/[contractId]`, params),
20
22
  budgets: (params: { orgUnitId: string; contractId: string }) =>
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "1.3.53";
25
+ export const CURRENT_VERSION = "1.3.55";
@@ -194,7 +194,9 @@ class UsersClient extends Client {
194
194
  userId: string;
195
195
  phone?: string;
196
196
  name?: string;
197
+ email?: string;
197
198
  role?: string;
199
+ userName?: string;
198
200
  },
199
201
  cookie: string
200
202
  ) {
@@ -204,8 +206,10 @@ class UsersClient extends Client {
204
206
  unknown,
205
207
  {
206
208
  name?: string;
209
+ email?: string;
207
210
  phone?: string;
208
211
  role?: string;
212
+ userName?: string;
209
213
  }
210
214
  >(
211
215
  `units/${orgUnitId}/users/${userId}`,
@@ -299,6 +299,8 @@ export const CreateUserDrawerWithUsername = ({
299
299
  const isFormValid = () => {
300
300
  if (!userName?.trim()) return false;
301
301
 
302
+ if (!email?.trim()) return false;
303
+
302
304
  if (roles.length === 0) return false;
303
305
 
304
306
  if (isUsernameValid === false) return false;
@@ -457,13 +459,18 @@ export const CreateUserDrawerWithUsername = ({
457
459
  <InputText
458
460
  // TODO[2FA]: Uncomment when 2FA settings are ready
459
461
  // label={is2FAEnabled ? "Email" : "Email (optional)"}
460
- // hasError={is2FAEnabled && isTouched && !email?.trim() && !phone?.trim()}
461
- label="Email (optional)"
462
+ hasError={isTouched && !email?.trim()}
463
+ label="Email"
462
464
  value={email}
463
465
  wrapperProps={{ style: { marginTop: 16 } }}
464
466
  onChange={(event) => updateField("email", event.target.value)}
465
467
  />
466
468
 
469
+ <ErrorMessage
470
+ show={isTouched && !email?.trim()}
471
+ message="Email is required"
472
+ />
473
+
467
474
  <InputText
468
475
  // TODO[2FA]: Uncomment when 2FA settings are ready
469
476
  // label={is2FAEnabled ? "Phone number" : "Phone number (optional)"}
@@ -0,0 +1,20 @@
1
+ import { useBuyerPortal } from "../../../shared/hooks";
2
+ import {
3
+ UpdateUserDrawer,
4
+ type UpdateUserDrawerProps,
5
+ } from "../UpdateUserDrawer/UpdateUserDrawer";
6
+ import { UpdateUserDrawerWithUsername } from "../UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername";
7
+
8
+ export type UpdateUserDrawerSelectorProps = UpdateUserDrawerProps;
9
+
10
+ export const UpdateUserDrawerSelector = (
11
+ props: UpdateUserDrawerSelectorProps
12
+ ) => {
13
+ const { featureFlags } = useBuyerPortal();
14
+
15
+ if (featureFlags?.enableUsernameCreation) {
16
+ return <UpdateUserDrawerWithUsername {...props} />;
17
+ }
18
+
19
+ return <UpdateUserDrawer {...props} />;
20
+ };
@@ -0,0 +1,342 @@
1
+ import { useRef, useState } from "react";
2
+
3
+ import { Skeleton, useUI } from "@faststore/ui";
4
+
5
+ import { useUpdateRoles } from "../../../roles/hooks";
6
+ import { RoleData, UserRoles } from "../../../roles/types";
7
+ import {
8
+ type BasicDrawerProps,
9
+ BasicDrawer,
10
+ ErrorMessage,
11
+ InputText,
12
+ } from "../../../shared/components";
13
+ import { useAnalytics } from "../../../shared/hooks";
14
+ import { ANALYTICS_EVENTS } from "../../../shared/services/logger/analytics/constants";
15
+ import {
16
+ maskPhoneNumber,
17
+ normalizePhoneNumber,
18
+ } from "../../../shared/utils/phoneNumber";
19
+ import { useGetUserById, useUpdateUser } from "../../hooks";
20
+
21
+ export type UpdateUserDrawerWithUsernameProps = Omit<
22
+ BasicDrawerProps,
23
+ "children"
24
+ > & {
25
+ onUpdate?: () => void;
26
+ orgUnitId: string;
27
+ userId: string;
28
+ rolesOptions: RoleData[] | null;
29
+ };
30
+
31
+ export const UpdateUserDrawerWithUsername = ({
32
+ close,
33
+ onUpdate,
34
+ userId,
35
+ orgUnitId,
36
+ rolesOptions,
37
+ ...props
38
+ }: UpdateUserDrawerWithUsernameProps) => {
39
+ const { pushToast } = useUI();
40
+ const { trackEntityEdited, trackEntityEditError } = useAnalytics({
41
+ entityType: "user",
42
+ entityId: userId,
43
+ defaultTimerName: "user_edit",
44
+ shouldTrackDefaultTimer: true,
45
+ });
46
+ const initialRolesRef = useRef<string[]>([]);
47
+ const initialUsernameRef = useRef<string>("");
48
+
49
+ const { isUserLoading } = useGetUserById(
50
+ { orgUnitId, userId },
51
+ {
52
+ onSuccess: (data) => {
53
+ const userRoles = data?.roles ? data.roles : [];
54
+ initialRolesRef.current = userRoles;
55
+ initialUsernameRef.current = data?.userName ?? "";
56
+ setForm({
57
+ name: data?.name ?? "",
58
+ email: data?.email ?? "",
59
+ phone: data?.phone ?? "",
60
+ roles: userRoles,
61
+ username: data?.userName ?? "",
62
+ });
63
+ },
64
+ onError: () => {
65
+ pushToast({
66
+ message: "Failed to fetch user details",
67
+ status: "ERROR",
68
+ });
69
+ },
70
+ }
71
+ );
72
+
73
+ const [form, setForm] = useState<{
74
+ name: string;
75
+ email: string;
76
+ phone: string;
77
+ roles: string[];
78
+ username: string;
79
+ }>({
80
+ name: "",
81
+ email: "",
82
+ phone: "",
83
+ roles: [],
84
+ username: "",
85
+ });
86
+
87
+ const [isTouched, setIsTouched] = useState(false);
88
+
89
+ const { name, email, roles, phone, username } = form;
90
+
91
+ const updateField = (field: keyof typeof form, value: string | string[]) => {
92
+ setForm((prev) => ({
93
+ ...prev,
94
+ [field]: value,
95
+ }));
96
+ };
97
+
98
+ const handleUpdateUserSuccess = () => {
99
+ trackEntityEdited(ANALYTICS_EVENTS.USER_EDITED, {
100
+ user_id: userId,
101
+ user_name: name,
102
+ user_phone: phone,
103
+ old_roles: initialRolesRef.current,
104
+ new_roles: roles,
105
+ roles_count: roles.length,
106
+ org_unit_id: orgUnitId,
107
+ });
108
+
109
+ pushToast({
110
+ message: "User successfully updated",
111
+ status: "INFO",
112
+ });
113
+ onUpdate?.();
114
+ close();
115
+ };
116
+
117
+ const { updateUser, isUpdateUserLoading } = useUpdateUser({
118
+ onSuccess: handleUpdateUserSuccess,
119
+ onError: (error) => {
120
+ trackEntityEditError(
121
+ ANALYTICS_EVENTS.USER_EDIT_ERROR,
122
+ "user",
123
+ userId,
124
+ error,
125
+ {
126
+ org_unit_id: orgUnitId,
127
+ }
128
+ );
129
+
130
+ pushToast({
131
+ message: "Failed to update user",
132
+ status: "ERROR",
133
+ });
134
+ },
135
+ });
136
+
137
+ const { updateRoles, isUpdateRolesLoading } = useUpdateRoles({
138
+ onSuccess: handleUpdateUserSuccess,
139
+ onError: (error) => {
140
+ trackEntityEditError(
141
+ ANALYTICS_EVENTS.USER_EDIT_ERROR,
142
+ "user",
143
+ userId,
144
+ error,
145
+ {
146
+ org_unit_id: orgUnitId,
147
+ }
148
+ );
149
+
150
+ pushToast({
151
+ message: "Failed to update user",
152
+ status: "ERROR",
153
+ });
154
+ },
155
+ });
156
+
157
+ const isAllFieldsFilled = Object.keys(form).every((key) => {
158
+ if (key === "roles" || key === "phone") {
159
+ return true; // allow empty roles
160
+ }
161
+
162
+ return (form[key as keyof typeof form] as string)?.trim();
163
+ });
164
+
165
+ const handleConfirmClick = () => {
166
+ setIsTouched(true);
167
+
168
+ if (!isAllFieldsFilled) {
169
+ return;
170
+ }
171
+
172
+ const updatedRoles = rolesOptions?.map((option) => ({
173
+ roleName: option.roleName,
174
+ isActive: roles.includes(option.roleName),
175
+ }));
176
+
177
+ updateRoles({
178
+ data: {
179
+ userId,
180
+ roles: updatedRoles ? updatedRoles : ([] as UserRoles[]),
181
+ },
182
+ orgUnitId,
183
+ });
184
+
185
+ updateUser({
186
+ userId,
187
+ name,
188
+ email,
189
+ phone: phone ?? "",
190
+ roles,
191
+ orgUnitId,
192
+ userName: username,
193
+ });
194
+ };
195
+
196
+ const isLoading = isUpdateUserLoading || isUpdateRolesLoading;
197
+
198
+ const isConfirmButtonEnabled = isAllFieldsFilled && !isLoading;
199
+
200
+ return (
201
+ <BasicDrawer
202
+ data-fs-bp-update-user-drawer-with-username
203
+ close={close}
204
+ {...props}
205
+ >
206
+ <BasicDrawer.Heading title="Edit user details" onClose={close} />
207
+
208
+ <BasicDrawer.Body>
209
+ {isUserLoading ? (
210
+ <Skeleton size={{ width: "100%", height: "3.5rem" }} />
211
+ ) : (
212
+ <InputText
213
+ label="Username"
214
+ value={username}
215
+ hasError={isTouched && !username?.trim()}
216
+ onChange={(event) => updateField("username", event.target.value)}
217
+ readOnly={!!initialUsernameRef.current}
218
+ disabled={!!initialUsernameRef.current}
219
+ />
220
+ )}
221
+
222
+ <ErrorMessage
223
+ show={isTouched && !username?.trim()}
224
+ message="Username is required"
225
+ />
226
+
227
+ {isUserLoading ? (
228
+ <Skeleton size={{ width: "100%", height: "3.5rem" }} />
229
+ ) : (
230
+ <InputText
231
+ label="Full Name"
232
+ wrapperProps={{ style: { marginTop: 16, marginBottom: 16 } }}
233
+ value={name}
234
+ hasError={isTouched && !name?.trim()}
235
+ onChange={(event) => updateField("name", event.target.value)}
236
+ />
237
+ )}
238
+
239
+ <ErrorMessage
240
+ show={isTouched && !name?.trim()}
241
+ message="Full name is required"
242
+ />
243
+
244
+ {isUserLoading ? (
245
+ <Skeleton
246
+ size={{ width: "100%", height: "3.5rem" }}
247
+ style={{
248
+ marginTop: "1rem",
249
+ marginBottom: "1rem",
250
+ width: "100%",
251
+ height: "3.5rem",
252
+ }}
253
+ />
254
+ ) : (
255
+ <InputText
256
+ label="Email"
257
+ value={email}
258
+ wrapperProps={{ style: { marginBottom: 16 } }}
259
+ hasError={isTouched && !email?.trim()}
260
+ onChange={(event) => updateField("email", event.target.value)}
261
+ />
262
+ )}
263
+
264
+ <ErrorMessage
265
+ show={isTouched && !email?.trim()}
266
+ message="Email is required"
267
+ />
268
+
269
+ {isUserLoading ? (
270
+ <Skeleton
271
+ size={{ width: "100%", height: "3.5rem" }}
272
+ style={{
273
+ marginTop: "1rem",
274
+ marginBottom: "1rem",
275
+ width: "100%",
276
+ height: "3.5rem",
277
+ }}
278
+ />
279
+ ) : (
280
+ <InputText
281
+ label="Phone number (optional)"
282
+ value={maskPhoneNumber(phone, "USA")}
283
+ onChange={(event) =>
284
+ // TODO: Update this when implementing i18n
285
+ updateField("phone", normalizePhoneNumber(event.target.value))
286
+ }
287
+ />
288
+ )}
289
+
290
+ <div data-fs-bp-update-user-roles>
291
+ <span data-fs-bp-update-user-roles-label>Roles</span>
292
+ {rolesOptions &&
293
+ rolesOptions.map((role) => {
294
+ if (isUserLoading) {
295
+ return (
296
+ <Skeleton
297
+ key={role.roleId}
298
+ size={{ width: "100%", height: "1.25rem" }}
299
+ />
300
+ );
301
+ }
302
+
303
+ const id = `role-${role.roleName
304
+ .toLowerCase()
305
+ .replace(" ", "-")}`;
306
+ return (
307
+ <span data-fs-bp-update-user-role-wrapper key={role.roleName}>
308
+ <input
309
+ type="checkbox"
310
+ key={role.roleId}
311
+ value={role.roleId}
312
+ id={id}
313
+ checked={roles?.includes(role.roleName)}
314
+ onChange={(event) => {
315
+ const newRoles = event.target.checked
316
+ ? [...(roles ?? []), role.roleName]
317
+ : roles?.filter((r) => r !== role.roleName) ?? [];
318
+ updateField("roles", newRoles);
319
+ }}
320
+ />
321
+ <label htmlFor={id}>{role.roleName}</label>
322
+ </span>
323
+ );
324
+ })}
325
+ </div>
326
+ </BasicDrawer.Body>
327
+ <BasicDrawer.Footer>
328
+ <BasicDrawer.Button variant="ghost" onClick={close}>
329
+ Cancel
330
+ </BasicDrawer.Button>
331
+ <BasicDrawer.Button
332
+ variant="confirm"
333
+ disabled={!isConfirmButtonEnabled}
334
+ onClick={handleConfirmClick}
335
+ isLoading={isLoading}
336
+ >
337
+ Save
338
+ </BasicDrawer.Button>
339
+ </BasicDrawer.Footer>
340
+ </BasicDrawer>
341
+ );
342
+ };
@@ -0,0 +1,31 @@
1
+ @import "../../../shared/components/BasicDrawer/basic-drawer.scss";
2
+
3
+ [data-fs-bp-update-user-drawer-with-username] {
4
+ @import "../UpdateUserDrawer/update-user-drawer.scss";
5
+
6
+ [data-fs-bp-update-user-roles] {
7
+ display: flex;
8
+ flex-direction: column;
9
+ gap: var(--fs-spacing-2);
10
+ margin-bottom: var(--fs-spacing-6);
11
+ margin-top: var(--fs-spacing-5);
12
+
13
+ [data-fs-bp-update-user-roles-label] {
14
+ font-weight: var(--fs-text-weight-regular);
15
+ font-size: var(--fs-text-size-1);
16
+ line-height: var(--fs-text-size-3);
17
+ }
18
+
19
+ [data-fs-bp-update-user-role-wrapper] {
20
+ input {
21
+ margin-right: var(--fs-spacing-1);
22
+ }
23
+
24
+ label {
25
+ font-weight: var(--fs-text-weight-regular);
26
+ font-size: var(--fs-text-size-1);
27
+ line-height: var(--fs-text-size-3);
28
+ }
29
+ }
30
+ }
31
+ }
@@ -7,7 +7,7 @@ import { useBuyerPortal, useDrawerProps } from "../../../shared/hooks";
7
7
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
8
8
  import { DeleteUserDrawer } from "../DeleteUserDrawer/DeleteUserDrawer";
9
9
  import { ReassignOrgUnitDrawer } from "../ReassignOrgUnitDrawer/ReassignOrgUnitDrawer";
10
- import { UpdateUserDrawer } from "../UpdateUserDrawer/UpdateUserDrawer";
10
+ import { UpdateUserDrawerSelector } from "../UpdateUserDrawerSelector/UpdateUserDrawerSelector";
11
11
 
12
12
  import type { RoleData } from "../../../roles/types";
13
13
 
@@ -99,7 +99,7 @@ export const UserDropdownMenu = ({
99
99
  />
100
100
  )}
101
101
  {isUpdateUserDrawerOpen && (
102
- <UpdateUserDrawer
102
+ <UpdateUserDrawerSelector
103
103
  rolesOptions={rolesOptions}
104
104
  userId={user.id}
105
105
  orgUnitId={currentOrgUnit?.id ?? ""}
@@ -4,3 +4,4 @@
4
4
  @import "../CreateUserDrawer/create-user-drawer.scss";
5
5
  @import "../CreateUserDrawerWithUsername/create-user-drawer-with-username.scss";
6
6
  @import "../UpdateUserDrawer/update-user-drawer.scss";
7
+ @import "../UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss";
@@ -16,3 +16,8 @@ export {
16
16
  UpdateUserDrawer,
17
17
  type UpdateUserDrawerProps,
18
18
  } from "./UpdateUserDrawer/UpdateUserDrawer";
19
+ export { UpdateUserDrawerWithUsername } from "./UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername";
20
+ export {
21
+ UpdateUserDrawerSelector,
22
+ type UpdateUserDrawerSelectorProps,
23
+ } from "./UpdateUserDrawerSelector/UpdateUserDrawerSelector";
@@ -10,7 +10,8 @@ import { GlobalLayout } from "../../../shared/layouts";
10
10
  import { OrgUnitTabsLayout } from "../../../shared/layouts/OrgUnitTabsLayout/OrgUnitTabLayout";
11
11
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
12
12
  import { maskPhoneNumber } from "../../../shared/utils/phoneNumber";
13
- import { ReassignOrgUnitDrawer, UpdateUserDrawer } from "../../components";
13
+ import { ReassignOrgUnitDrawer } from "../../components";
14
+ import { UpdateUserDrawerSelector } from "../../components/UpdateUserDrawerSelector/UpdateUserDrawerSelector";
14
15
  import { UserDropdownMenu } from "../../components/UserDropdownMenu/UserDropdownMenu";
15
16
 
16
17
  import type { RoleData } from "../../../roles/types";
@@ -148,7 +149,7 @@ export const UserDetailsLayout = ({
148
149
  />
149
150
  )}
150
151
  {isUpdateUserDrawerOpen && (
151
- <UpdateUserDrawer
152
+ <UpdateUserDrawerSelector
152
153
  rolesOptions={rolesOptions ?? []}
153
154
  userId={user?.id ?? ""}
154
155
  orgUnitId={currentOrgUnit?.id ?? ""}
@@ -1,6 +1,7 @@
1
1
  @import "@faststore/ui/src/components/molecules/Toggle/styles.scss";
2
2
  @import "../../components/ReassignOrgUnitDrawer/reassign-org-unit-drawer.scss";
3
3
  @import "../../components/UpdateUserDrawer/update-user-drawer.scss";
4
+ @import "../../components/UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss";
4
5
  @import "../../components/DeleteUserDrawer/delete-user-drawer.scss";
5
6
  @import "../../components/UserDropdownMenu/user-dropdown-menu.scss";
6
7
 
@@ -13,14 +13,12 @@ export const getUserByIdService = async ({
13
13
  cookie: string;
14
14
  }): Promise<UserData | null> => {
15
15
  try {
16
- const { email, name, phone, orgUnit, role } = await usersClient.getUserById(
17
- orgUnitId,
18
- userId,
19
- cookie
20
- );
16
+ const { email, name, phone, orgUnit, role, userName } =
17
+ await usersClient.getUserById(orgUnitId, userId, cookie);
21
18
 
22
19
  return {
23
20
  name,
21
+ userName: userName ?? "",
24
22
  roles: role ? role : [],
25
23
  id: userId,
26
24
  email: email ?? "",
@@ -4,8 +4,10 @@ export type UpdateUserServiceProps = {
4
4
  orgUnitId: string;
5
5
  userId: string;
6
6
  name?: string;
7
+ email?: string;
7
8
  phone?: string;
8
9
  roles?: string[];
10
+ userName?: string;
9
11
  cookie: string;
10
12
  };
11
13
 
@@ -14,16 +16,20 @@ export const updateUserService = async ({
14
16
  userId,
15
17
  cookie,
16
18
  name,
19
+ email,
17
20
  phone = "",
18
21
  roles,
22
+ userName,
19
23
  }: UpdateUserServiceProps) => {
20
24
  return usersClient.updateUser(
21
25
  {
22
26
  orgUnitId,
23
27
  userId,
24
28
  name,
29
+ email,
25
30
  phone,
26
31
  role: roles?.join(",") || undefined,
32
+ userName,
27
33
  },
28
34
  cookie
29
35
  );
@@ -0,0 +1,94 @@
1
+ import storeConfig from "discovery.config";
2
+
3
+ import { B2BAgentLayout } from "../features/b2b-agent/layouts";
4
+ import { withErrorBoundary } from "../features/shared/components";
5
+ import {
6
+ withAuthLoader,
7
+ withLoaderErrorBoundary,
8
+ } from "../features/shared/utils";
9
+
10
+ import type { AuthRouteProps, LoaderData } from "../features/shared/types";
11
+
12
+ /**
13
+ * Extracts the VTEX auth token value from a Cookie header string.
14
+ * Returns only the cookie VALUE (JWT), never "CookieName=value".
15
+ */
16
+ const getAuthCookieInfo = (
17
+ cookieHeader: string
18
+ ): { token: string; accountFromCookie?: string } => {
19
+ if (!cookieHeader) return { token: "" };
20
+
21
+ // VtexIdclientAutCookie_<account>=<value>
22
+ const m = cookieHeader.match(/VtexIdclientAutCookie_([a-zA-Z0-9-]+)=([^;]+)/);
23
+ if (m?.[2]) return { accountFromCookie: m[1], token: m[2] };
24
+
25
+ // fallback: VtexIdclientAutCookie=<value>
26
+ const g = cookieHeader.match(/VtexIdclientAutCookie=([^;]+)/);
27
+ if (g?.[1]) return { token: g[1] };
28
+
29
+ return { token: "" };
30
+ };
31
+
32
+ type B2BAgentPageQuery = Record<string, never>;
33
+
34
+ const loaderFunction = async (
35
+ data: LoaderData<B2BAgentPageQuery>
36
+ ): Promise<AuthRouteProps<undefined>> => {
37
+ return withAuthLoader(data, async () => {
38
+ // No business data is returned.
39
+ // The authenticated client context is enough for this page.
40
+ return undefined;
41
+ });
42
+ };
43
+
44
+ export const loader = withLoaderErrorBoundary(loaderFunction, {
45
+ componentName: "B2BAgentPage",
46
+ redirectToError: true,
47
+ });
48
+
49
+ const B2BAgentPage = (props: AuthRouteProps<undefined>) => {
50
+ if (!props.authorized) return null;
51
+
52
+ const cookieHeader = props.clientContext?.cookie ?? "";
53
+ const tokenFromContext = props.clientContext?.vtexIdclientAutCookie ?? "";
54
+
55
+ const { token: tokenFromCookie, accountFromCookie } =
56
+ getAuthCookieInfo(cookieHeader);
57
+
58
+ // Prefer a token-like value if available; otherwise extract from cookie header.
59
+ const vtexIdclientAutCookie =
60
+ tokenFromContext && !tokenFromContext.includes("=")
61
+ ? tokenFromContext
62
+ : tokenFromCookie;
63
+
64
+ if (!vtexIdclientAutCookie) {
65
+ throw new Error("Missing VTEX auth token for B2B Agent iframe");
66
+ }
67
+
68
+ // account/locale via discovery.config (recomendado)
69
+ const account = storeConfig?.api?.storeId ?? accountFromCookie ?? "";
70
+ const locale = storeConfig?.session?.locale ?? "en-US";
71
+
72
+ const customerId = props.clientContext?.customerId;
73
+ const userId = props.clientContext?.userId;
74
+
75
+ return (
76
+ <B2BAgentLayout
77
+ vtexIdclientAutCookie={vtexIdclientAutCookie}
78
+ account={account}
79
+ locale={locale}
80
+ customerId={customerId}
81
+ userId={userId}
82
+ />
83
+ );
84
+ };
85
+
86
+ export default withErrorBoundary(B2BAgentPage, {
87
+ onError: (error) => {
88
+ console.error("onError", error);
89
+ },
90
+ tags: {
91
+ component: "B2BAgentPage",
92
+ errorType: "b2b_agent_error",
93
+ },
94
+ });
@@ -42,6 +42,10 @@
42
42
 
43
43
  // Payment Methods
44
44
  @import "../features/payment-methods/layouts/PaymentMethodsLayout/payment-methods-layout.scss";
45
+
45
46
  // Roles
46
47
  @import "../features/roles/layout/RolesLayout/roles-layout.scss";
47
48
  @import "../features/roles/layout/RoleDetailsLayout/role-details-layout.scss";
49
+
50
+ // B2B Agent
51
+ @import "../features/b2b-agent/layouts/B2BAgentLayout/b2b-agent-layout.scss";