@vtex/faststore-plugin-buyer-portal 2.0.28 → 2.0.30

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 (31) hide show
  1. package/package.json +3 -1
  2. package/src/features/addresses/components/AddressDropdownMenu/AddressDropdownMenu.tsx +57 -47
  3. package/src/features/addresses/components/AddressLocationsList/AddressLocationsList.tsx +45 -30
  4. package/src/features/addresses/components/AddressesCard/AddressesCard.tsx +1 -1
  5. package/src/features/addresses/components/CreateAddressSettingsDrawer/CreateAddressSettingsDrawer.tsx +28 -17
  6. package/src/features/addresses/layouts/AddressDetailsLayout/AddressDetailsLayout.tsx +62 -43
  7. package/src/features/addresses/layouts/AddressesLayout/AddressesLayout.tsx +10 -2
  8. package/src/features/buying-policies/components/AccountingFieldCriteriaSelector/AccountingFieldCriteriaSelector.tsx +5 -2
  9. package/src/features/buying-policies/components/AccountingFieldCriteriaSelector/AccountingValueCriteriaSelector.tsx +8 -3
  10. package/src/features/buying-policies/components/BudgetCriteriaSelector/BudgetCriteriaSelector.tsx +5 -2
  11. package/src/features/contracts/components/UnableToRemoveContractsDrawer/UnableToRemoveContractsDrawer.tsx +1 -0
  12. package/src/features/org-units/components/AddAllToOrgUnitDropdown/AddAllToOrgUnitDropdown.tsx +12 -6
  13. package/src/features/recipients/components/RecipientsList/RecipientsList.tsx +7 -1
  14. package/src/features/shared/clients/Auth.ts +14 -0
  15. package/src/features/shared/components/BuyerPortalProvider/BuyerPortalProvider.tsx +6 -0
  16. package/src/features/shared/components/LocalizationEslintIgnore/LocalizationEslintIgnore.tsx +12 -0
  17. package/src/features/shared/components/index.ts +5 -0
  18. package/src/features/shared/hooks/index.ts +1 -0
  19. package/src/features/shared/hooks/useIsAddressEditingRestricted.ts +12 -0
  20. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +1 -1
  21. package/src/features/shared/services/__tests__/has-storefront-resource.service.test.ts +69 -0
  22. package/src/features/shared/services/feature-flags/__tests__/get-feature-flags.service.test.ts +6 -1
  23. package/src/features/shared/services/feature-flags/get-feature-flags.service.ts +1 -0
  24. package/src/features/shared/services/has-storefront-resource.service.ts +27 -0
  25. package/src/features/shared/services/index.ts +4 -0
  26. package/src/features/shared/utils/__tests__/isAddressEditingRestricted.test.ts +44 -0
  27. package/src/features/shared/utils/__tests__/withAuthLoader.test.ts +49 -1
  28. package/src/features/shared/utils/constants.ts +10 -2
  29. package/src/features/shared/utils/index.ts +4 -0
  30. package/src/features/shared/utils/isAddressEditingRestricted.ts +10 -0
  31. package/src/features/shared/utils/withAuthLoader.ts +28 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.28",
3
+ "version": "2.0.30",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -16,6 +16,8 @@
16
16
  "test": "vitest run",
17
17
  "test:watch": "vitest",
18
18
  "test:coverage": "vitest run --coverage",
19
+ "i18n:check": "node scripts/check-localization-keys.mjs check --base=main",
20
+ "i18n:refresh": "node scripts/check-localization-keys.mjs refresh",
19
21
  "prepare": "husky",
20
22
  "release": "yarn release:stable",
21
23
  "release:stable": "yarn check --integrity && yarn release-it -c .release-it.json --ci --no-npm",
@@ -12,7 +12,11 @@ import {
12
12
  } from "..";
13
13
  import { AddRecipientsDrawer } from "../../../recipients/components";
14
14
  import { BasicDropdownMenu, Icon } from "../../../shared/components";
15
- import { useBuyerPortal, useDrawerProps } from "../../../shared/hooks";
15
+ import {
16
+ useBuyerPortal,
17
+ useDrawerProps,
18
+ useIsAddressEditingRestricted,
19
+ } from "../../../shared/hooks";
16
20
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
17
21
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
18
22
  import { useAddRecipientsToAddress } from "../../hooks/useAddRecipientsToAddress";
@@ -42,6 +46,7 @@ export const AddressDropdownMenu = ({
42
46
  const router = useRouter();
43
47
  const { pushToast } = useUI();
44
48
  const { t } = useLocalization();
49
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
45
50
  const { featureFlags } = useBuyerPortal();
46
51
  const isRecipientsContractLevelEnabled = Boolean(
47
52
  featureFlags?.enableRecipientsContractLevel
@@ -187,27 +192,31 @@ ${t("shared.messages.changesTimeout")}`,
187
192
  <BasicDropdownMenu data-fs-bp-dropdown-menu>
188
193
  <DropdownItem onClick={handleOpenNewTab}>
189
194
  <Icon name="OpenNewTab" {...sizeProps} />
190
- Open
191
- </DropdownItem>
192
- <DropdownItem onClick={openEditDrawerProps}>
193
- <Icon name="Edit" {...sizeProps} />
194
- {t("addresses.dropdown.editDetails")}
195
- </DropdownItem>
196
- <DropdownItem
197
- data-fs-bp-testid="dropdown-item-add-location"
198
- onClick={openLocationDrawer}
199
- >
200
- <UIIcon name="PlusCircle" {...sizeProps} />
201
- {t("addresses.actions.addLocations", [resolvedLocationsLabel])}
195
+ {t("shared.buttons.open")}
202
196
  </DropdownItem>
203
- {!isRecipientsContractLevelEnabled && (
204
- <DropdownItem
205
- data-fs-bp-testid="dropdown-item-add-recipient"
206
- onClick={openRecipientsDrawer}
207
- >
208
- <UIIcon name="PlusCircle" {...sizeProps} />
209
- {t("addresses.actions.addRecipients")}
210
- </DropdownItem>
197
+ {!isAddressEditingRestricted && (
198
+ <>
199
+ <DropdownItem onClick={openEditDrawerProps}>
200
+ <Icon name="Edit" {...sizeProps} />
201
+ {t("addresses.dropdown.editDetails")}
202
+ </DropdownItem>
203
+ <DropdownItem
204
+ data-fs-bp-testid="dropdown-item-add-location"
205
+ onClick={openLocationDrawer}
206
+ >
207
+ <UIIcon name="PlusCircle" {...sizeProps} />
208
+ {t("addresses.actions.addLocations", [resolvedLocationsLabel])}
209
+ </DropdownItem>
210
+ {!isRecipientsContractLevelEnabled && (
211
+ <DropdownItem
212
+ data-fs-bp-testid="dropdown-item-add-recipient"
213
+ onClick={openRecipientsDrawer}
214
+ >
215
+ <UIIcon name="PlusCircle" {...sizeProps} />
216
+ {t("addresses.actions.addRecipients")}
217
+ </DropdownItem>
218
+ )}
219
+ </>
211
220
  )}
212
221
  {currentAddress.isDefault ? (
213
222
  <DropdownItem onClick={handleUnsetAsDefault}>
@@ -229,16 +238,15 @@ ${t("shared.messages.changesTimeout")}`,
229
238
  <UIIcon name="MinusCircle" {...sizeProps} />
230
239
  {t("addresses.actions.removeFromUnit")}
231
240
  </DropdownItem>
232
- {/* Remove validation while we reevaluate the rule */}
233
- {/* {isRootLevelOrgUnit && ( */}
234
- <DropdownItem
235
- onClick={openDeleteAddressDrawer}
236
- data-fs-bp-dropdown-menu-item-mode="danger"
237
- >
238
- <Icon name="Delete" {...sizeProps} data-fs-bp-delete-address />
239
- <span data-fs-bp-delete-address>{t("shared.buttons.delete")}</span>
240
- </DropdownItem>
241
- {/* )} */}
241
+ {!isAddressEditingRestricted && (
242
+ <DropdownItem
243
+ onClick={openDeleteAddressDrawer}
244
+ data-fs-bp-dropdown-menu-item-mode="danger"
245
+ >
246
+ <Icon name="Delete" {...sizeProps} data-fs-bp-delete-address />
247
+ <span data-fs-bp-delete-address>{t("shared.buttons.delete")}</span>
248
+ </DropdownItem>
249
+ )}
242
250
  </BasicDropdownMenu>
243
251
  {isEditDrawerOpen && (
244
252
  <EditAddressDrawer
@@ -257,22 +265,24 @@ ${t("shared.messages.changesTimeout")}`,
257
265
  locationsLabel={resolvedLocationsLabel}
258
266
  />
259
267
  )}
260
- {!isRecipientsContractLevelEnabled && isOpenRecipientsDrawer && (
261
- <AddRecipientsDrawer
262
- readonly
263
- {...recipientDrawerProps}
264
- isOpen={isOpenRecipientsDrawer}
265
- isSubmitting={isAddRecipientToAddressLoading}
266
- onSubmit={handleAddRecipientsSubmit}
267
- onSubmitted={() => router.reload()}
268
- addressId={currentAddress.id}
269
- viewLink={`${buyerPortalRoutes.addressDetails({
270
- orgUnitId,
271
- contractId: customerId,
272
- addressId: currentAddress.id,
273
- })}?tab=recipient-tab`}
274
- />
275
- )}
268
+ {!isAddressEditingRestricted &&
269
+ !isRecipientsContractLevelEnabled &&
270
+ isOpenRecipientsDrawer && (
271
+ <AddRecipientsDrawer
272
+ readonly
273
+ {...recipientDrawerProps}
274
+ isOpen={isOpenRecipientsDrawer}
275
+ isSubmitting={isAddRecipientToAddressLoading}
276
+ onSubmit={handleAddRecipientsSubmit}
277
+ onSubmitted={() => router.reload()}
278
+ addressId={currentAddress.id}
279
+ viewLink={`${buyerPortalRoutes.addressDetails({
280
+ orgUnitId,
281
+ contractId: customerId,
282
+ addressId: currentAddress.id,
283
+ })}?tab=recipient-tab`}
284
+ />
285
+ )}
276
286
  {isOpenRemoveAddressDrawer && (
277
287
  <RemoveAddressDrawer
278
288
  readonly
@@ -15,6 +15,7 @@ import { getTableColumns } from "../../../shared/components/Table/utils/tableCol
15
15
  import {
16
16
  useBuyerPortal,
17
17
  useDrawerProps,
18
+ useIsAddressEditingRestricted,
18
19
  usePageItems,
19
20
  } from "../../../shared/hooks";
20
21
  import { PAGE_PARAMS, SEARCH_PARAMS } from "../../../shared/hooks/usePageItems";
@@ -78,6 +79,7 @@ export const AddressLocationsList = forwardRef<
78
79
  const [totalLocations, setTotalLocations] = useState(total);
79
80
  const { currentOrgUnit } = useBuyerPortal();
80
81
  const router = useRouter();
82
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
81
83
  const { listLocationData, listLocationRefresh } = useAddressLocationList({
82
84
  addressId: (router.query.addressId as string) || "",
83
85
  page,
@@ -118,8 +120,40 @@ export const AddressLocationsList = forwardRef<
118
120
  refreshLocations: handleRefreshLocations,
119
121
  }));
120
122
 
123
+ const locationActions = isAddressEditingRestricted
124
+ ? []
125
+ : [
126
+ {
127
+ testId: "location-action-delete",
128
+ iconName: "Trash",
129
+ ariaLabel: "remove location action",
130
+ onSelect: openDeleteLocation,
131
+ },
132
+ {
133
+ testId: "location-action-edit",
134
+ iconName: "Edit",
135
+ ariaLabel: "edit location action",
136
+ onSelect: openEditLocation,
137
+ },
138
+ ];
139
+
140
+ const renderActions = (item: LocationData) =>
141
+ locationActions.map(({ testId, iconName, ariaLabel, onSelect }) => (
142
+ <IconButton
143
+ key={testId}
144
+ data-fs-bp-address-tabs-actions-icon
145
+ data-fs-bp-testid={testId}
146
+ icon={<Icon name={iconName} width={20} height={20} />}
147
+ aria-label={ariaLabel}
148
+ onClick={() => {
149
+ selectLocation(item);
150
+ onSelect();
151
+ }}
152
+ />
153
+ ));
154
+
121
155
  const columns = getTableColumns({
122
- actionsLength: 2,
156
+ actionsLength: locationActions.length,
123
157
  nameColumnLabel: t("shared.labels.name"),
124
158
  });
125
159
 
@@ -141,13 +175,15 @@ export const AddressLocationsList = forwardRef<
141
175
  iconSize={56}
142
176
  title={t("addresses.empty.noResults")}
143
177
  >
144
- <Button
145
- variant="primary"
146
- onClick={() => onOpenAddDrawer?.()}
147
- data-fs-bp-address-tabs-empty-state
148
- >
149
- {t("addresses.buttons.addLocations")}
150
- </Button>
178
+ {!isAddressEditingRestricted && (
179
+ <Button
180
+ variant="primary"
181
+ onClick={() => onOpenAddDrawer?.()}
182
+ data-fs-bp-address-tabs-empty-state
183
+ >
184
+ {t("addresses.buttons.addLocations")}
185
+ </Button>
186
+ )}
151
187
  </EmptyState>
152
188
  ) : (
153
189
  <div data-fs-bp-address-tabs-table>
@@ -162,28 +198,7 @@ export const AddressLocationsList = forwardRef<
162
198
  key={item.id}
163
199
  title={item.value}
164
200
  searchTerm={search}
165
- actionIcons={[
166
- <IconButton
167
- data-fs-bp-address-tabs-actions-icon
168
- data-fs-bp-testid="location-action-delete"
169
- icon={<Icon name="Trash" width={20} height={20} />}
170
- aria-label={"remove location action"}
171
- onClick={() => {
172
- selectLocation(item);
173
- openDeleteLocation();
174
- }}
175
- />,
176
- <IconButton
177
- data-fs-bp-address-tabs-actions-icon
178
- data-fs-bp-testid="location-action-edit"
179
- icon={<Icon name="Edit" width={20} height={20} />}
180
- aria-label={"edit location action"}
181
- onClick={() => {
182
- selectLocation(item);
183
- openEditLocation();
184
- }}
185
- />,
186
- ]}
201
+ actionIcons={renderActions(item)}
187
202
  />
188
203
  ))
189
204
  )}
@@ -67,7 +67,7 @@ export default function AddressesCard({
67
67
  <DropdownMenu>
68
68
  <DropdownItem data-fs-card-dropdown-item>
69
69
  <Icon name="ArrowSquareOut" data-fs-dropdown-icon />
70
- Open
70
+ {t("shared.buttons.open")}
71
71
  </DropdownItem>
72
72
  <DropdownItem>
73
73
  <Icon
@@ -14,7 +14,11 @@ import {
14
14
  } from "../../../shared/components";
15
15
  import { OptionSelected } from "../../../shared/components/OptionSelected/OptionSelected";
16
16
  import { SearchHighlight } from "../../../shared/components/SearchHighlight/SearchHighlight";
17
- import { useSetScopeConfig, SCOPE_KEYS } from "../../../shared/hooks";
17
+ import {
18
+ SCOPE_KEYS,
19
+ useIsAddressEditingRestricted,
20
+ useSetScopeConfig,
21
+ } from "../../../shared/hooks";
18
22
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
19
23
  import { useEditAddressLocationSettings } from "../../hooks";
20
24
  import { useDebouncedSearchAddressByUnitId } from "../../hooks/useDebouncedSearchAddressByUnitId";
@@ -41,6 +45,7 @@ export const CreateAddressSettingsDrawer = ({
41
45
  }: CreateAddressSettingsDrawerProps) => {
42
46
  const { pushToast } = useUI();
43
47
  const { t } = useLocalization();
48
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
44
49
  const addressListTypeOptions = createListTypeOptions("addresses", t);
45
50
  const router = useRouter();
46
51
  const locationLabelText = initialLocationsLabel;
@@ -172,7 +177,9 @@ export const CreateAddressSettingsDrawer = ({
172
177
  });
173
178
  const formData = getFormData();
174
179
  const locationLabelChanged =
175
- !!locationsLabel && locationsLabel !== initialLocationsLabel;
180
+ !isAddressEditingRestricted &&
181
+ !!locationsLabel &&
182
+ locationsLabel !== initialLocationsLabel;
176
183
 
177
184
  if (formData.length === 0 && !locationLabelChanged) {
178
185
  // Close drawer and trigger update when only list type changed
@@ -337,21 +344,25 @@ export const CreateAddressSettingsDrawer = ({
337
344
  />
338
345
  )}
339
346
 
340
- <h4
341
- data-fs-bp-default-addresses-title
342
- data-fs-bp-default-addresses-locations-field
343
- >
344
- {t("addresses.labels.locations")}
345
- </h4>
346
- <h4 data-fs-bp-default-addresses-subtitle>
347
- {t("addresses.forms.setLocationsFieldName", [locationLabelText])}
348
- </h4>
349
- <InputText
350
- label={t("addresses.forms.fieldLabel")}
351
- value={locationsLabel}
352
- onChange={(event) => setLocationsLabel(event.target.value)}
353
- name="locations-label"
354
- />
347
+ {!isAddressEditingRestricted && (
348
+ <>
349
+ <h4
350
+ data-fs-bp-default-addresses-title
351
+ data-fs-bp-default-addresses-locations-field
352
+ >
353
+ {t("addresses.labels.locations")}
354
+ </h4>
355
+ <h4 data-fs-bp-default-addresses-subtitle>
356
+ {t("addresses.forms.setLocationsFieldName", [locationLabelText])}
357
+ </h4>
358
+ <InputText
359
+ label={t("addresses.forms.fieldLabel")}
360
+ value={locationsLabel}
361
+ onChange={(event) => setLocationsLabel(event.target.value)}
362
+ name="locations-label"
363
+ />
364
+ </>
365
+ )}
355
366
  </div>
356
367
  </SettingsDrawer>
357
368
  );
@@ -21,7 +21,11 @@ import {
21
21
  import { TabBar } from "../../../shared/components/Tab/TabBar";
22
22
  import { TabContent } from "../../../shared/components/Tab/TabContent";
23
23
  import { TabOption } from "../../../shared/components/Tab/TabOption";
24
- import { useBuyerPortal, useDrawerProps } from "../../../shared/hooks";
24
+ import {
25
+ useBuyerPortal,
26
+ useDrawerProps,
27
+ useIsAddressEditingRestricted,
28
+ } from "../../../shared/hooks";
25
29
  import {
26
30
  PAGE_PARAMS,
27
31
  SEARCH_PARAMS,
@@ -81,6 +85,7 @@ export const AddressDetailsLayout = ({
81
85
  currentContract: contract,
82
86
  featureFlags,
83
87
  } = useBuyerPortal();
88
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
84
89
  const isRecipientsContractLevelEnabled = Boolean(
85
90
  featureFlags?.enableRecipientsContractLevel
86
91
  );
@@ -273,8 +278,12 @@ export const AddressDetailsLayout = ({
273
278
  if (deleteRecipientErrorRef.current) throw deleteRecipientErrorRef.current;
274
279
  };
275
280
 
276
- const renderActionButton = () =>
277
- ({
281
+ const renderActionButton = () => {
282
+ if (isAddressEditingRestricted) {
283
+ return null;
284
+ }
285
+
286
+ return {
278
287
  ["address-tab"]: (
279
288
  <button
280
289
  type="button"
@@ -302,7 +311,8 @@ export const AddressDetailsLayout = ({
302
311
  {t("shared.buttons.add")}
303
312
  </button>
304
313
  ),
305
- }[activeTabId]);
314
+ }[activeTabId];
315
+ };
306
316
 
307
317
  return (
308
318
  <GlobalLayout>
@@ -389,11 +399,13 @@ export const AddressDetailsLayout = ({
389
399
  );
390
400
  })}
391
401
 
392
- <EditAddressDrawer
393
- readonly
394
- {...createDrawerProps}
395
- currentAddress={addressDetails}
396
- />
402
+ {!isAddressEditingRestricted && (
403
+ <EditAddressDrawer
404
+ readonly
405
+ {...createDrawerProps}
406
+ currentAddress={addressDetails}
407
+ />
408
+ )}
397
409
  </TabContent>
398
410
 
399
411
  <TabContent id="location-tab">
@@ -406,15 +418,17 @@ export const AddressDetailsLayout = ({
406
418
  onOpenAddDrawer={openLocationsDrawer}
407
419
  locationsLabel={locationSettingsData?.name}
408
420
  />
409
- <LocationDrawer
410
- {...locationsDrawerProps}
411
- isOpen={isOpenLocationsDrawer}
412
- addressId={addressDetails.id}
413
- locationsLabel={locationSettingsData?.name}
414
- onAddLocationSuccess={() =>
415
- addressLocationsListRef.current?.refreshLocations()
416
- }
417
- />
421
+ {!isAddressEditingRestricted && (
422
+ <LocationDrawer
423
+ {...locationsDrawerProps}
424
+ isOpen={isOpenLocationsDrawer}
425
+ addressId={addressDetails.id}
426
+ locationsLabel={locationSettingsData?.name}
427
+ onAddLocationSuccess={() =>
428
+ addressLocationsListRef.current?.refreshLocations()
429
+ }
430
+ />
431
+ )}
418
432
  </TabContent>
419
433
 
420
434
  {!isRecipientsContractLevelEnabled && (
@@ -446,6 +460,7 @@ export const AddressDetailsLayout = ({
446
460
  isLoading={isRecipientsLoading}
447
461
  searchTerm={recipientsSearchTerm}
448
462
  countryCode="USA"
463
+ hideActions={isAddressEditingRestricted}
449
464
  onEditRecipient={(recipient) => {
450
465
  setCurrentRecipient(recipient);
451
466
  openEditRecipientDrawer();
@@ -477,7 +492,7 @@ export const AddressDetailsLayout = ({
477
492
  )}
478
493
  </div>
479
494
 
480
- {isOpenRecipientsDrawer && (
495
+ {isOpenRecipientsDrawer && !isAddressEditingRestricted && (
481
496
  <AddRecipientsDrawer
482
497
  readonly
483
498
  {...recipientDrawerProps}
@@ -489,31 +504,35 @@ export const AddressDetailsLayout = ({
489
504
  />
490
505
  )}
491
506
 
492
- {isOpenEditRecipientDrawer && currentRecipient && (
493
- <EditRecipientDrawer
494
- readonly
495
- recipient={currentRecipient}
496
- {...editRecipientDrawerProps}
497
- isOpen={isOpenEditRecipientDrawer}
498
- isSubmitting={isEditRecipientToAddressLoading}
499
- onSubmit={handleEditRecipientSubmit}
500
- onSubmitted={handleRefetchRecipients}
501
- addressId={addressDetails.id}
502
- />
503
- )}
507
+ {isOpenEditRecipientDrawer &&
508
+ currentRecipient &&
509
+ !isAddressEditingRestricted && (
510
+ <EditRecipientDrawer
511
+ readonly
512
+ recipient={currentRecipient}
513
+ {...editRecipientDrawerProps}
514
+ isOpen={isOpenEditRecipientDrawer}
515
+ isSubmitting={isEditRecipientToAddressLoading}
516
+ onSubmit={handleEditRecipientSubmit}
517
+ onSubmitted={handleRefetchRecipients}
518
+ addressId={addressDetails.id}
519
+ />
520
+ )}
504
521
 
505
- {isOpenDeleteRecipientDrawer && currentRecipient && (
506
- <DeleteRecipientDrawer
507
- readonly
508
- recipient={currentRecipient}
509
- {...deleteRecipientDrawerProps}
510
- isOpen={isOpenDeleteRecipientDrawer}
511
- isSubmitting={isDeleteRecipientAddressLoading}
512
- onSubmit={handleDeleteRecipientSubmit}
513
- onSubmitted={handleRefetchRecipients}
514
- addressId={addressDetails.id}
515
- />
516
- )}
522
+ {isOpenDeleteRecipientDrawer &&
523
+ currentRecipient &&
524
+ !isAddressEditingRestricted && (
525
+ <DeleteRecipientDrawer
526
+ readonly
527
+ recipient={currentRecipient}
528
+ {...deleteRecipientDrawerProps}
529
+ isOpen={isOpenDeleteRecipientDrawer}
530
+ isSubmitting={isDeleteRecipientAddressLoading}
531
+ onSubmit={handleDeleteRecipientSubmit}
532
+ onSubmitted={handleRefetchRecipients}
533
+ addressId={addressDetails.id}
534
+ />
535
+ )}
517
536
  </TabContent>
518
537
  )}
519
538
  </Tab>
@@ -17,6 +17,7 @@ import {
17
17
  useBuyerPortal,
18
18
  useDrawerProps,
19
19
  useGetScopeConfig,
20
+ useIsAddressEditingRestricted,
20
21
  useUrlPaginatedSearch,
21
22
  SCOPE_KEYS,
22
23
  } from "../../../shared/hooks";
@@ -34,6 +35,7 @@ export const AddressLayout = () => {
34
35
  const { currentOrgUnit } = useBuyerPortal();
35
36
  const { pushToast } = useUI();
36
37
  const { t } = useLocalization();
38
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
37
39
 
38
40
  const {
39
41
  page,
@@ -165,7 +167,9 @@ export const AddressLayout = () => {
165
167
  style={{ backgroundColor: "transparent", color: "#1f1f1f" }}
166
168
  onClick={openCreateAddressSettingsDrawer}
167
169
  />
168
- <HeaderInside.Button onClick={openCreateDrawer} />
170
+ {!isAddressEditingRestricted && (
171
+ <HeaderInside.Button onClick={openCreateDrawer} />
172
+ )}
169
173
  </>
170
174
  )}
171
175
  </HeaderInside>
@@ -180,11 +184,15 @@ export const AddressLayout = () => {
180
184
  title={
181
185
  committedSearch.length > 0
182
186
  ? t("addresses.empty.noResults")
187
+ : isAddressEditingRestricted
188
+ ? t("addresses.empty.managedByMerchant")
183
189
  : t("addresses.empty.noAddressesYet")
184
190
  }
185
191
  description={
186
192
  committedSearch.length > 0
187
193
  ? t("addresses.empty.tryDifferentTerms")
194
+ : isAddressEditingRestricted
195
+ ? t("addresses.empty.managedByMerchantDescription")
188
196
  : undefined
189
197
  }
190
198
  iconName={
@@ -263,7 +271,7 @@ export const AddressLayout = () => {
263
271
  </div>
264
272
  )}
265
273
 
266
- {isCreateAddressDrawerOpen && (
274
+ {isCreateAddressDrawerOpen && !isAddressEditingRestricted && (
267
275
  <CreateAddressDrawer
268
276
  readonly
269
277
  isOpen={isCreateAddressDrawerOpen}
@@ -4,6 +4,7 @@ import { useUI } from "@faststore/ui";
4
4
 
5
5
  import { useListAccountingFields } from "../../../accounting-fields/hooks";
6
6
  import { CustomDropdown } from "../../../shared/components/CustomDropdown/CustomDropdown";
7
+ import { LocalizationEslintIgnore } from "../../../shared/components/LocalizationEslintIgnore/LocalizationEslintIgnore";
7
8
  import { useBuyerPortal } from "../../../shared/hooks";
8
9
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
9
10
  import {
@@ -91,7 +92,9 @@ export const AccountingFieldCriteriaSelector = ({
91
92
  <div data-fs-bp-accounting-field-criteria-selector>
92
93
  <div data-fs-bp-accounting-fields-criteria-string>
93
94
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
94
- $exists(customData.customFields.fields[customFieldId="
95
+ <LocalizationEslintIgnore>
96
+ $exists(customData.customFields.fields[customFieldId="
97
+ </LocalizationEslintIgnore>
95
98
  </span>
96
99
 
97
100
  <div onClick={handleFieldClick} style={{ display: "inline-block" }}>
@@ -115,7 +118,7 @@ export const AccountingFieldCriteriaSelector = ({
115
118
  </div>
116
119
 
117
120
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
118
- "].value)
121
+ <LocalizationEslintIgnore>"].value)</LocalizationEslintIgnore>
119
122
  </span>
120
123
  </div>
121
124
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  useListAccountingValues,
9
9
  } from "../../../accounting-fields/hooks";
10
10
  import { CustomDropdown } from "../../../shared/components/CustomDropdown/CustomDropdown";
11
+ import { LocalizationEslintIgnore } from "../../../shared/components/LocalizationEslintIgnore/LocalizationEslintIgnore";
11
12
  import { useBuyerPortal, useDebounce } from "../../../shared/hooks";
12
13
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
13
14
  import {
@@ -216,7 +217,9 @@ export const AccountingValueCriteriaSelector = ({
216
217
  <div data-fs-bp-accounting-field-criteria-selector>
217
218
  <div data-fs-bp-accounting-fields-criteria-string>
218
219
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
219
- customData.customFields.fields[customFieldId="
220
+ <LocalizationEslintIgnore>
221
+ customData.customFields.fields[customFieldId="
222
+ </LocalizationEslintIgnore>
220
223
  </span>
221
224
 
222
225
  <div onClick={handleFieldClick} style={{ display: "inline-block" }}>
@@ -244,7 +247,9 @@ export const AccountingValueCriteriaSelector = ({
244
247
  </div>
245
248
 
246
249
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
247
- "].customFieldValueId="
250
+ <LocalizationEslintIgnore>
251
+ "].customFieldValueId="
252
+ </LocalizationEslintIgnore>
248
253
  </span>
249
254
 
250
255
  <div onClick={handleValueClick} style={{ display: "inline-block" }}>
@@ -275,7 +280,7 @@ export const AccountingValueCriteriaSelector = ({
275
280
  </div>
276
281
 
277
282
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
278
- "
283
+ <LocalizationEslintIgnore>"</LocalizationEslintIgnore>
279
284
  </span>
280
285
  </div>
281
286
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect } from "react";
2
2
 
3
3
  import { CustomDropdown } from "../../../shared/components/CustomDropdown/CustomDropdown";
4
+ import { LocalizationEslintIgnore } from "../../../shared/components/LocalizationEslintIgnore/LocalizationEslintIgnore";
4
5
  import { useBuyerPortal } from "../../../shared/hooks";
5
6
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
6
7
  import { useGetBudgets } from "../../hooks/useGetBudgets";
@@ -90,7 +91,9 @@ export const BudgetCriteriaSelector = ({
90
91
  <div data-fs-bp-budget-criteria-selector>
91
92
  <div data-fs-bp-budget-criteria-string>
92
93
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
93
- $exists(budgetData.budgets[id="
94
+ <LocalizationEslintIgnore>
95
+ $exists(budgetData.budgets[id="
96
+ </LocalizationEslintIgnore>
94
97
  </span>
95
98
 
96
99
  <div onClick={handleBudgetValueClick}>
@@ -115,7 +118,7 @@ export const BudgetCriteriaSelector = ({
115
118
  </div>
116
119
 
117
120
  <span onClick={handleEditablePartClick} data-fs-bp-editable-part>
118
- "])
121
+ <LocalizationEslintIgnore>"])</LocalizationEslintIgnore>
119
122
  </span>
120
123
  </div>
121
124
  </div>
@@ -37,6 +37,7 @@ export const UnableToRemoveContractsDrawer = ({
37
37
  </p>
38
38
  <p>
39
39
  {t("contracts.removeDrawer.unableDeselectHint", [
40
+ // eslint-disable-next-line no-restricted-syntax -- legacy hardcoded string, pending migration (see specs/custom-localization-system.md#out-of-scope)
40
41
  <strong key="organizationalUnit">organizational unit</strong>,
41
42
  ])}
42
43
  </p>
@@ -12,7 +12,10 @@ import { AddBuyingPolicyDrawer } from "../../../buying-policies/components";
12
12
  import { CreateCreditCardDrawer } from "../../../credit-cards/components";
13
13
  import { useGetRolesOptions } from "../../../roles/hooks";
14
14
  import { BasicDropdownMenu, Icon } from "../../../shared/components";
15
- import { useDrawerProps } from "../../../shared/hooks";
15
+ import {
16
+ useDrawerProps,
17
+ useIsAddressEditingRestricted,
18
+ } from "../../../shared/hooks";
16
19
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
17
20
  import { CreateUserDrawerSelector } from "../../../users/components";
18
21
 
@@ -38,6 +41,7 @@ export const AddAllToOrgUnitDropdown = ({
38
41
  const { t } = useLocalization();
39
42
  const sizeProps = { width: 20, height: 20 };
40
43
  const { rolesOptions } = useGetRolesOptions(unitId);
44
+ const isAddressEditingRestricted = useIsAddressEditingRestricted();
41
45
 
42
46
  const {
43
47
  open: openCreditCardDrawer,
@@ -119,10 +123,12 @@ export const AddAllToOrgUnitDropdown = ({
119
123
 
120
124
  {isSingleContract && (
121
125
  <>
122
- <DropdownItem onClick={openAddressDrawer}>
123
- <Icon name="LocalPostOffice" {...sizeProps} />
124
- {t("shared.orgUnitDropdown.addAddress")}
125
- </DropdownItem>
126
+ {!isAddressEditingRestricted && (
127
+ <DropdownItem onClick={openAddressDrawer}>
128
+ <Icon name="LocalPostOffice" {...sizeProps} />
129
+ {t("shared.orgUnitDropdown.addAddress")}
130
+ </DropdownItem>
131
+ )}
126
132
  <DropdownItem onClick={openCreditCardDrawer}>
127
133
  <Icon name="CreditCard" {...sizeProps} />
128
134
  {t("shared.orgUnitDropdown.addCreditCard")}
@@ -181,7 +187,7 @@ export const AddAllToOrgUnitDropdown = ({
181
187
  />
182
188
  )}
183
189
 
184
- {isOpenAddressDrawer && (
190
+ {isOpenAddressDrawer && !isAddressEditingRestricted && (
185
191
  <CreateAddressDrawer
186
192
  {...addressDrawerProps}
187
193
  isOpen={isOpenAddressDrawer}
@@ -18,6 +18,7 @@ export type RecipientsListProps = {
18
18
  countryCode?: CountryCodes;
19
19
  onEditRecipient: (recipient: RecipientData) => void;
20
20
  onDeleteRecipient: (recipient: RecipientData) => void;
21
+ hideActions?: boolean;
21
22
  };
22
23
 
23
24
  export const RecipientsList = ({
@@ -27,11 +28,12 @@ export const RecipientsList = ({
27
28
  countryCode,
28
29
  onEditRecipient,
29
30
  onDeleteRecipient,
31
+ hideActions = false,
30
32
  }: RecipientsListProps) => {
31
33
  const { t } = useLocalization();
32
34
 
33
35
  const columns = getTableColumns({
34
- actionsLength: 2,
36
+ actionsLength: hideActions ? 0 : 2,
35
37
  nameColumnSize: "11.125rem",
36
38
  nameColumnLabel: t("shared.labels.name"),
37
39
  extraColumns: [
@@ -46,6 +48,10 @@ export const RecipientsList = ({
46
48
  });
47
49
 
48
50
  const renderActions = (recipient: RecipientData) => {
51
+ if (hideActions) {
52
+ return undefined;
53
+ }
54
+
49
55
  return [
50
56
  <IconButton
51
57
  data-fs-bp-recipients-action-icon
@@ -2,6 +2,8 @@ import { getApiUrl } from "../../shared/utils";
2
2
 
3
3
  import { Client } from "./Client";
4
4
 
5
+ import type { StorefrontResourceName } from "../utils/constants";
6
+
5
7
  type AuthContextProps = {
6
8
  cookie: string;
7
9
  };
@@ -28,6 +30,18 @@ export default class AuthClient extends Client {
28
30
  },
29
31
  });
30
32
  }
33
+
34
+ hasStorefrontResource(
35
+ resourceName: StorefrontResourceName,
36
+ context: AuthContextProps
37
+ ) {
38
+ return this.get<boolean | string>(`permissions/granted/${resourceName}`, {
39
+ headers: {
40
+ Cookie: context.cookie,
41
+ "Content-Type": "text/plain",
42
+ },
43
+ });
44
+ }
31
45
  }
32
46
 
33
47
  const authClient = new AuthClient();
@@ -20,6 +20,11 @@ export type FeatureFlags = {
20
20
  authSettings?: AuthSettingsFeatureFlags;
21
21
  suma?: boolean;
22
22
  enableRecipientsContractLevel?: boolean;
23
+ enableAddressRestriction?: boolean;
24
+ };
25
+
26
+ export type StorefrontPermissions = {
27
+ canManageAddresses: boolean;
23
28
  };
24
29
 
25
30
  export type BuyerPortalContextType = {
@@ -28,6 +33,7 @@ export type BuyerPortalContextType = {
28
33
  currentContract?: ContractData | null;
29
34
  currentUser?: UserData | null;
30
35
  featureFlags?: FeatureFlags;
36
+ permissions?: StorefrontPermissions;
31
37
  };
32
38
 
33
39
  export const BuyerPortalContext = createContext<
@@ -0,0 +1,12 @@
1
+ export type LocalizationEslintIgnoreProps = {
2
+ children: React.ReactNode;
3
+ };
4
+
5
+ /**
6
+ * Wraps non-translatable literals (e.g. technical/DSL syntax) so the
7
+ * localization guardrail ignores them. `eslint.config.mjs` exempts this
8
+ * component by name. See docs/conventions.md#localization.
9
+ */
10
+ export const LocalizationEslintIgnore = ({
11
+ children,
12
+ }: LocalizationEslintIgnoreProps) => <>{children}</>;
@@ -35,6 +35,7 @@ export {
35
35
  type BuyerPortalContextType,
36
36
  type BuyerPortalProviderProps,
37
37
  type FeatureFlags,
38
+ type StorefrontPermissions,
38
39
  } from "./BuyerPortalProvider/BuyerPortalProvider";
39
40
  export { Card, CardBody, CardFooter, CardHeader } from "./Card";
40
41
  export {
@@ -73,6 +74,10 @@ export {
73
74
  LevelDivider,
74
75
  type LevelDividerProps,
75
76
  } from "./LevelDivider/LevelDivider";
77
+ export {
78
+ LocalizationEslintIgnore,
79
+ type LocalizationEslintIgnoreProps,
80
+ } from "./LocalizationEslintIgnore/LocalizationEslintIgnore";
76
81
  export { Logo } from "./Logo/Logo";
77
82
  export { MainLinksDropdownMenu } from "./MainLinksDropdownMenu/MainLinksDropdownMenu";
78
83
  export { Pagination } from "./Pagination/Pagination";
@@ -1,4 +1,5 @@
1
1
  export { useBuyerPortal } from "./useBuyerPortal";
2
+ export { useIsAddressEditingRestricted } from "./useIsAddressEditingRestricted";
2
3
  export { useDrawerProps, type DrawerProps } from "./useDrawerProps";
3
4
  export {
4
5
  useMutation,
@@ -0,0 +1,12 @@
1
+ import { isAddressEditingRestricted } from "../utils/isAddressEditingRestricted";
2
+
3
+ import { useBuyerPortal } from "./useBuyerPortal";
4
+
5
+ export const useIsAddressEditingRestricted = (): boolean => {
6
+ const { featureFlags, permissions } = useBuyerPortal();
7
+
8
+ return isAddressEditingRestricted({
9
+ enableAddressRestriction: featureFlags?.enableAddressRestriction,
10
+ canManageAddresses: permissions?.canManageAddresses,
11
+ });
12
+ };
@@ -48,7 +48,7 @@ export const SumaSidebar = ({
48
48
  aria-label="Back to contracts"
49
49
  >
50
50
  <Icon name="ChevronRight" width={16} height={16} />
51
- Contracts
51
+ {t("layouts.navigation.contracts")}
52
52
  </Link>
53
53
  )}
54
54
 
@@ -0,0 +1,69 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("discovery.config", () => ({
4
+ default: {
5
+ api: { storeId: "test-store" },
6
+ secureSubdomain: "https://test.vtex.com",
7
+ },
8
+ }));
9
+
10
+ const hasStorefrontResourceMock = vi.fn();
11
+
12
+ vi.mock("../../clients/Auth", () => ({
13
+ authClient: {
14
+ hasStorefrontResource: (...args: unknown[]) =>
15
+ hasStorefrontResourceMock(...args),
16
+ },
17
+ }));
18
+
19
+ import { hasStorefrontResourceService } from "../has-storefront-resource.service";
20
+
21
+ describe("hasStorefrontResourceService", () => {
22
+ beforeEach(() => {
23
+ hasStorefrontResourceMock.mockReset();
24
+ });
25
+
26
+ it("returns true when the BFF answers true", async () => {
27
+ hasStorefrontResourceMock.mockResolvedValue(true);
28
+
29
+ await expect(
30
+ hasStorefrontResourceService({
31
+ cookie: "cookie",
32
+ resourceName: "manage-addresses",
33
+ })
34
+ ).resolves.toBe(true);
35
+ });
36
+
37
+ it("returns true when the BFF answers the string true", async () => {
38
+ hasStorefrontResourceMock.mockResolvedValue("true");
39
+
40
+ await expect(
41
+ hasStorefrontResourceService({
42
+ cookie: "cookie",
43
+ resourceName: "manage-addresses",
44
+ })
45
+ ).resolves.toBe(true);
46
+ });
47
+
48
+ it("returns false when the BFF answers false", async () => {
49
+ hasStorefrontResourceMock.mockResolvedValue(false);
50
+
51
+ await expect(
52
+ hasStorefrontResourceService({
53
+ cookie: "cookie",
54
+ resourceName: "manage-addresses",
55
+ })
56
+ ).resolves.toBe(false);
57
+ });
58
+
59
+ it("fails closed when the BFF throws", async () => {
60
+ hasStorefrontResourceMock.mockRejectedValue(new Error("network"));
61
+
62
+ await expect(
63
+ hasStorefrontResourceService({
64
+ cookie: "cookie",
65
+ resourceName: "manage-addresses",
66
+ })
67
+ ).resolves.toBe(false);
68
+ });
69
+ });
@@ -51,11 +51,16 @@ describe("normalizeFlagsEndpointBooleans", () => {
51
51
 
52
52
  it("preserves other flags alongside suma", () => {
53
53
  const result = normalizeFlagsEndpointBooleans({
54
- flags: { suma: true, enable_alternative_login: true },
54
+ flags: {
55
+ suma: true,
56
+ enable_alternative_login: true,
57
+ enable_address_restriction: true,
58
+ },
55
59
  });
56
60
 
57
61
  expect(result["suma"]).toBe(true);
58
62
  expect(result["enable_alternative_login"]).toBe(true);
63
+ expect(result["enable_address_restriction"]).toBe(true);
59
64
  });
60
65
  });
61
66
 
@@ -100,5 +100,6 @@ export async function getBuyerPortalFeatureFlags(
100
100
  enableRecipientsContractLevel:
101
101
  normalized["enable_recipients_contract_level"] ?? false,
102
102
  suma: normalized["suma"] ?? false,
103
+ enableAddressRestriction: normalized["enable_address_restriction"] ?? false,
103
104
  };
104
105
  }
@@ -0,0 +1,27 @@
1
+ import { authClient } from "../clients/Auth";
2
+
3
+ import type { StorefrontResourceName } from "../utils/constants";
4
+
5
+ export type HasStorefrontResourceServiceProps = {
6
+ cookie: string;
7
+ resourceName: StorefrontResourceName;
8
+ };
9
+
10
+ function isGranted(value: unknown): boolean {
11
+ return value === true || value === "true";
12
+ }
13
+
14
+ export const hasStorefrontResourceService = async ({
15
+ cookie,
16
+ resourceName,
17
+ }: HasStorefrontResourceServiceProps): Promise<boolean> => {
18
+ try {
19
+ const result = await authClient.hasStorefrontResource(resourceName, {
20
+ cookie,
21
+ });
22
+
23
+ return isGranted(result);
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
@@ -14,6 +14,10 @@ export {
14
14
  validateOrderEntryAccessService,
15
15
  type ValidateOrderEntryAccessServiceProps,
16
16
  } from "./validate-order-entry-access.service";
17
+ export {
18
+ hasStorefrontResourceService,
19
+ type HasStorefrontResourceServiceProps,
20
+ } from "./has-storefront-resource.service";
17
21
  export * from "./logger";
18
22
  export {
19
23
  getDependenciesVersionService,
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { isAddressEditingRestricted } from "../isAddressEditingRestricted";
4
+
5
+ describe("isAddressEditingRestricted", () => {
6
+ it("is false when the flag is off", () => {
7
+ expect(
8
+ isAddressEditingRestricted({
9
+ enableAddressRestriction: false,
10
+ canManageAddresses: false,
11
+ })
12
+ ).toBe(false);
13
+ });
14
+
15
+ it("is false when the flag is on and the user can manage addresses", () => {
16
+ expect(
17
+ isAddressEditingRestricted({
18
+ enableAddressRestriction: true,
19
+ canManageAddresses: true,
20
+ })
21
+ ).toBe(false);
22
+ });
23
+
24
+ it("is true when the flag is on and the user cannot manage addresses", () => {
25
+ expect(
26
+ isAddressEditingRestricted({
27
+ enableAddressRestriction: true,
28
+ canManageAddresses: false,
29
+ })
30
+ ).toBe(true);
31
+ });
32
+
33
+ it("fails closed when the flag is on and the permission is unknown", () => {
34
+ expect(
35
+ isAddressEditingRestricted({
36
+ enableAddressRestriction: true,
37
+ })
38
+ ).toBe(true);
39
+ });
40
+
41
+ it("is false when both values are omitted", () => {
42
+ expect(isAddressEditingRestricted({})).toBe(false);
43
+ });
44
+ });
@@ -5,6 +5,8 @@ import { withAuthLoader } from "../withAuthLoader";
5
5
  import type { LoaderData } from "../../types";
6
6
 
7
7
  const validateAccessServiceMock = vi.fn();
8
+ const hasStorefrontResourceServiceMock = vi.fn();
9
+ const getBuyerPortalFeatureFlagsMock = vi.fn().mockResolvedValue({});
8
10
 
9
11
  vi.mock("../../services", () => ({
10
12
  validateAccessService: (...args: unknown[]) =>
@@ -12,7 +14,13 @@ vi.mock("../../services", () => ({
12
14
  }));
13
15
 
14
16
  vi.mock("../../services/feature-flags/get-feature-flags.service", () => ({
15
- getBuyerPortalFeatureFlags: vi.fn().mockResolvedValue({}),
17
+ getBuyerPortalFeatureFlags: (...args: unknown[]) =>
18
+ getBuyerPortalFeatureFlagsMock(...args),
19
+ }));
20
+
21
+ vi.mock("../../services/has-storefront-resource.service", () => ({
22
+ hasStorefrontResourceService: (...args: unknown[]) =>
23
+ hasStorefrontResourceServiceMock(...args),
16
24
  }));
17
25
 
18
26
  vi.mock("../getClientContext", () => ({
@@ -35,6 +43,9 @@ const createLoaderData = (): LoaderData => ({
35
43
  describe("withAuthLoader", () => {
36
44
  beforeEach(() => {
37
45
  validateAccessServiceMock.mockReset();
46
+ hasStorefrontResourceServiceMock.mockReset();
47
+ getBuyerPortalFeatureFlagsMock.mockReset();
48
+ getBuyerPortalFeatureFlagsMock.mockResolvedValue({});
38
49
  });
39
50
 
40
51
  it("uses the custom validateAccess option instead of the default when granted", async () => {
@@ -81,4 +92,41 @@ describe("withAuthLoader", () => {
81
92
  });
82
93
  expect(result.authorized).toBe(true);
83
94
  });
95
+
96
+ it("does not call the permission API when address restriction is off", async () => {
97
+ validateAccessServiceMock.mockResolvedValue(true);
98
+ getBuyerPortalFeatureFlagsMock.mockResolvedValue({
99
+ enableAddressRestriction: false,
100
+ });
101
+
102
+ await withAuthLoader(createLoaderData(), async () => ({ context: {} }));
103
+
104
+ expect(hasStorefrontResourceServiceMock).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("calls the permission API and merges the result when address restriction is on", async () => {
108
+ validateAccessServiceMock.mockResolvedValue(true);
109
+ getBuyerPortalFeatureFlagsMock.mockResolvedValue({
110
+ enableAddressRestriction: true,
111
+ });
112
+ hasStorefrontResourceServiceMock.mockResolvedValue(false);
113
+
114
+ const result = await withAuthLoader(createLoaderData(), async () => ({
115
+ context: {},
116
+ }));
117
+
118
+ expect(hasStorefrontResourceServiceMock).toHaveBeenCalledWith({
119
+ cookie: "cookie-value",
120
+ resourceName: "manage-addresses",
121
+ });
122
+ expect(result).toMatchObject({
123
+ authorized: true,
124
+ data: {
125
+ context: {
126
+ featureFlags: { enableAddressRestriction: true },
127
+ permissions: { canManageAddresses: false },
128
+ },
129
+ },
130
+ });
131
+ });
84
132
  });
@@ -4,7 +4,7 @@ export const API_URL = (checkoutUrl: string, operation?: string) =>
4
4
  `${checkoutUrl}/_v/store-front/${operation}`;
5
5
  // DEV URL - CHANGE BEFORE MERGE
6
6
  // export const API_URL = (checkoutUrl?: string, operation?: string) =>
7
- // `https://altphonenumber--b2bfaststoredev.myvtex.com/_v/store-front/${operation}`;
7
+ // `https://nicholasb2b--b2bfaststoreqa.myvtex.com/_v/store-front/${operation}`;
8
8
 
9
9
  export const DEBOUNCE_TIMEOUT = 500;
10
10
 
@@ -25,4 +25,12 @@ export const SCOPE_KEYS = {
25
25
  CREDIT_CARDS: "creditCards",
26
26
  } as const;
27
27
 
28
- export const CURRENT_VERSION = "2.0.28";
28
+ /** Public names for GET /_v/store-front/permissions/granted/:resourceName */
29
+ export const STOREFRONT_RESOURCE_NAMES = {
30
+ MANAGE_ADDRESSES: "manage-addresses",
31
+ } as const;
32
+
33
+ export type StorefrontResourceName =
34
+ (typeof STOREFRONT_RESOURCE_NAMES)[keyof typeof STOREFRONT_RESOURCE_NAMES];
35
+
36
+ export const CURRENT_VERSION = "2.0.30";
@@ -13,7 +13,11 @@ export {
13
13
  DEBOUNCE_TIMEOUT,
14
14
  DEFAULT_PAGE_SIZE,
15
15
  SCOPE_KEYS,
16
+ STOREFRONT_RESOURCE_NAMES,
17
+ type StorefrontResourceName,
16
18
  } from "./constants";
19
+ export { isAddressEditingRestricted } from "./isAddressEditingRestricted";
20
+ export type { AddressRestrictionInput } from "./isAddressEditingRestricted";
17
21
  export {
18
22
  getAuthCookie,
19
23
  getCookieAsString,
@@ -0,0 +1,10 @@
1
+ export type AddressRestrictionInput = {
2
+ enableAddressRestriction?: boolean;
3
+ canManageAddresses?: boolean;
4
+ };
5
+
6
+ export const isAddressEditingRestricted = ({
7
+ enableAddressRestriction,
8
+ canManageAddresses,
9
+ }: AddressRestrictionInput): boolean =>
10
+ enableAddressRestriction === true && canManageAddresses !== true;
@@ -2,7 +2,9 @@ import { LOCALIZATION_ENTRY_IDS } from "../localization/constants";
2
2
  import { getLocalizationLabelsService } from "../localization/services/get-localization-labels.service";
3
3
  import { validateAccessService } from "../services";
4
4
  import { getBuyerPortalFeatureFlags } from "../services/feature-flags/get-feature-flags.service";
5
+ import { hasStorefrontResourceService } from "../services/has-storefront-resource.service";
5
6
 
7
+ import { STOREFRONT_RESOURCE_NAMES } from "./constants";
6
8
  import { ClientContext, getClientContext } from "./getClientContext";
7
9
 
8
10
  import type { FeatureFlags } from "../components/BuyerPortalProvider/BuyerPortalProvider";
@@ -46,10 +48,23 @@ export const withAuthLoader = async <T>(
46
48
  }
47
49
 
48
50
  const featureFlagsPromise = getBuyerPortalFeatureFlags(cookie);
49
-
50
- const [pageData, featureFlags] = await Promise.all([
51
+ const canManageAddressesPromise = featureFlagsPromise.then(
52
+ async (flags) => {
53
+ if (!flags.enableAddressRestriction) {
54
+ return true;
55
+ }
56
+
57
+ return hasStorefrontResourceService({
58
+ cookie,
59
+ resourceName: STOREFRONT_RESOURCE_NAMES.MANAGE_ADDRESSES,
60
+ });
61
+ }
62
+ );
63
+
64
+ const [pageData, featureFlags, canManageAddresses] = await Promise.all([
51
65
  dataLoader({ cookie, userId, ...clientContext, featureFlagsPromise }),
52
66
  featureFlagsPromise,
67
+ canManageAddressesPromise,
53
68
  ]);
54
69
 
55
70
  const mergedPageData = (() => {
@@ -67,9 +82,12 @@ export const withAuthLoader = async <T>(
67
82
  const currentFeatureFlags =
68
83
  (currentContext as { featureFlags?: Record<string, unknown> })
69
84
  .featureFlags ?? {};
85
+ const currentPermissions =
86
+ (currentContext as { permissions?: Record<string, unknown> })
87
+ .permissions ?? {};
70
88
 
71
89
  return {
72
- ...(pageData as Record<string, unknown>),
90
+ ...(pageData as unknown as Record<string, unknown>),
73
91
  context: {
74
92
  ...currentContext,
75
93
  featureFlags: {
@@ -78,8 +96,14 @@ export const withAuthLoader = async <T>(
78
96
  ? currentFeatureFlags
79
97
  : {}),
80
98
  },
99
+ permissions: {
100
+ canManageAddresses,
101
+ ...(typeof currentPermissions === "object"
102
+ ? currentPermissions
103
+ : {}),
104
+ },
81
105
  },
82
- } as T;
106
+ } as unknown as T;
83
107
  })();
84
108
 
85
109
  return {