@redacto.io/consent-sdk-react 9.5.0-beta.0 → 10.0.0-beta.1

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 (66) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +16 -2
  3. package/dist/index.d.mts +13 -4
  4. package/dist/index.d.ts +13 -4
  5. package/dist/index.js +116 -11
  6. package/dist/index.mjs +116 -11
  7. package/dist/privacy-center.d.mts +76 -19
  8. package/dist/privacy-center.d.ts +76 -19
  9. package/dist/privacy-center.js +2022 -1378
  10. package/dist/privacy-center.mjs +1946 -1297
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +5 -0
  13. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +29 -3
  14. package/src/RedactoNoticeConsent/api/index.ts +130 -1
  15. package/src/RedactoNoticeConsent/api/types.ts +33 -0
  16. package/src/RedactoNoticeConsent/types.ts +1 -0
  17. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +3 -0
  18. package/src/RedactoNoticeConsentInline/api/index.ts +4 -1
  19. package/src/RedactoNoticeConsentInline/api/types.ts +1 -0
  20. package/src/RedactoNoticeConsentInline/types.ts +1 -0
  21. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  22. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +3 -1
  23. package/src/RedactoPrivacyCenter/api/actions.ts +15 -2
  24. package/src/RedactoPrivacyCenter/api/client.ts +146 -20
  25. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +64 -35
  26. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +12 -1
  27. package/src/RedactoPrivacyCenter/components/Form.tsx +63 -7
  28. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +83 -0
  29. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +193 -0
  30. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  31. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +64 -1
  32. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +8 -2
  33. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  34. package/src/RedactoPrivacyCenter/context/types.ts +1 -0
  35. package/src/RedactoPrivacyCenter/lib/api.ts +8 -0
  36. package/src/RedactoPrivacyCenter/lib/constants.ts +9 -0
  37. package/src/RedactoPrivacyCenter/lib/types.ts +63 -1
  38. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  39. package/src/RedactoPrivacyCenter/locales/bho/translation.json +7 -1
  40. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  41. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  42. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  43. package/src/RedactoPrivacyCenter/locales/en/translation.json +7 -1
  44. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  45. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  46. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  47. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  48. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  49. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  50. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  51. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  52. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  53. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  54. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  55. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  56. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  58. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  59. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  60. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  61. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  62. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +105 -0
  63. package/tests/Form.test.tsx +58 -7
  64. package/tests/consentClient.test.ts +171 -0
  65. package/tests/manageConsentServer.test.ts +38 -0
  66. package/tests/mocks.ts +1 -0
@@ -2,11 +2,13 @@ import { apiFetch, ApiError } from "./fetcher";
2
2
  import type { ApiFetchOptions, ApiResponse } from "./fetcher";
3
3
  import type {
4
4
  CaseMessage,
5
+ IdentityListResponse,
5
6
  MessageDocument,
6
7
  NotificationActionResponse,
7
8
  NotificationListResponse,
8
9
  NotificationQueryParams,
9
10
  NotificationSummary,
11
+ SelectPrincipalResponse,
10
12
  UnreadCountResponse,
11
13
  UploadDocumentResponse,
12
14
  } from "../lib/types";
@@ -23,9 +25,16 @@ export type BaseRequest = {
23
25
  /**
24
26
  * Consent service client factory.
25
27
  * Creates a client instance with a specific base URL.
26
- * @param baseUrl - Consent server base URL
28
+ * @param baseUrl - Consent server base URL (Python service, e.g. http://host/consent)
29
+ * @param ledgerBaseUrl - Go ledger base URL (e.g. http://host:8090/consent-ledger).
30
+ * Used for user-consents reads/writes served from the ledger's `/public/...` paths.
31
+ * When omitted, those calls fall back to the legacy Python
32
+ * `/dsar/privacy-center` endpoints and payloads (pre-ledger behavior), so
33
+ * integrations without a deployed ledger keep working.
27
34
  */
28
- export function createConsentClient(baseUrl: string) {
35
+ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
36
+ const useLedger = Boolean(ledgerBaseUrl);
37
+ const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
29
38
  /**
30
39
  * Privacy Center API fetch wrapper that uses Authorization: Bearer header.
31
40
  * Privacy center endpoints require JWT tokens in Authorization header, not X-Auth-Token.
@@ -170,6 +179,13 @@ export function createConsentClient(baseUrl: string) {
170
179
  return `${baseUrl}/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}/dsar/privacy-center${path}`;
171
180
  }
172
181
 
182
+ function ledgerUrl(baseRequest: BaseRequest, path: string): string {
183
+ if (!useLedger) {
184
+ throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
185
+ }
186
+ return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
187
+ }
188
+
173
189
  function caseUrl(baseRequest: BaseRequest, caseUuid: string, path = ''): string {
174
190
  return privacyCenterUrl(baseRequest, `/cases/${caseUuid}${path}`);
175
191
  }
@@ -373,7 +389,10 @@ export function createConsentClient(baseUrl: string) {
373
389
  if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
374
390
  if (params.language) queryParams.append("language", params.language);
375
391
  const queryString = queryParams.toString();
376
- const url = privacyCenterUrl(baseRequest, `/user-consents${queryString ? `?${queryString}` : ""}`);
392
+ const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
393
+ const url = useLedger
394
+ ? ledgerUrl(baseRequest, suffix)
395
+ : privacyCenterUrl(baseRequest, suffix);
377
396
  return privacyCenterApiFetch(url, {
378
397
  ...options,
379
398
  method: "GET",
@@ -408,10 +427,10 @@ export function createConsentClient(baseUrl: string) {
408
427
  if (params.language) queryParams.append("language", params.language);
409
428
  if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
410
429
  const queryString = queryParams.toString();
411
- const url = privacyCenterUrl(
412
- baseRequest,
413
- `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`
414
- );
430
+ const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
431
+ const url = useLedger
432
+ ? ledgerUrl(baseRequest, suffix)
433
+ : privacyCenterUrl(baseRequest, suffix);
415
434
  return privacyCenterApiFetch(url, {
416
435
  ...options,
417
436
  method: "GET",
@@ -421,30 +440,88 @@ export function createConsentClient(baseUrl: string) {
421
440
  },
422
441
 
423
442
  /**
424
- * Manage consent (privacy center) - revoke, regrant, or renew
443
+ * Manage consent (privacy center) - revoke, regrant, or renew.
444
+ *
445
+ * The Go ledger has no manage-consent route, so with a ledger configured
446
+ * this is expressed as a `partial` submit-consent on the ledger — mirroring
447
+ * the Python dual-write relay (`build_consent_management_payload`): the
448
+ * affected purpose is re-submitted with `selected: false` to revoke or
449
+ * `selected: true` to regrant/renew, and `partial: true` so the rest of the
450
+ * notice's purposes are left untouched. The principal is resolved from the
451
+ * JWT, so no `contact`/identifiers are sent in the body, and
452
+ * `consent_event_uuid` is omitted so the ledger mints a fresh event.
453
+ *
454
+ * Without a ledger, the legacy Python `/manage-consent?status=` endpoint is
455
+ * used with its `{ purpose_uuid, contact, ... }` payload.
425
456
  */
426
457
  async manageConsent(
427
458
  baseRequest: BaseRequest,
428
459
  purposeId: string,
460
+ // Sent to the legacy Python endpoint only; the ledger resolves the
461
+ // principal from the JWT, so no identifiers are sent in ledger mode.
429
462
  contact: string,
430
463
  status: 'revoke' | 'regrant' | 'renew',
431
464
  authToken?: string,
432
465
  options?: Omit<ApiFetchOptions, "getAuthToken">,
433
466
  nominatorContact?: string,
434
467
  dataElementUuids?: string[],
435
- productUuid?: string
468
+ productUuid?: string,
469
+ noticeUuid?: string,
470
+ dataElements?: Array<{ uuid: string; selected: boolean }>
436
471
  ): Promise<unknown> {
437
- const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
438
- const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
439
- if (nominatorContact) {
440
- body.nominator_contact = nominatorContact;
441
- }
442
- if (Array.isArray(dataElementUuids) && dataElementUuids.length > 0) {
443
- body.data_element_uuids = dataElementUuids;
472
+ if (!useLedger) {
473
+ const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
474
+ const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
475
+ if (nominatorContact) {
476
+ body.nominator_contact = nominatorContact;
477
+ }
478
+ if (Array.isArray(dataElementUuids) && dataElementUuids.length > 0) {
479
+ body.data_element_uuids = dataElementUuids;
480
+ }
481
+ if (productUuid) {
482
+ body.product_uuid = productUuid;
483
+ }
484
+ return privacyCenterApiFetch(url, {
485
+ ...options,
486
+ method: "POST",
487
+ body: JSON.stringify(body),
488
+ authToken: resolveAuthToken(authToken, baseRequest),
489
+ });
444
490
  }
445
- if (productUuid) {
446
- body.product_uuid = productUuid;
491
+ if (!noticeUuid) {
492
+ throw new Error("notice_uuid is required to manage consent via the ledger");
447
493
  }
494
+ const purposeSelected = status !== 'revoke';
495
+ const elements = (dataElements ?? []).map((de) => {
496
+ let selected: boolean;
497
+ if (status === 'revoke') {
498
+ selected = false;
499
+ } else if (status === 'regrant') {
500
+ // Only the data elements the user chose to re-grant become selected.
501
+ selected = Array.isArray(dataElementUuids)
502
+ ? dataElementUuids.includes(de.uuid)
503
+ : de.selected;
504
+ } else {
505
+ // renew — preserve the currently-granted set.
506
+ selected = de.selected;
507
+ }
508
+ return { uuid: de.uuid, selected };
509
+ });
510
+ const body = {
511
+ notice_uuid: noticeUuid,
512
+ partial: true,
513
+ declined: false,
514
+ select_all_mandatory: false,
515
+ self_declared_adult: false,
516
+ purposes: [
517
+ {
518
+ purpose_uuid: purposeId,
519
+ selected: purposeSelected,
520
+ data_elements: elements,
521
+ },
522
+ ],
523
+ };
524
+ const url = ledgerUrl(baseRequest, "/submit-consent");
448
525
  return privacyCenterApiFetch(url, {
449
526
  ...options,
450
527
  method: "POST",
@@ -476,6 +553,44 @@ export function createConsentClient(baseUrl: string) {
476
553
  });
477
554
  },
478
555
 
556
+ /**
557
+ * List all data principals that share this session's contact
558
+ * (shared-contact profile picker). Identity is derived server-side from the
559
+ * JWT — the caller sends nothing but its bearer token.
560
+ */
561
+ async listIdentities(
562
+ baseRequest: BaseRequest,
563
+ authToken?: string,
564
+ options?: Omit<ApiFetchOptions, "getAuthToken">
565
+ ): Promise<IdentityListResponse> {
566
+ const url = privacyCenterUrl(baseRequest, '/identities');
567
+ return privacyCenterApiFetch<IdentityListResponse>(url, {
568
+ ...options,
569
+ cache: 'no-store',
570
+ authToken: resolveAuthToken(authToken, baseRequest),
571
+ });
572
+ },
573
+
574
+ /**
575
+ * Re-scope the session to a chosen principal uuid (must be one of the
576
+ * principals sharing this session's contact). Returns fresh access/refresh
577
+ * tokens scoped to that principal.
578
+ */
579
+ async selectPrincipal(
580
+ baseRequest: BaseRequest,
581
+ principalUuid: string,
582
+ authToken?: string,
583
+ options?: Omit<ApiFetchOptions, "getAuthToken">
584
+ ): Promise<SelectPrincipalResponse> {
585
+ const url = privacyCenterUrl(baseRequest, '/identities/select');
586
+ return privacyCenterApiFetch<SelectPrincipalResponse>(url, {
587
+ ...options,
588
+ method: 'POST',
589
+ body: JSON.stringify({ uuid: principalUuid }),
590
+ authToken: resolveAuthToken(authToken, baseRequest),
591
+ });
592
+ },
593
+
479
594
  /**
480
595
  * Get receipts (privacy center) — consent receipts for the authenticated user
481
596
  */
@@ -504,7 +619,13 @@ export function createConsentClient(baseUrl: string) {
504
619
  if (params.created_before) queryParams.append("created_before", params.created_before);
505
620
  if (params.language) queryParams.append("language", params.language);
506
621
  const queryString = queryParams.toString();
507
- const url = privacyCenterUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
622
+ // With a ledger configured, receipts are served by the Go ledger
623
+ // (paginated list under /public/...); otherwise the legacy Python
624
+ // privacy-center list is used. normalizeReceipts tolerates both envelopes.
625
+ const receiptsSuffix = `/receipts${queryString ? `?${queryString}` : ""}`;
626
+ const url = useLedger
627
+ ? ledgerUrl(baseRequest, receiptsSuffix)
628
+ : privacyCenterUrl(baseRequest, receiptsSuffix);
508
629
  return privacyCenterApiFetch<unknown>(url, {
509
630
  ...options,
510
631
  method: "GET",
@@ -524,7 +645,12 @@ export function createConsentClient(baseUrl: string) {
524
645
  receiptUuid: string,
525
646
  authToken?: string
526
647
  ): Promise<Blob> {
527
- const url = privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
648
+ // The ledger serves the receipt PDF at /receipts/{uuid}/download, the
649
+ // legacy Python service at /receipts/{uuid}/pdf. The receiptUuid always
650
+ // comes from the same-mode list above, so list and download stay consistent.
651
+ const url = useLedger
652
+ ? ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`)
653
+ : privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
528
654
  const token = resolveAuthToken(authToken, baseRequest);
529
655
  const requestHeaders = new Headers();
530
656
  requestHeaders.set("Accept", "application/pdf");
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useState, type CSSProperties } from 'react';
2
- import { ChevronDown } from 'lucide-react';
2
+ import { ChevronDown, Users } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
3
4
  import { PCAvatar, PCPopover } from '../../ui';
4
5
  import { useBranding } from '../../context/BrandingContext';
5
6
  import { useOrganization } from '../../context/OrganizationContext';
7
+ import { useProfileSwitch } from '../../context/ProfileSwitchContext';
6
8
  import { getVar } from '../../lib/utils';
7
9
  import { usePrivacyCenterExitAction } from './usePrivacyCenterExitAction';
8
10
 
@@ -30,6 +32,7 @@ function initialsFromEmail(email: string): string {
30
32
  }
31
33
 
32
34
  export function UserMenu({ onBack }: UserMenuProps) {
35
+ const { t } = useTranslation();
33
36
  const branding = useBranding();
34
37
  const { organization } = useOrganization();
35
38
  const [email, setEmail] = useState('');
@@ -40,6 +43,7 @@ export function UserMenu({ onBack }: UserMenuProps) {
40
43
 
41
44
  const { handleAction, label: actionLabel, Icon: ActionIcon } =
42
45
  usePrivacyCenterExitAction(onBack);
46
+ const { canSwitch, switchProfile } = useProfileSwitch();
43
47
 
44
48
  const displayName = branding?.brand_name || organization?.name || '';
45
49
  const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
@@ -57,6 +61,25 @@ export function UserMenu({ onBack }: UserMenuProps) {
57
61
  transition: 'background 150ms ease, border-color 150ms ease, box-shadow 150ms ease',
58
62
  };
59
63
 
64
+ // Shared style for the popover's action rows (switch profile, back/sign out).
65
+ const menuItemStyle: CSSProperties = {
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ gap: 10,
69
+ width: '100%',
70
+ padding: '10px 14px',
71
+ border: 'none',
72
+ borderRadius: 8,
73
+ background: 'transparent',
74
+ color: getVar('--pc-text-primary'),
75
+ cursor: 'pointer',
76
+ fontFamily: 'inherit',
77
+ fontSize: 14,
78
+ fontWeight: 500,
79
+ textAlign: 'left',
80
+ transition: 'background 150ms ease',
81
+ };
82
+
60
83
  const trigger = (
61
84
  <div
62
85
  style={triggerStyle}
@@ -139,7 +162,10 @@ export function UserMenu({ onBack }: UserMenuProps) {
139
162
  display: 'flex',
140
163
  alignItems: 'center',
141
164
  gap: 10,
142
- borderBottom: onBack ? `1px solid ${getVar('--pc-border-color')}` : 'none',
165
+ borderBottom:
166
+ onBack || canSwitch
167
+ ? `1px solid ${getVar('--pc-border-color')}`
168
+ : 'none',
143
169
  }}
144
170
  >
145
171
  <PCAvatar name={initialsFromEmail(email)} size="sm" color="info" />
@@ -162,40 +188,43 @@ export function UserMenu({ onBack }: UserMenuProps) {
162
188
  </div>
163
189
  )}
164
190
 
165
- {/* Action row */}
166
- {onBack && (
191
+ {/* Action rows */}
192
+ {(canSwitch || onBack) && (
167
193
  <div style={{ padding: '6px 8px 8px' }}>
168
- <button
169
- type="button"
170
- role="menuitem"
171
- onClick={handleAction}
172
- style={{
173
- display: 'flex',
174
- alignItems: 'center',
175
- gap: 10,
176
- width: '100%',
177
- padding: '10px 14px',
178
- border: 'none',
179
- borderRadius: 8,
180
- background: 'transparent',
181
- color: getVar('--pc-text-primary'),
182
- cursor: 'pointer',
183
- fontFamily: 'inherit',
184
- fontSize: 14,
185
- fontWeight: 500,
186
- textAlign: 'left',
187
- transition: 'background 150ms ease',
188
- }}
189
- onMouseEnter={(e) => {
190
- e.currentTarget.style.background = getVar('--pc-bg-hover');
191
- }}
192
- onMouseLeave={(e) => {
193
- e.currentTarget.style.background = 'transparent';
194
- }}
195
- >
196
- <ActionIcon size={16} strokeWidth={2} />
197
- <span>{actionLabel}</span>
198
- </button>
194
+ {canSwitch && (
195
+ <button
196
+ type="button"
197
+ role="menuitem"
198
+ onClick={switchProfile}
199
+ style={menuItemStyle}
200
+ onMouseEnter={(e) => {
201
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
202
+ }}
203
+ onMouseLeave={(e) => {
204
+ e.currentTarget.style.background = 'transparent';
205
+ }}
206
+ >
207
+ <Users size={16} strokeWidth={2} />
208
+ <span>{t('switchProfile')}</span>
209
+ </button>
210
+ )}
211
+ {onBack && (
212
+ <button
213
+ type="button"
214
+ role="menuitem"
215
+ onClick={handleAction}
216
+ style={menuItemStyle}
217
+ onMouseEnter={(e) => {
218
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
219
+ }}
220
+ onMouseLeave={(e) => {
221
+ e.currentTarget.style.background = 'transparent';
222
+ }}
223
+ >
224
+ <ActionIcon size={16} strokeWidth={2} />
225
+ <span>{actionLabel}</span>
226
+ </button>
227
+ )}
199
228
  </div>
200
229
  )}
201
230
  </div>
@@ -754,6 +754,15 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
754
754
  };
755
755
 
756
756
  try {
757
+ // The ledger's submit-consent needs the notice the purpose was consented
758
+ // under, plus the purpose's data elements with their current selected
759
+ // state — both live on the loaded consent record (selectedRecord).
760
+ const noticeUuid = selectedRecord?.notice_uuid ?? undefined;
761
+ const dataElements = (selectedRecord?.data_elements ?? []).map((el) => ({
762
+ uuid: el.uuid,
763
+ selected: el.selected,
764
+ }));
765
+
757
766
  const response = await manageConsent({
758
767
  organizationId,
759
768
  workspaceId,
@@ -765,6 +774,8 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
765
774
  nominatorContact,
766
775
  dataElementUuids,
767
776
  productUuid,
777
+ noticeUuid,
778
+ dataElements,
768
779
  });
769
780
 
770
781
  if (response) {
@@ -787,7 +798,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
787
798
  handleCloseModal();
788
799
  }
789
800
  },
790
- [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient]
801
+ [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord]
791
802
  );
792
803
 
793
804
  const getVar = (varName: string): string => `var(${varName})`;
@@ -10,9 +10,10 @@ import { PiUploadBold } from 'react-icons/pi';
10
10
  import { toast } from '../utils/toast';
11
11
  import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
12
12
  import { PCModal } from '../ui/PCModal';
13
- import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
13
+ import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup } from './groupHelpers';
14
14
  import { pcStyles } from '../styles/pcStyles';
15
- import { createCaseRequest, getFormData } from '../lib/api';
15
+ import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
16
+ import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
16
17
  import { ensureValidToken } from '../lib/utils';
17
18
  import { useAuth } from '../context/AuthContext';
18
19
  import { useBaseUrl } from '../context/BaseUrlContext';
@@ -142,7 +143,7 @@ export default function Form({
142
143
  const [isSubmitted, setIsSubmitted] = useState(false);
143
144
  const { t } = useTranslation();
144
145
  const { accessToken, refreshToken, onError, updateTokens } = useAuth();
145
- const { consentClient, baseUrl } = useBaseUrl();
146
+ const { consentClient, baseUrl, ledgerBaseUrl } = useBaseUrl();
146
147
  const organizationId = organization?.uuid;
147
148
  const workspaceId = organization?.workspaces[0]?.uuid;
148
149
 
@@ -274,6 +275,57 @@ export default function Form({
274
275
  [],
275
276
  );
276
277
 
278
+ // Approach A: source the DSAR request-form product picker from the Go
279
+ // ledger's user-consents. After the consent cutover, consents live in the
280
+ // ledger, so Python's form/data returns no product groups; we overlay the
281
+ // ledger's product-grouped consents onto the form data instead. Only the
282
+ // end-user privacy-center path is eligible — popup / case-management resolve
283
+ // an arbitrary contact, which the JWT-scoped ledger endpoint cannot serve,
284
+ // so those keep the Python form/data groups. Returns the form data unchanged
285
+ // when ineligible or when the ledger read fails (graceful fallback to
286
+ // whatever groups form/data supplied). Without a configured ledgerBaseUrl,
287
+ // consents still live in Python and form/data groups are authoritative, so
288
+ // the overlay must not run (fetchUserConsents would return the legacy Python
289
+ // shape, which consentGroupToFormProductGroup does not map).
290
+ const withLedgerPickerGroups = useCallback(
291
+ async (form: FormData, language?: string): Promise<FormData> => {
292
+ if (!ledgerBaseUrl || isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
293
+ return form;
294
+ }
295
+ const consents = await fetchUserConsents({
296
+ organizationId,
297
+ workspaceId,
298
+ authContext,
299
+ consentClient,
300
+ // No status filter: include every status (active / expired / withdrawn
301
+ // / declined), matching Python form/data, so a DSAR can target products
302
+ // the user no longer actively consents to.
303
+ limit: FORM_PICKER_GROUP_LIMIT,
304
+ nominatedLimit: FORM_PICKER_GROUP_LIMIT,
305
+ language,
306
+ });
307
+ if (!consents) {
308
+ return form;
309
+ }
310
+ return {
311
+ ...form,
312
+ direct_groups: (consents.direct ?? []).map(consentGroupToFormProductGroup),
313
+ nominated_groups: (consents.nominated ?? []).map(
314
+ consentGroupToFormProductGroup,
315
+ ),
316
+ };
317
+ },
318
+ [
319
+ ledgerBaseUrl,
320
+ isPopup,
321
+ isCaseManagementContext,
322
+ organizationId,
323
+ workspaceId,
324
+ authContext,
325
+ consentClient,
326
+ ],
327
+ );
328
+
277
329
  const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
278
330
  if (!organizationId || !workspaceId || !data) {
279
331
  return;
@@ -314,7 +366,7 @@ export default function Form({
314
366
  return;
315
367
  }
316
368
 
317
- setData(translatedFormData);
369
+ setData(await withLedgerPickerGroups(translatedFormData, langValue));
318
370
 
319
371
  if (translatedFormData.grievance_options) {
320
372
  const currentGrievanceSelections = { ...selectedGrievances };
@@ -333,7 +385,7 @@ export default function Form({
333
385
  } finally {
334
386
  setIsTranslating(false);
335
387
  }
336
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t]);
388
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withLedgerPickerGroups]);
337
389
 
338
390
  // Clear product / org-level selections when request type changes.
339
391
  // Per-product state (selectedPurposes, selectedCorrectionElements,
@@ -486,7 +538,11 @@ export default function Form({
486
538
  }
487
539
  }
488
540
 
489
- setData(displayData);
541
+ const pickerData = await withLedgerPickerGroups(
542
+ displayData,
543
+ langParam || undefined,
544
+ );
545
+ setData(pickerData);
490
546
  setGrievanceOptions(displayData.grievance_options || []);
491
547
  const initialGrievances = (
492
548
  displayData.grievance_options || []
@@ -509,7 +565,7 @@ export default function Form({
509
565
  };
510
566
 
511
567
  void initializePrivacyCenterForm();
512
- }, [organizationId, workspaceId, isPopup, authContext, consentClient]);
568
+ }, [organizationId, workspaceId, isPopup, authContext, consentClient, withLedgerPickerGroups]);
513
569
 
514
570
  /**
515
571
  * Maps translated correction element names back to English names using UUID lookup.
@@ -0,0 +1,83 @@
1
+ import { ChevronRight } from 'lucide-react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PCText, PCTitle } from '../ui';
4
+ import type { SelectUserProps } from '../lib/types';
5
+
6
+ /**
7
+ * "Choose your profile" picker, shown when the signed-in contact maps to more
8
+ * than one data principal (shared contact). Selecting one re-scopes the session
9
+ * to that principal uuid. Purely presentational — SelectUserGate owns the fetch
10
+ * and the re-scope. Structural styles live in styles/injectStyles.ts
11
+ * (.pc-select-user*).
12
+ */
13
+ export const SelectUser = ({
14
+ email,
15
+ candidates,
16
+ onSelect,
17
+ busyOrgUserId,
18
+ error,
19
+ }: SelectUserProps) => {
20
+ const { t } = useTranslation();
21
+ const subtitle = email
22
+ ? t('emailLinkedToMultipleProfiles', { email })
23
+ : t('contactLinkedToMultipleProfiles');
24
+
25
+ // A candidate with no uuid can't be selected (it's the value we submit),
26
+ // so drop it rather than render an unclickable row.
27
+ const selectable = candidates.filter((candidate) => candidate.uuid);
28
+
29
+ return (
30
+ <div className="pc-select-user">
31
+ <div className="pc-select-user__header">
32
+ <PCTitle as="h2">{t('chooseYourProfile')}</PCTitle>
33
+ <PCText as="p" size="base" color="secondary" align="center">
34
+ {subtitle}
35
+ </PCText>
36
+ </div>
37
+
38
+ <div className="pc-select-user__list">
39
+ {selectable.map((candidate) => {
40
+ const principalUuid = candidate.uuid || '';
41
+ const primaryLabel =
42
+ candidate.primary_email || candidate.primary_mobile || candidate.uuid;
43
+ const secondaryLabel =
44
+ candidate.primary_mobile || candidate.primary_email || '';
45
+ const isBusy = busyOrgUserId === principalUuid;
46
+ return (
47
+ <button
48
+ key={candidate.uuid}
49
+ type="button"
50
+ className="pc-select-user__item"
51
+ onClick={() => onSelect(principalUuid)}
52
+ disabled={!principalUuid || busyOrgUserId !== null}
53
+ >
54
+ <span className="pc-select-user__item-text">
55
+ <span className="pc-select-user__item-primary">
56
+ {primaryLabel}
57
+ </span>
58
+ {secondaryLabel ? (
59
+ <span className="pc-select-user__item-secondary">
60
+ {secondaryLabel}
61
+ </span>
62
+ ) : null}
63
+ </span>
64
+ {isBusy ? (
65
+ <span className="pc-select-user__item-status">
66
+ {t('signingInEllipsis')}
67
+ </span>
68
+ ) : (
69
+ <ChevronRight size={18} className="pc-select-user__item-chevron" />
70
+ )}
71
+ </button>
72
+ );
73
+ })}
74
+ </div>
75
+
76
+ {error ? (
77
+ <p className="pc-select-user__error" role="alert">
78
+ {error}
79
+ </p>
80
+ ) : null}
81
+ </div>
82
+ );
83
+ };