@vtex/faststore-plugin-buyer-portal 2.0.26 → 2.0.27

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +10 -3
  3. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +18 -5
  4. package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +14 -9
  5. package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +13 -0
  6. package/src/features/contracts/services/__tests__/list-unit-contracts.service.test.ts +117 -0
  7. package/src/features/contracts/services/__tests__/resolve-attached-contract.service.test.ts +118 -0
  8. package/src/features/contracts/services/index.ts +10 -0
  9. package/src/features/contracts/services/list-unit-contracts.service.ts +44 -0
  10. package/src/features/contracts/services/resolve-attached-contract.service.ts +53 -0
  11. package/src/features/contracts/types/ContractData.ts +2 -0
  12. package/src/features/org-units/layouts/OrgUnitDetailsLayout/OrgUnitDetailsLayout.tsx +12 -7
  13. package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +30 -1
  14. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +9 -4
  15. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +27 -5
  16. package/src/features/shared/utils/__tests__/contractScopedLink.test.ts +39 -0
  17. package/src/features/shared/utils/__tests__/resolveContractId.test.ts +10 -2
  18. package/src/features/shared/utils/constants.ts +1 -1
  19. package/src/features/shared/utils/contractScopedLink.ts +16 -0
  20. package/src/features/shared/utils/getContractSettingsLinks.ts +7 -9
  21. package/src/features/shared/utils/getFinanceSettingsLinks.ts +4 -6
  22. package/src/features/shared/utils/index.ts +1 -0
  23. package/src/features/shared/utils/resolveContractId.ts +6 -3
  24. package/src/pages/contracts.tsx +2 -2
  25. package/src/pages/org-unit-details.tsx +22 -2
  26. package/src/pages/org-units.tsx +36 -23
  27. package/src/pages/roles.tsx +59 -42
  28. package/src/pages/users.tsx +25 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.26",
3
+ "version": "2.0.27",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -29,10 +29,12 @@ export const ContractListItem = ({
29
29
  const displayName = contract.name ?? contract.email;
30
30
  const { pushToast } = useUI();
31
31
  const { switchContract, loading } = useSwitchContract();
32
+ const isRemovable = contract.isAttached !== false;
32
33
 
33
34
  const handleCheckboxClick = (event: React.MouseEvent) => {
34
35
  event.stopPropagation();
35
36
  event.preventDefault();
37
+ if (!isRemovable) return;
36
38
  onSelect(contract.id, event);
37
39
  };
38
40
 
@@ -59,10 +61,14 @@ export const ContractListItem = ({
59
61
  >
60
62
  <div
61
63
  data-fs-bp-contract-list-item-checkbox
64
+ data-fs-bp-contract-list-item-checkbox-disabled={
65
+ !isRemovable || undefined
66
+ }
62
67
  role="checkbox"
63
68
  aria-checked={isSelected}
69
+ aria-disabled={!isRemovable}
64
70
  aria-label={`Select ${displayName ?? "contract"}`}
65
- tabIndex={0}
71
+ tabIndex={isRemovable ? 0 : -1}
66
72
  onClick={handleCheckboxClick}
67
73
  onKeyDown={(e) =>
68
74
  e.key === " " && handleCheckboxClick(e as unknown as React.MouseEvent)
@@ -91,7 +97,7 @@ export const ContractListItem = ({
91
97
  <Dropdown>
92
98
  <BasicDropdownMenu.Trigger />
93
99
  <BasicDropdownMenu>
94
- {contract.isDefault ? (
100
+ {contract.isDefault || !isRemovable ? (
95
101
  <DropdownItem disabled>
96
102
  <Icon name="Star" width={16} height={16} />
97
103
  {t("contracts.actions.setAsDefault")}
@@ -105,7 +111,8 @@ export const ContractListItem = ({
105
111
  <BasicDropdownMenu.Separator />
106
112
  <DropdownItem
107
113
  data-fs-bp-dropdown-menu-item-mode="danger"
108
- onClick={onRemove}
114
+ disabled={!isRemovable}
115
+ onClick={isRemovable ? onRemove : undefined}
109
116
  >
110
117
  <Icon name="CircleRemove" width={16} height={16} />
111
118
  {t("shared.buttons.remove")}
@@ -23,6 +23,7 @@ import { GlobalLayout } from "../../../shared/layouts";
23
23
  import { SumaSidebar } from "../../../shared/layouts/SumaPageLayout/SumaSidebar";
24
24
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
25
25
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
26
+ import { contractScopedLink } from "../../../shared/utils/contractScopedLink";
26
27
  import { AddContractsDrawer } from "../../components/AddContractsDrawer/AddContractsDrawer";
27
28
  import { ConfirmRemoveDrawer } from "../../components/ConfirmRemoveDrawer/ConfirmRemoveDrawer";
28
29
  import { ContractSelectAllRow } from "../../components/ContractSelectAllRow";
@@ -169,9 +170,14 @@ export const ContractListingLayout = ({
169
170
  },
170
171
  });
171
172
 
173
+ const removableIds = new Set(
174
+ contracts.filter((c) => c.isAttached !== false).map((c) => c.id)
175
+ );
176
+
172
177
  const handleRequestRemove = (overrideIds?: Set<string>) => {
173
178
  const targetIds = overrideIds ?? selectedIds;
174
- if (targetIds.size === contracts.length) {
179
+ if (targetIds.size === 0) return;
180
+ if (targetIds.size === removableIds.size) {
175
181
  openUnableToRemoveDrawer();
176
182
  return;
177
183
  }
@@ -180,6 +186,7 @@ export const ContractListingLayout = ({
180
186
  };
181
187
 
182
188
  const handleItemRemove = (id: string) => {
189
+ if (!removableIds.has(id)) return;
183
190
  handleRequestRemove(new Set([id]));
184
191
  };
185
192
 
@@ -215,6 +222,7 @@ export const ContractListingLayout = ({
215
222
  });
216
223
 
217
224
  const handleSetDefault = (contractId: string) => {
225
+ if (!removableIds.has(contractId)) return;
218
226
  setDefaultContract({ orgUnitId: orgUnit.id, contractId })?.catch(() => {});
219
227
  };
220
228
 
@@ -267,9 +275,14 @@ export const ContractListingLayout = ({
267
275
  selectedIds.has(id)
268
276
  ).length;
269
277
 
278
+ // Contracts sourced from the legacy fallback (never attached) can't be
279
+ // selected for bulk removal — the BFF has no attach relation to detach.
280
+ const removableFilteredIds = filteredIds.filter((id) => removableIds.has(id));
281
+
270
282
  const handleSelect = (id: string, event: React.MouseEvent) => {
283
+ if (!removableIds.has(id)) return;
271
284
  if (event.shiftKey) {
272
- handleShiftClick(id, filteredIds);
285
+ handleShiftClick(id, removableFilteredIds);
273
286
  } else {
274
287
  handleModifierClick(id);
275
288
  }
@@ -279,14 +292,14 @@ export const ContractListingLayout = ({
279
292
  if (selectedInFiltered > 0) {
280
293
  clearSelection();
281
294
  } else {
282
- handleSelectAll(filteredIds);
295
+ handleSelectAll(removableFilteredIds);
283
296
  }
284
297
  };
285
298
 
286
299
  const handleKeyDown = (event: React.KeyboardEvent) => {
287
300
  if ((event.metaKey || event.ctrlKey) && event.key === "a") {
288
301
  event.preventDefault();
289
- handleSelectAll(filteredIds);
302
+ handleSelectAll(removableFilteredIds);
290
303
  }
291
304
  };
292
305
 
@@ -342,7 +355,7 @@ export const ContractListingLayout = ({
342
355
  <BasicCard
343
356
  data-fs-bp-contracts-settings-card
344
357
  footerMessage={t("orgUnitDetails.links.manageContractSettings")}
345
- footerLink={buyerPortalRoutes.profileDetails({
358
+ footerLink={contractScopedLink(buyerPortalRoutes.profileDetails, {
346
359
  orgUnitId: orgUnit.id,
347
360
  contractId: defaultContractId,
348
361
  })}
@@ -4,6 +4,7 @@ import { AccountingFieldDropdown } from "../../../accounting-fields/components";
4
4
  import { checkAccountingFieldIsEmpty } from "../../../accounting-fields/utils";
5
5
  import { Icon } from "../../../shared/components";
6
6
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
7
+ import { contractScopedLink } from "../../../shared/utils/contractScopedLink";
7
8
 
8
9
  import type { AccountingField } from "../../../accounting-fields/types";
9
10
  import type { LocalizeFn } from "../../../shared/localization/types";
@@ -35,23 +36,23 @@ export const buildContractSettingsNavItems = ({
35
36
  return [
36
37
  {
37
38
  name: t("contracts.nav.basicInformation"),
38
- link: buyerPortalRoutes.profileDetails(linkParams),
39
+ link: contractScopedLink(buyerPortalRoutes.profileDetails, linkParams),
39
40
  },
40
41
  {
41
42
  name: t("layouts.navigation.addresses"),
42
- link: buyerPortalRoutes.addresses(linkParams),
43
+ link: contractScopedLink(buyerPortalRoutes.addresses, linkParams),
43
44
  },
44
45
  {
45
46
  name: t("layouts.navigation.paymentMethods"),
46
- link: buyerPortalRoutes.paymentMethods(linkParams),
47
+ link: contractScopedLink(buyerPortalRoutes.paymentMethods, linkParams),
47
48
  },
48
49
  {
49
50
  name: t("layouts.navigation.creditCards"),
50
- link: buyerPortalRoutes.creditCards(linkParams),
51
+ link: contractScopedLink(buyerPortalRoutes.creditCards, linkParams),
51
52
  },
52
53
  {
53
54
  name: t("contracts.nav.assortment"),
54
- link: buyerPortalRoutes.productAssortment(linkParams),
55
+ link: contractScopedLink(buyerPortalRoutes.productAssortment, linkParams),
55
56
  },
56
57
  {
57
58
  name: t("layouts.navigation.accountingFields"),
@@ -59,10 +60,14 @@ export const buildContractSettingsNavItems = ({
59
60
  submenu: {
60
61
  items: accountingFields.map((field) => ({
61
62
  name: field.name,
62
- link: buyerPortalRoutes.accountingFields({
63
- ...linkParams,
64
- accountingFieldId: field.id,
65
- }),
63
+ link: contractScopedLink(
64
+ (params) =>
65
+ buyerPortalRoutes.accountingFields({
66
+ ...params,
67
+ accountingFieldId: field.id,
68
+ }),
69
+ linkParams
70
+ ),
66
71
  hasNotification: checkAccountingFieldIsEmpty(field),
67
72
  actions: (
68
73
  <AccountingFieldDropdown
@@ -89,6 +89,14 @@
89
89
  flex-shrink: 0;
90
90
  color: var(--fs-bp-color-brand);
91
91
  }
92
+
93
+ [data-fs-bp-suma-sidebar-identity-contract] {
94
+ @include text-style("body");
95
+ color: var(--fs-bp-color-neutral-8);
96
+ white-space: nowrap;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ }
92
100
  }
93
101
  }
94
102
 
@@ -437,6 +445,11 @@
437
445
  outline: 2px solid var(--fs-bp-color-brand);
438
446
  outline-offset: 2px;
439
447
  }
448
+
449
+ &[data-fs-bp-contract-list-item-checkbox-disabled] {
450
+ cursor: not-allowed;
451
+ opacity: 0.4;
452
+ }
440
453
  }
441
454
 
442
455
  [data-fs-bp-letter-highlight] {
@@ -0,0 +1,117 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../list-attached-contracts.service", () => ({
4
+ listAttachedContractsService: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../get-contracts-org-by-unit-id.service", () => ({
8
+ getContractsByOrgUnitIdService: vi.fn(),
9
+ }));
10
+
11
+ import { getContractsByOrgUnitIdService } from "../get-contracts-org-by-unit-id.service";
12
+ import { listAttachedContractsService } from "../list-attached-contracts.service";
13
+ import { listUnitContractsService } from "../list-unit-contracts.service";
14
+
15
+ import type { ContractData } from "../../types";
16
+
17
+ const listAttachedContractsMock = vi.mocked(listAttachedContractsService);
18
+ const getContractsByOrgUnitIdMock = vi.mocked(getContractsByOrgUnitIdService);
19
+
20
+ const cookie = "VtexIdclientAutCookie=test-token";
21
+ const orgUnitId = "unit-123";
22
+
23
+ function contract(id: string, isDefault = false): ContractData {
24
+ return {
25
+ id,
26
+ name: id,
27
+ email: `${id}@b.com`,
28
+ isActive: true,
29
+ creationDate: "2026-01-01",
30
+ isDefault,
31
+ };
32
+ }
33
+
34
+ describe("listUnitContractsService", () => {
35
+ beforeEach(() => {
36
+ listAttachedContractsMock.mockReset();
37
+ getContractsByOrgUnitIdMock.mockReset();
38
+ });
39
+
40
+ it("returns the attached listing when the unit has attached contracts", async () => {
41
+ listAttachedContractsMock.mockResolvedValue([
42
+ contract("a", true),
43
+ contract("b"),
44
+ ]);
45
+
46
+ const result = await listUnitContractsService({ orgUnitId, cookie });
47
+
48
+ expect(result.map((c) => c.id)).toEqual(["a", "b"]);
49
+ expect(getContractsByOrgUnitIdMock).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("flags attached contracts as isAttached: true", async () => {
53
+ listAttachedContractsMock.mockResolvedValue([contract("a", true)]);
54
+
55
+ const result = await listUnitContractsService({ orgUnitId, cookie });
56
+
57
+ expect(result.map((c) => c.isAttached)).toEqual([true]);
58
+ });
59
+
60
+ it("falls back to the legacy unit-contracts listing when nothing is attached", async () => {
61
+ listAttachedContractsMock.mockResolvedValue([]);
62
+ getContractsByOrgUnitIdMock.mockResolvedValue([
63
+ contract("legacy-1"),
64
+ contract("legacy-2"),
65
+ ]);
66
+
67
+ const result = await listUnitContractsService({ orgUnitId, cookie });
68
+
69
+ expect(result.map((c) => c.id)).toEqual(["legacy-1", "legacy-2"]);
70
+ expect(getContractsByOrgUnitIdMock).toHaveBeenCalledWith({
71
+ orgUnitId,
72
+ cookie,
73
+ });
74
+ });
75
+
76
+ it("flags legacy fallback contracts as isAttached: false", async () => {
77
+ listAttachedContractsMock.mockResolvedValue([]);
78
+ getContractsByOrgUnitIdMock.mockResolvedValue([contract("legacy-1")]);
79
+
80
+ const result = await listUnitContractsService({ orgUnitId, cookie });
81
+
82
+ expect(result.map((c) => c.isAttached)).toEqual([false]);
83
+ });
84
+
85
+ it("flags the first fallback contract as default when none is flagged", async () => {
86
+ listAttachedContractsMock.mockResolvedValue([]);
87
+ getContractsByOrgUnitIdMock.mockResolvedValue([
88
+ contract("legacy-1"),
89
+ contract("legacy-2"),
90
+ ]);
91
+
92
+ const result = await listUnitContractsService({ orgUnitId, cookie });
93
+
94
+ expect(result.map((c) => c.isDefault)).toEqual([true, false]);
95
+ });
96
+
97
+ it("honors an explicit default in the fallback listing", async () => {
98
+ listAttachedContractsMock.mockResolvedValue([]);
99
+ getContractsByOrgUnitIdMock.mockResolvedValue([
100
+ contract("legacy-1"),
101
+ contract("legacy-2", true),
102
+ ]);
103
+
104
+ const result = await listUnitContractsService({ orgUnitId, cookie });
105
+
106
+ expect(result.map((c) => c.isDefault)).toEqual([false, true]);
107
+ });
108
+
109
+ it("returns an empty list when both listings are empty", async () => {
110
+ listAttachedContractsMock.mockResolvedValue([]);
111
+ getContractsByOrgUnitIdMock.mockResolvedValue([]);
112
+
113
+ const result = await listUnitContractsService({ orgUnitId, cookie });
114
+
115
+ expect(result).toEqual([]);
116
+ });
117
+ });
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../list-unit-contracts.service", () => ({
4
+ listUnitContractsService: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../../../shared/services/logger", () => ({
8
+ logError: vi.fn(),
9
+ }));
10
+
11
+ import { logError } from "../../../shared/services/logger";
12
+ import { listUnitContractsService } from "../list-unit-contracts.service";
13
+ import { resolveAttachedContractService } from "../resolve-attached-contract.service";
14
+
15
+ import type { ContractData } from "../../types";
16
+
17
+ const listUnitContractsMock = vi.mocked(listUnitContractsService);
18
+ const logErrorMock = vi.mocked(logError);
19
+
20
+ const cookie = "VtexIdclientAutCookie=test-token";
21
+ const orgUnitId = "unit-123";
22
+
23
+ function contract(id: string, isDefault = false): ContractData {
24
+ return {
25
+ id,
26
+ name: id,
27
+ email: `${id}@b.com`,
28
+ isActive: true,
29
+ creationDate: "2026-01-01",
30
+ isDefault,
31
+ };
32
+ }
33
+
34
+ describe("resolveAttachedContractService", () => {
35
+ beforeEach(() => {
36
+ listUnitContractsMock.mockReset();
37
+ logErrorMock.mockReset();
38
+ });
39
+
40
+ it("returns the URL's contract when it belongs to the unit", async () => {
41
+ listUnitContractsMock.mockResolvedValue([
42
+ contract("a", true),
43
+ contract("b"),
44
+ ]);
45
+
46
+ const result = await resolveAttachedContractService({
47
+ orgUnitId,
48
+ contractId: "b",
49
+ cookie,
50
+ });
51
+
52
+ expect(result?.id).toBe("b");
53
+ });
54
+
55
+ it("returns the unit's default contract when no contractId is given", async () => {
56
+ listUnitContractsMock.mockResolvedValue([
57
+ contract("a"),
58
+ contract("b", true),
59
+ ]);
60
+
61
+ const result = await resolveAttachedContractService({ orgUnitId, cookie });
62
+
63
+ expect(result?.id).toBe("b");
64
+ });
65
+
66
+ it("falls back to the unit's default when the URL's contract belongs to another unit", async () => {
67
+ listUnitContractsMock.mockResolvedValue([
68
+ contract("a", true),
69
+ contract("b"),
70
+ ]);
71
+
72
+ const result = await resolveAttachedContractService({
73
+ orgUnitId,
74
+ contractId: "foreign-contract",
75
+ cookie,
76
+ });
77
+
78
+ expect(result?.id).toBe("a");
79
+ });
80
+
81
+ it("falls back to the first contract when none is flagged as default", async () => {
82
+ listUnitContractsMock.mockResolvedValue([contract("a"), contract("b")]);
83
+
84
+ const result = await resolveAttachedContractService({ orgUnitId, cookie });
85
+
86
+ expect(result?.id).toBe("a");
87
+ });
88
+
89
+ it("returns null when the unit has no contracts", async () => {
90
+ listUnitContractsMock.mockResolvedValue([]);
91
+
92
+ const result = await resolveAttachedContractService({ orgUnitId, cookie });
93
+
94
+ expect(result).toBeNull();
95
+ });
96
+
97
+ it("returns null when the lookup fails instead of breaking the page", async () => {
98
+ listUnitContractsMock.mockRejectedValue(new Error("BFF unavailable"));
99
+
100
+ const result = await resolveAttachedContractService({ orgUnitId, cookie });
101
+
102
+ expect(result).toBeNull();
103
+ });
104
+
105
+ it("logs the error when the lookup fails", async () => {
106
+ listUnitContractsMock.mockRejectedValue(new Error("BFF unavailable"));
107
+
108
+ await resolveAttachedContractService({ orgUnitId, cookie });
109
+
110
+ expect(logErrorMock).toHaveBeenCalledWith(
111
+ "Failed to resolve attached contract",
112
+ expect.objectContaining({
113
+ orgUnitId,
114
+ error_message: "BFF unavailable",
115
+ })
116
+ );
117
+ });
118
+ });
@@ -12,6 +12,16 @@ export { getContractDetailsService } from "./get-contract-details.service";
12
12
 
13
13
  export { listAttachedContractsService } from "./list-attached-contracts.service";
14
14
 
15
+ export {
16
+ listUnitContractsService,
17
+ type ListUnitContractsServiceProps,
18
+ } from "./list-unit-contracts.service";
19
+
20
+ export {
21
+ resolveAttachedContractService,
22
+ type ResolveAttachedContractServiceProps,
23
+ } from "./resolve-attached-contract.service";
24
+
15
25
  export { listAvailableContractsService } from "./list-available-contracts.service";
16
26
 
17
27
  export {
@@ -0,0 +1,44 @@
1
+ import { getContractsByOrgUnitIdService } from "./get-contracts-org-by-unit-id.service";
2
+ import { listAttachedContractsService } from "./list-attached-contracts.service";
3
+
4
+ import type { ContractData } from "../types";
5
+
6
+ export type ListUnitContractsServiceProps = {
7
+ orgUnitId: string;
8
+ cookie: string;
9
+ };
10
+
11
+ /**
12
+ * Lists the contracts a unit operates under. The SUMA attach listing is the
13
+ * source of truth, but units that predate the attach flow have nothing
14
+ * explicitly attached — for those, fall back to the legacy unit-contracts
15
+ * endpoint (`units/<id>/contracts`), which is what the pre-SUMA portal
16
+ * displayed. Never fall back to the unit's `customerGroup.customerIds`:
17
+ * those mirror the session's customer group, not the unit's own contracts.
18
+ * The first fallback contract is flagged as default when none is, mirroring
19
+ * what listAttachedContractsService does for an unflagged BFF response.
20
+ * Fallback contracts are flagged `isAttached: false` — callers must treat
21
+ * them as read-only (no attach/detach actions), since the BFF has no
22
+ * attach relation to operate on for them.
23
+ */
24
+ export const listUnitContractsService = async ({
25
+ orgUnitId,
26
+ cookie,
27
+ }: ListUnitContractsServiceProps): Promise<ContractData[]> => {
28
+ const attached = await listAttachedContractsService({ orgUnitId, cookie });
29
+
30
+ if (attached?.length) {
31
+ return attached.map((contract) => ({ ...contract, isAttached: true }));
32
+ }
33
+
34
+ const contracts =
35
+ (await getContractsByOrgUnitIdService({ orgUnitId, cookie })) ?? [];
36
+
37
+ const hasExplicitDefault = contracts.some((contract) => contract.isDefault);
38
+
39
+ return contracts.map((contract, index) => ({
40
+ ...contract,
41
+ isDefault: hasExplicitDefault ? Boolean(contract.isDefault) : index === 0,
42
+ isAttached: false,
43
+ }));
44
+ };
@@ -0,0 +1,53 @@
1
+ import { logError } from "../../shared/services/logger";
2
+
3
+ import { listUnitContractsService } from "./list-unit-contracts.service";
4
+
5
+ import type { ContractData } from "../types";
6
+
7
+ export type ResolveAttachedContractServiceProps = {
8
+ orgUnitId: string;
9
+ /** contractId carried in the URL — may be absent or belong to another unit */
10
+ contractId?: string;
11
+ cookie: string;
12
+ };
13
+
14
+ /**
15
+ * Resolves which contract the sidebar of an org-unit-scoped page should link
16
+ * to: the URL's contractId when it belongs to the unit, otherwise the unit's
17
+ * default contract. Contracts come from listUnitContractsService (attached
18
+ * listing, legacy unit contracts as fallback). Returns null when the unit has
19
+ * no resolvable contract (or the lookup fails) — callers must then avoid
20
+ * building contract-scoped URLs, since an empty contractId segment makes the
21
+ * path fall into the home catch-all route and 302 the user to their root org
22
+ * unit.
23
+ */
24
+ export const resolveAttachedContractService = async ({
25
+ orgUnitId,
26
+ contractId,
27
+ cookie,
28
+ }: ResolveAttachedContractServiceProps): Promise<ContractData | null> => {
29
+ let contracts: ContractData[];
30
+
31
+ try {
32
+ contracts = await listUnitContractsService({ orgUnitId, cookie });
33
+ } catch (error) {
34
+ // The contract only feeds sidebar links/identity; the page's own data
35
+ // must still render if this lookup fails. Log it though — otherwise a
36
+ // transient BFF 5xx is indistinguishable from "unit has no contract".
37
+ logError("Failed to resolve attached contract", {
38
+ orgUnitId,
39
+ error_message: error instanceof Error ? error.message : String(error),
40
+ });
41
+ return null;
42
+ }
43
+
44
+ if (!contracts.length) {
45
+ return null;
46
+ }
47
+
48
+ return (
49
+ contracts.find((contract) => contract.id === contractId) ??
50
+ contracts.find((contract) => contract.isDefault) ??
51
+ contracts[0]
52
+ );
53
+ };
@@ -4,6 +4,8 @@ export type ContractData = {
4
4
  email: string;
5
5
  isActive: boolean;
6
6
  isDefault?: boolean;
7
+ /** False when sourced from the legacy unit-contracts fallback (never explicitly attached) — undefined/true means attached. */
8
+ isAttached?: boolean;
7
9
  creationDate: string;
8
10
  salesRepresentative?: string;
9
11
  imageUrl?: string;
@@ -28,6 +28,7 @@ import { useMediaQuery } from "../../../shared/hooks/useMediaQuery";
28
28
  import { GlobalLayout } from "../../../shared/layouts";
29
29
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
30
30
  import {
31
+ contractScopedLink,
31
32
  getContractSettingsLinks,
32
33
  getFinanceSettingsLinks,
33
34
  getOrganizationSettingsLinks,
@@ -127,10 +128,14 @@ export const OrgUnitsDetailsLayout = ({
127
128
  submenu: {
128
129
  items: (listAccountingFieldData ?? []).map((item) => ({
129
130
  name: item.name,
130
- link: buyerPortalRoutes.accountingFields({
131
- ...linkParams,
132
- accountingFieldId: item.id,
133
- }),
131
+ link: contractScopedLink(
132
+ (params) =>
133
+ buyerPortalRoutes.accountingFields({
134
+ ...params,
135
+ accountingFieldId: item.id,
136
+ }),
137
+ linkParams
138
+ ),
134
139
  hasNotification: checkAccountingFieldIsEmpty(item),
135
140
  actions: (
136
141
  <AccountingFieldDropdown
@@ -261,7 +266,7 @@ export const OrgUnitsDetailsLayout = ({
261
266
  <BasicCard
262
267
  data-fs-bp-contracts-settings-card
263
268
  footerMessage={t("orgUnitDetails.links.manageContractSettings")}
264
- footerLink={buyerPortalRoutes.profileDetails({
269
+ footerLink={contractScopedLink(buyerPortalRoutes.profileDetails, {
265
270
  orgUnitId: orgUnit.id,
266
271
  contractId: singleContract?.id ?? "",
267
272
  })}
@@ -345,9 +350,9 @@ export const OrgUnitsDetailsLayout = ({
345
350
  footerMessage={t(
346
351
  "orgUnitDetails.links.manageFinanceAndComplianceSettings"
347
352
  )}
348
- footerLink={buyerPortalRoutes.buyingPolicies({
353
+ footerLink={contractScopedLink(buyerPortalRoutes.buyingPolicies, {
349
354
  orgUnitId: orgUnit.id,
350
- contractId: contracts[0]?.id,
355
+ contractId: contracts[0]?.id ?? "",
351
356
  })}
352
357
  enableFooter
353
358
  >
@@ -1,7 +1,14 @@
1
1
  import { Fragment } from "react";
2
2
 
3
+ import { OrgUnitBreadcrumb } from "../../../org-units/components";
4
+ import {
5
+ MAX_DESKTOP_BREADCRUMB_ITEMS,
6
+ MAX_MOBILE_BREADCRUMB_ITEMS,
7
+ QUERY_TABLET,
8
+ } from "../../../org-units/layouts/OrgUnitDetailsLayout/utils";
3
9
  import { HeaderInside } from "../../../shared/components";
4
10
  import { useBuyerPortal } from "../../../shared/hooks";
11
+ import { useMediaQuery } from "../../../shared/hooks/useMediaQuery";
5
12
  import { GlobalLayout, ContractTabsLayout } from "../../../shared/layouts";
6
13
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
7
14
 
@@ -15,7 +22,12 @@ export type ProfileLayoutProps = {
15
22
 
16
23
  export const ProfileLayout = ({ data }: ProfileLayoutProps) => {
17
24
  const { t } = useLocalization();
18
- const { currentOrgUnit: orgUnit } = useBuyerPortal();
25
+ const {
26
+ currentOrgUnit: orgUnit,
27
+ currentUser,
28
+ featureFlags,
29
+ } = useBuyerPortal();
30
+ const isMobile = useMediaQuery(QUERY_TABLET);
19
31
 
20
32
  const rows = getProfileDetailRows(data);
21
33
 
@@ -29,6 +41,23 @@ export const ProfileLayout = ({ data }: ProfileLayoutProps) => {
29
41
  pageName={t("shared.labels.contract")}
30
42
  >
31
43
  <section data-fs-bp-profile>
44
+ {/* Under SUMA this page is the unit's landing page, so identify
45
+ which unit the user is in — a child's profile is otherwise
46
+ indistinguishable from the root's when they share contracts. */}
47
+ {featureFlags?.suma && orgUnit && (
48
+ <HeaderInside data-fs-bp-profile-breadcrumb>
49
+ <OrgUnitBreadcrumb
50
+ userId={currentUser?.id ?? ""}
51
+ userRoles={currentUser?.roles ?? []}
52
+ orgUnit={orgUnit}
53
+ maxItems={
54
+ isMobile
55
+ ? MAX_MOBILE_BREADCRUMB_ITEMS
56
+ : MAX_DESKTOP_BREADCRUMB_ITEMS
57
+ }
58
+ />
59
+ </HeaderInside>
60
+ )}
32
61
  <HeaderInside title={t("profile.titles.profile")} />
33
62
 
34
63
  {data && (
@@ -10,6 +10,7 @@ import { Icon, VerticalNav } from "../../components";
10
10
  import { useBuyerPortal, useDrawerProps } from "../../hooks";
11
11
  import { useLocalization } from "../../localization/LocalizationContext";
12
12
  import {
13
+ contractScopedLink,
13
14
  getContractSettingsLinks,
14
15
  getFinanceSettingsLinks,
15
16
  getOrganizationSettingsLinks,
@@ -82,10 +83,14 @@ export const FullSidebarNav = ({
82
83
  submenu: {
83
84
  items: (listAccountingFieldData ?? []).map((item) => ({
84
85
  name: item.name,
85
- link: buyerPortalRoutes.accountingFields({
86
- ...linkParams,
87
- accountingFieldId: item.id,
88
- }),
86
+ link: contractScopedLink(
87
+ (params) =>
88
+ buyerPortalRoutes.accountingFields({
89
+ ...params,
90
+ accountingFieldId: item.id,
91
+ }),
92
+ linkParams
93
+ ),
89
94
  hasNotification: checkAccountingFieldIsEmpty(item),
90
95
  actions: (
91
96
  <AccountingFieldDropdown
@@ -23,12 +23,18 @@ export const SumaSidebar = ({
23
23
  showBackLink = false,
24
24
  loading = false,
25
25
  }: SumaSidebarProps) => {
26
- const { featureFlags, currentContract } = useBuyerPortal();
26
+ const { featureFlags, currentContract, currentOrgUnit } = useBuyerPortal();
27
27
  const { t } = useLocalization();
28
28
  const contractsUrl = buyerPortalRoutes.contracts({ orgUnitId });
29
29
 
30
+ // Identify WHERE the user is (the org unit), not just which contract they
31
+ // operate under — a child unit sharing its contracts with the root org is
32
+ // otherwise indistinguishable from it. The contract becomes a secondary
33
+ // line; contract-only identity is kept for pages with no unit context.
30
34
  const contractName = currentContract?.name ?? currentContract?.email ?? "";
31
- const showIdentity = variant === "full" && !!contractName;
35
+ const unitName = currentOrgUnit?.name ?? "";
36
+ const identityName = unitName || contractName;
37
+ const showIdentity = variant === "full" && !!identityName;
32
38
 
33
39
  return (
34
40
  <aside
@@ -48,10 +54,10 @@ export const SumaSidebar = ({
48
54
 
49
55
  {showIdentity && (
50
56
  <div data-fs-bp-suma-sidebar-identity>
51
- <LetterHighlight size="large" letter={contractName} />
57
+ <LetterHighlight size="large" letter={identityName} />
52
58
  <div data-fs-bp-suma-sidebar-identity-row>
53
- <span data-fs-bp-suma-sidebar-identity-name>{contractName}</span>
54
- {currentContract?.isDefault && (
59
+ <span data-fs-bp-suma-sidebar-identity-name>{identityName}</span>
60
+ {!unitName && currentContract?.isDefault && (
55
61
  <Icon
56
62
  name="Default"
57
63
  width={20}
@@ -61,6 +67,22 @@ export const SumaSidebar = ({
61
67
  />
62
68
  )}
63
69
  </div>
70
+ {unitName && contractName && (
71
+ <div data-fs-bp-suma-sidebar-identity-row>
72
+ <span data-fs-bp-suma-sidebar-identity-contract>
73
+ {contractName}
74
+ </span>
75
+ {currentContract?.isDefault && (
76
+ <Icon
77
+ name="Default"
78
+ width={16}
79
+ height={16}
80
+ data-fs-bp-suma-sidebar-identity-default
81
+ aria-label="Default contract"
82
+ />
83
+ )}
84
+ </div>
85
+ )}
64
86
  </div>
65
87
  )}
66
88
 
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buyerPortalRoutes } from "../buyerPortalRoutes";
4
+ import { contractScopedLink } from "../contractScopedLink";
5
+
6
+ describe("contractScopedLink", () => {
7
+ it("builds the contract-scoped URL when a contractId is known", () => {
8
+ expect(
9
+ contractScopedLink(buyerPortalRoutes.addresses, {
10
+ orgUnitId: "unit-1",
11
+ contractId: "contract-1",
12
+ })
13
+ ).toBe("/pvt/organization-account/addresses/unit-1/contract-1");
14
+ });
15
+
16
+ it("falls back to the unit's contracts listing when contractId is empty", () => {
17
+ // An empty contractId would render `/addresses/unit-1/`, which the home
18
+ // catch-all route captures and 302s to the user's root org unit.
19
+ expect(
20
+ contractScopedLink(buyerPortalRoutes.addresses, {
21
+ orgUnitId: "unit-1",
22
+ contractId: "",
23
+ })
24
+ ).toBe("/pvt/organization-account/contracts/unit-1");
25
+ });
26
+
27
+ it("supports builders with extra params via a wrapping arrow", () => {
28
+ expect(
29
+ contractScopedLink(
30
+ (params) =>
31
+ buyerPortalRoutes.accountingFields({
32
+ ...params,
33
+ accountingFieldId: "field-1",
34
+ }),
35
+ { orgUnitId: "unit-1", contractId: "" }
36
+ )
37
+ ).toBe("/pvt/organization-account/contracts/unit-1");
38
+ });
39
+ });
@@ -11,13 +11,22 @@ const orgUnit = {
11
11
  } as OrgUnitBasicData;
12
12
 
13
13
  describe("resolveContractId", () => {
14
- it("prefers the URL's contractId over context", () => {
14
+ it("prefers the loader-validated currentContract over the URL's contractId", () => {
15
15
  expect(
16
16
  resolveContractId({
17
17
  queryContractId: "contract-c",
18
18
  currentContract: contract,
19
19
  currentOrgUnit: orgUnit,
20
20
  })
21
+ ).toBe("contract-b");
22
+ });
23
+
24
+ it("uses the URL's contractId when no currentContract is resolved", () => {
25
+ expect(
26
+ resolveContractId({
27
+ queryContractId: "contract-c",
28
+ currentOrgUnit: orgUnit,
29
+ })
21
30
  ).toBe("contract-c");
22
31
  });
23
32
 
@@ -25,7 +34,6 @@ describe("resolveContractId", () => {
25
34
  expect(
26
35
  resolveContractId({
27
36
  queryContractId: ["contract-c", "contract-d"],
28
- currentContract: contract,
29
37
  currentOrgUnit: orgUnit,
30
38
  })
31
39
  ).toBe("contract-c");
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "2.0.26";
25
+ export const CURRENT_VERSION = "2.0.27";
@@ -0,0 +1,16 @@
1
+ import { buyerPortalRoutes } from "./buyerPortalRoutes";
2
+
3
+ /**
4
+ * Builds a contract-scoped URL, falling back to the unit's contracts listing
5
+ * when no contractId is known. An empty contractId would otherwise produce a
6
+ * URL with an empty segment, which the home catch-all route
7
+ * (`/pvt/organization-account/[[...contractMode]]`) captures and 302s to the
8
+ * user's root org unit — bouncing them out of the unit they were browsing.
9
+ */
10
+ export const contractScopedLink = (
11
+ buildLink: (params: { orgUnitId: string; contractId: string }) => string,
12
+ { orgUnitId, contractId }: { orgUnitId: string; contractId: string }
13
+ ) =>
14
+ contractId
15
+ ? buildLink({ orgUnitId, contractId })
16
+ : buyerPortalRoutes.contracts({ orgUnitId });
@@ -1,12 +1,10 @@
1
1
  import { buyerPortalRoutes } from "./buyerPortalRoutes";
2
+ import { contractScopedLink } from "./contractScopedLink";
2
3
 
3
4
  import type { LocalizeFn } from "../localization/types";
4
5
 
5
6
  export const getContractSettingsLinks = (
6
- {
7
- orgUnitId,
8
- contractId,
9
- }: {
7
+ linkParams: {
10
8
  orgUnitId: string;
11
9
  contractId: string;
12
10
  },
@@ -14,22 +12,22 @@ export const getContractSettingsLinks = (
14
12
  ) => [
15
13
  {
16
14
  name: t("layouts.navigation.profile"),
17
- link: buyerPortalRoutes.profileDetails({ orgUnitId, contractId }),
15
+ link: contractScopedLink(buyerPortalRoutes.profileDetails, linkParams),
18
16
  },
19
17
  {
20
18
  name: t("layouts.navigation.addresses"),
21
- link: buyerPortalRoutes.addresses({ orgUnitId, contractId }),
19
+ link: contractScopedLink(buyerPortalRoutes.addresses, linkParams),
22
20
  },
23
21
  {
24
22
  name: t("layouts.navigation.paymentMethods"),
25
- link: buyerPortalRoutes.paymentMethods({ orgUnitId, contractId }),
23
+ link: contractScopedLink(buyerPortalRoutes.paymentMethods, linkParams),
26
24
  },
27
25
  {
28
26
  name: t("layouts.navigation.creditCards"),
29
- link: buyerPortalRoutes.creditCards({ orgUnitId, contractId }),
27
+ link: contractScopedLink(buyerPortalRoutes.creditCards, linkParams),
30
28
  },
31
29
  {
32
30
  name: t("layouts.navigation.assortments"),
33
- link: buyerPortalRoutes.productAssortment({ orgUnitId, contractId }),
31
+ link: contractScopedLink(buyerPortalRoutes.productAssortment, linkParams),
34
32
  },
35
33
  ];
@@ -1,12 +1,10 @@
1
1
  import { buyerPortalRoutes } from "./buyerPortalRoutes";
2
+ import { contractScopedLink } from "./contractScopedLink";
2
3
 
3
4
  import type { LocalizeFn } from "../localization/types";
4
5
 
5
6
  export const getFinanceSettingsLinks = (
6
- {
7
- orgUnitId,
8
- contractId,
9
- }: {
7
+ linkParams: {
10
8
  orgUnitId: string;
11
9
  contractId: string;
12
10
  },
@@ -14,10 +12,10 @@ export const getFinanceSettingsLinks = (
14
12
  ) => [
15
13
  {
16
14
  name: t("layouts.navigation.budgets"),
17
- link: buyerPortalRoutes.budgets({ orgUnitId, contractId }),
15
+ link: contractScopedLink(buyerPortalRoutes.budgets, linkParams),
18
16
  },
19
17
  {
20
18
  name: t("layouts.navigation.buyingPolicies"),
21
- link: buyerPortalRoutes.buyingPolicies({ orgUnitId, contractId }),
19
+ link: contractScopedLink(buyerPortalRoutes.buyingPolicies, linkParams),
22
20
  },
23
21
  ];
@@ -21,6 +21,7 @@ export {
21
21
  export { maskCardNumber, maskCVV, maskExpirationDate } from "./creditCard";
22
22
  export { formatCurrency } from "./formatCurrency";
23
23
  export { getClientContext, type ClientContext } from "./getClientContext";
24
+ export { contractScopedLink } from "./contractScopedLink";
24
25
  export { getContractSettingsLinks } from "./getContractSettingsLinks";
25
26
  export { getFinanceSettingsLinks } from "./getFinanceSettingsLinks";
26
27
  export {
@@ -9,8 +9,11 @@ export type ResolveContractIdParams = {
9
9
 
10
10
  /**
11
11
  * Single source of truth for which contract the sidebar should link to.
12
- * The URL's `contractId` (when present) always wins; `customerIds[0]` is a
13
- * last resort for direct/bookmarked visits where nothing else is known.
12
+ * `currentContract` wins when the loader resolved one loaders validate it
13
+ * against the unit's attached contracts (honoring the URL's `contractId`
14
+ * when attached), so it is more trustworthy than the raw URL param, which
15
+ * may carry a contract from another org unit. `customerIds[0]` is a last
16
+ * resort for flows whose loaders don't resolve a contract.
14
17
  */
15
18
  export const resolveContractId = ({
16
19
  queryContractId,
@@ -22,8 +25,8 @@ export const resolveContractId = ({
22
25
  : queryContractId;
23
26
 
24
27
  return (
25
- fromQuery ||
26
28
  currentContract?.id ||
29
+ fromQuery ||
27
30
  currentOrgUnit?.customerGroup.customerIds[0] ||
28
31
  ""
29
32
  );
@@ -1,5 +1,5 @@
1
1
  import { ContractListingLayout } from "../features/contracts/layouts/ContractListingLayout/ContractListingLayout";
2
- import { listAttachedContractsService } from "../features/contracts/services";
2
+ import { listUnitContractsService } from "../features/contracts/services";
3
3
  import { getOrgUnitBasicDataService } from "../features/org-units/services";
4
4
  import { withErrorBoundary } from "../features/shared/components";
5
5
  import { ErrorTabsLayout } from "../features/shared/layouts/ErrorTabsLayout/ErrorTabsLayout";
@@ -65,7 +65,7 @@ const loaderFunction = async (
65
65
 
66
66
  const [orgUnit, contracts, user, localizationLabels] = await Promise.all([
67
67
  getOrgUnitBasicDataService({ id: orgUnitId, cookie }),
68
- listAttachedContractsService({ orgUnitId, cookie }),
68
+ listUnitContractsService({ orgUnitId, cookie }),
69
69
  getUserByIdService({ orgUnitId, userId, cookie }),
70
70
  getLocalizationLabelsService({
71
71
  entryIds: [
@@ -1,4 +1,7 @@
1
- import { getContractsByOrgUnitIdService } from "../features/contracts/services";
1
+ import {
2
+ getContractsByOrgUnitIdService,
3
+ resolveAttachedContractService,
4
+ } from "../features/contracts/services";
2
5
  import { OrgUnitsDetailsLayout } from "../features/org-units/layouts";
3
6
  import {
4
7
  getOrgUnitBasicDataService,
@@ -71,7 +74,24 @@ const loaderFunction = async (
71
74
  const featureFlags = await getBuyerPortalFeatureFlags(cookie);
72
75
 
73
76
  if (featureFlags?.suma) {
74
- const location = buyerPortalRoutes.contracts({ orgUnitId, contractId });
77
+ // Under SUMA the unit's landing page is its Contract Information
78
+ // (profile) — the SUMA counterpart of this details page, with the full
79
+ // sidebar (Addresses, Payment methods, …). Resolve which contract that
80
+ // is for THIS unit instead of trusting the URL's contractId, which may
81
+ // carry the unit the user navigated from. Units with no resolvable
82
+ // contract land on their contracts listing, where admins can attach one.
83
+ const contract = await resolveAttachedContractService({
84
+ orgUnitId,
85
+ contractId,
86
+ cookie,
87
+ });
88
+
89
+ const location = contract
90
+ ? buyerPortalRoutes.profileDetails({
91
+ orgUnitId,
92
+ contractId: contract.id,
93
+ })
94
+ : buyerPortalRoutes.contracts({ orgUnitId });
75
95
 
76
96
  data.res?.writeHead(302, { Location: location });
77
97
  data.res?.end();
@@ -1,4 +1,7 @@
1
- import { getContractDetailsService } from "../features/contracts/services";
1
+ import {
2
+ getContractDetailsService,
3
+ resolveAttachedContractService,
4
+ } from "../features/contracts/services";
2
5
  import { OrgUnitsLayout } from "../features/org-units/layouts";
3
6
  import {
4
7
  getChildrenOrgUnitsService,
@@ -55,30 +58,40 @@ const loaderFunction = async (
55
58
 
56
59
  return withAuthLoader(
57
60
  data,
58
- async ({ cookie, customerId, userId, ...restClientContext }) => {
59
- const [currentOrgUnit, user, localizationLabels] = await Promise.all([
60
- getOrgUnitBasicDataService({
61
- id: data.query.orgUnitId,
62
- cookie,
63
- }),
64
- getUserByIdService({ orgUnitId, userId, cookie }),
65
- getLocalizationLabelsService({
66
- entryIds: LOCALIZATION_ENTRY_IDS.orgUnits,
67
- locale: data.locale,
68
- }),
69
- ]);
70
-
61
+ async ({
62
+ cookie,
63
+ customerId,
64
+ userId,
65
+ featureFlagsPromise,
66
+ ...restClientContext
67
+ }) => {
71
68
  // Organizational Units don't need contract data, but the sidebar
72
- // (rendered by shared layouts) uses `currentContract` for its identity
73
- // block, so fetch it when the URL carries the contract the user was
74
- // last viewing.
75
- const currentContract = contractId
76
- ? await getContractDetailsService({
77
- contractId,
78
- unitId: orgUnitId,
69
+ // (rendered by shared layouts) builds contract-scoped links and its
70
+ // identity block from `currentContract`. Under SUMA, resolve it
71
+ // against the unit's attached contracts (URL contractId when
72
+ // attached, else the unit's default) so those links never carry a
73
+ // foreign or empty id. With the flag off, keep the pre-SUMA behavior
74
+ // byte-for-byte: fetch only when the URL already carries a contractId.
75
+ const featureFlags = await featureFlagsPromise;
76
+ const contractDetailsPromise = featureFlags?.suma
77
+ ? resolveAttachedContractService({ orgUnitId, contractId, cookie })
78
+ : contractId
79
+ ? getContractDetailsService({ contractId, unitId: orgUnitId, cookie })
80
+ : Promise.resolve(null);
81
+
82
+ const [currentOrgUnit, user, localizationLabels, currentContract] =
83
+ await Promise.all([
84
+ getOrgUnitBasicDataService({
85
+ id: data.query.orgUnitId,
79
86
  cookie,
80
- })
81
- : null;
87
+ }),
88
+ getUserByIdService({ orgUnitId, userId, cookie }),
89
+ getLocalizationLabelsService({
90
+ entryIds: LOCALIZATION_ENTRY_IDS.orgUnits,
91
+ locale: data.locale,
92
+ }),
93
+ contractDetailsPromise,
94
+ ]);
82
95
 
83
96
  const clientContext = {
84
97
  cookie,
@@ -1,4 +1,7 @@
1
- import { getContractDetailsService } from "../features/contracts/services";
1
+ import {
2
+ getContractDetailsService,
3
+ resolveAttachedContractService,
4
+ } from "../features/contracts/services";
2
5
  import { getOrgUnitBasicDataService } from "../features/org-units/services";
3
6
  import { RolesLayout } from "../features/roles/layout";
4
7
  import { getRolesAndPermissionsService } from "../features/roles/services";
@@ -46,51 +49,65 @@ const loaderFunction = async (
46
49
  ): Promise<AuthRouteProps<RolesPageData>> => {
47
50
  const { orgUnitId, contractId } = data.query;
48
51
 
49
- return withAuthLoader(data, async ({ cookie, userId, ...clientContext }) => {
50
- // Roles don't need contract data, but the sidebar (rendered by shared
51
- // layouts) uses `currentContract` for its identity block, so fetch it
52
- // when the URL carries the contract the user was last viewing.
53
- const contractDetailsPromise = contractId
54
- ? getContractDetailsService({ contractId, unitId: orgUnitId, cookie })
55
- : Promise.resolve(null);
56
-
57
- const [
58
- rolesAndPermissions,
59
- currentOrgUnit,
60
- user,
61
- currentContract,
62
- localizationLabels,
63
- ] = await Promise.all([
64
- getRolesAndPermissionsService({
65
- unitId: orgUnitId,
66
- customerId: clientContext.customerId,
67
- cookie,
68
- }),
69
- getOrgUnitBasicDataService({
70
- id: orgUnitId,
71
- cookie,
72
- }),
73
- getUserByIdService({ orgUnitId, userId, cookie }),
74
- contractDetailsPromise,
75
- getLocalizationLabelsService({
76
- entryIds: LOCALIZATION_ENTRY_IDS.roles,
77
- locale: data.locale,
78
- }),
79
- ]);
52
+ return withAuthLoader(
53
+ data,
54
+ async ({ cookie, userId, featureFlagsPromise, ...clientContext }) => {
55
+ // Roles don't need contract data, but the sidebar (rendered by shared
56
+ // layouts) builds contract-scoped links and its identity block from
57
+ // `currentContract`. Under SUMA, resolve it against the unit's
58
+ // attached contracts (URL contractId when attached, else the unit's
59
+ // default) so those links never carry a foreign or empty id. With the
60
+ // flag off, keep the pre-SUMA behavior byte-for-byte: fetch only when
61
+ // the URL already carries a contractId.
62
+ const featureFlags = await featureFlagsPromise;
63
+ const contractDetailsPromise = featureFlags?.suma
64
+ ? resolveAttachedContractService({
65
+ orgUnitId,
66
+ contractId,
67
+ cookie,
68
+ })
69
+ : contractId
70
+ ? getContractDetailsService({ contractId, unitId: orgUnitId, cookie })
71
+ : Promise.resolve(null);
80
72
 
81
- return {
82
- data: {
73
+ const [
83
74
  rolesAndPermissions,
84
- },
85
- context: {
86
- clientContext: { cookie, userId, ...clientContext },
87
75
  currentOrgUnit,
76
+ user,
88
77
  currentContract,
89
- currentUser: user,
90
- localization: { labels: localizationLabels },
91
- },
92
- };
93
- });
78
+ localizationLabels,
79
+ ] = await Promise.all([
80
+ getRolesAndPermissionsService({
81
+ unitId: orgUnitId,
82
+ customerId: clientContext.customerId,
83
+ cookie,
84
+ }),
85
+ getOrgUnitBasicDataService({
86
+ id: orgUnitId,
87
+ cookie,
88
+ }),
89
+ getUserByIdService({ orgUnitId, userId, cookie }),
90
+ contractDetailsPromise,
91
+ getLocalizationLabelsService({
92
+ entryIds: LOCALIZATION_ENTRY_IDS.roles,
93
+ locale: data.locale,
94
+ }),
95
+ ]);
96
+
97
+ return {
98
+ data: {
99
+ rolesAndPermissions,
100
+ },
101
+ context: {
102
+ clientContext: { cookie, userId, ...clientContext },
103
+ currentOrgUnit,
104
+ currentContract,
105
+ currentUser: user,
106
+ localization: { labels: localizationLabels },
107
+ },
108
+ };
109
+ }
110
+ );
94
111
  };
95
112
 
96
113
  export const loader = withLoaderErrorBoundary(loaderFunction, {
@@ -1,4 +1,7 @@
1
- import { getContractDetailsService } from "../features/contracts/services";
1
+ import {
2
+ getContractDetailsService,
3
+ resolveAttachedContractService,
4
+ } from "../features/contracts/services";
2
5
  import { getOrgUnitBasicDataService } from "../features/org-units/services";
3
6
  import { getRolesIdsService } from "../features/roles/services";
4
7
  import { RoleData } from "../features/roles/types";
@@ -63,7 +66,13 @@ const loaderFunction = async (
63
66
 
64
67
  return withAuthLoader(
65
68
  data,
66
- async ({ cookie, userId, customerId, ...clientContext }) => {
69
+ async ({
70
+ cookie,
71
+ userId,
72
+ customerId,
73
+ featureFlagsPromise,
74
+ ...clientContext
75
+ }) => {
67
76
  const validPage = getValidPage(page);
68
77
 
69
78
  const localizationLabels = await getLocalizationLabelsService({
@@ -92,9 +101,20 @@ const loaderFunction = async (
92
101
  }
93
102
 
94
103
  // Users don't need contract data, but the sidebar (rendered by shared
95
- // layouts) uses `currentContract` for its identity block, so fetch it
96
- // when the URL carries the contract the user was last viewing.
97
- const contractDetailsPromise = contractId
104
+ // layouts) builds contract-scoped links and its identity block from
105
+ // `currentContract`. Under SUMA, resolve it against the unit's
106
+ // attached contracts (URL contractId when attached, else the unit's
107
+ // default) so those links never carry a foreign or empty id. With the
108
+ // flag off, keep the pre-SUMA behavior byte-for-byte: fetch only when
109
+ // the URL already carries a contractId.
110
+ const featureFlags = await featureFlagsPromise;
111
+ const contractDetailsPromise = featureFlags?.suma
112
+ ? resolveAttachedContractService({
113
+ orgUnitId,
114
+ contractId,
115
+ cookie,
116
+ })
117
+ : contractId
98
118
  ? getContractDetailsService({
99
119
  contractId,
100
120
  unitId: orgUnitId,