@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.
- package/CHANGELOG.md +10 -48
- package/package.json +1 -1
- package/plugin.config.js +4 -0
- package/src/features/b2b-agent/layouts/B2BAgentLayout/B2BAgentLayout.tsx +100 -0
- package/src/features/b2b-agent/layouts/B2BAgentLayout/b2b-agent-layout.scss +15 -0
- package/src/features/b2b-agent/layouts/index.ts +1 -0
- package/src/features/shared/utils/buyerPortalRoutes.ts +2 -0
- package/src/features/shared/utils/constants.ts +1 -1
- package/src/features/users/clients/UsersClient.ts +4 -0
- package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +9 -2
- package/src/features/users/components/UpdateUserDrawerSelector/UpdateUserDrawerSelector.tsx +20 -0
- package/src/features/users/components/UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername.tsx +342 -0
- package/src/features/users/components/UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss +31 -0
- package/src/features/users/components/UserDropdownMenu/UserDropdownMenu.tsx +2 -2
- package/src/features/users/components/UserDropdownMenu/user-dropdown-menu.scss +1 -0
- package/src/features/users/components/index.ts +5 -0
- package/src/features/users/layouts/UserDetailsLayout/UserDetailsLayout.tsx +3 -2
- package/src/features/users/layouts/UserDetailsLayout/user-details-layout.scss +1 -0
- package/src/features/users/services/get-user-by-id.service.ts +3 -5
- package/src/features/users/services/update-user.service.ts +6 -0
- package/src/pages/b2b-agent.tsx +94 -0
- 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.
|
|
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
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
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.
|
|
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
package/plugin.config.js
CHANGED
|
@@ -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 @@
|
|
|
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 }) =>
|
|
@@ -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}`,
|
package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx
CHANGED
|
@@ -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
|
-
|
|
461
|
-
label="Email
|
|
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
|
+
};
|
package/src/features/users/components/UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername.tsx
ADDED
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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 } =
|
|
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
|
+
});
|
package/src/themes/layouts.scss
CHANGED
|
@@ -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";
|