@redacto.io/consent-sdk-react 10.0.0-beta.7 → 10.0.0-beta.9

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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  3. package/dist/index.d.mts +29 -3
  4. package/dist/index.d.ts +29 -3
  5. package/dist/index.js +162 -56
  6. package/dist/index.mjs +161 -57
  7. package/dist/privacy-center.d.mts +54 -12
  8. package/dist/privacy-center.d.ts +54 -12
  9. package/dist/privacy-center.js +265 -77
  10. package/dist/privacy-center.mjs +264 -78
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
  13. package/src/RedactoNoticeConsent/api/index.ts +78 -48
  14. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  15. package/src/RedactoNoticeConsent/api/types.ts +57 -11
  16. package/src/RedactoNoticeConsent/types.ts +28 -2
  17. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  18. package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
  19. package/src/RedactoPrivacyCenter/api/client.ts +122 -25
  20. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  21. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  22. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  23. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
  24. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  25. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  26. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  27. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  28. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -5
  29. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  30. package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
  31. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  32. package/src/shared/sandbox.ts +6 -0
  33. package/tests/Form.test.tsx +142 -5
  34. package/tests/consentClient.test.ts +46 -0
@@ -524,12 +524,14 @@ describe('Form (P2-SDK-3)', () => {
524
524
  expect(submitButton.disabled).toBe(true);
525
525
  });
526
526
 
527
- it('skips the ledger picker overlay when no ledgerBaseUrl is configured', async () => {
528
- // Without a ledger, consents still live in Python and the form/data
529
- // groups are authoritative — the overlay must not fire a user-consents
530
- // read (it would hit the legacy endpoint and mis-map its shape).
527
+ it('runs the picker overlay against the legacy endpoint when no ledgerBaseUrl is configured', async () => {
528
+ // `getUserConsents` serves the same grouped payload from either backend
529
+ // (ledger `/public/...` or Python `/dsar/privacy-center/...`), so the
530
+ // overlay must run in both modes — Python's form/data no longer emits
531
+ // product groups, and skipping the read leaves the picker empty.
531
532
  baseUrlStub.ledgerBaseUrl = undefined;
532
533
  setupSession();
534
+ fetchUserConsents.mockResolvedValue(null);
533
535
  getFormData.mockResolvedValue(
534
536
  makeFormData({
535
537
  direct_groups: [
@@ -545,12 +547,12 @@ describe('Form (P2-SDK-3)', () => {
545
547
 
546
548
  render(<Form onCreateRequest={vi.fn()} />);
547
549
  await waitFor(() => expect(getFormData).toHaveBeenCalled());
550
+ await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
548
551
 
549
552
  pickRequestType(RequestTypeEnum.ERASURE);
550
553
  await waitFor(() => {
551
554
  expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
552
555
  });
553
- expect(fetchUserConsents).not.toHaveBeenCalled();
554
556
  });
555
557
 
556
558
  it('runs the ledger picker overlay when ledgerBaseUrl is configured', async () => {
@@ -565,6 +567,141 @@ describe('Form (P2-SDK-3)', () => {
565
567
  await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
566
568
  });
567
569
 
570
+ it('keeps the form/data groups when user-consents comes back with no groups', async () => {
571
+ // A successful-but-empty read must never overwrite groups form/data did
572
+ // supply — that turned a populated picker into "No products available".
573
+ setupSession();
574
+ fetchUserConsents.mockResolvedValue({ direct: [], nominated: [] });
575
+ getFormData.mockResolvedValue(
576
+ makeFormData({
577
+ direct_groups: [
578
+ makeGroup(),
579
+ makeGroup({
580
+ product_uuid: 'prod-direct-2',
581
+ product_name: 'Acme Rewards',
582
+ purposes: [basePurpose('purp-2', 'Analytics')],
583
+ }),
584
+ ],
585
+ }),
586
+ );
587
+
588
+ render(<Form onCreateRequest={vi.fn()} />);
589
+ await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
590
+
591
+ pickRequestType(RequestTypeEnum.ERASURE);
592
+ await waitFor(() => {
593
+ expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
594
+ });
595
+ expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
596
+ expect(screen.queryByText('No products available')).not.toBeInTheDocument();
597
+ });
598
+
599
+ it('falls back to the legacy flat purposes list when the backend exposes no product groups', async () => {
600
+ // Pre-grouping backends return purposes without any product grouping.
601
+ // Those consents must stay reachable under one synthetic group instead
602
+ // of the picker claiming the principal has none.
603
+ setupSession();
604
+ fetchUserConsents.mockResolvedValue(null);
605
+ getFormData.mockResolvedValue(
606
+ makeFormData({
607
+ direct_groups: [],
608
+ nominated_groups: [],
609
+ purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
610
+ }),
611
+ );
612
+
613
+ render(<Form onCreateRequest={vi.fn()} />);
614
+ await waitFor(() => expect(getFormData).toHaveBeenCalled());
615
+
616
+ pickRequestType(RequestTypeEnum.ACCESS);
617
+
618
+ await waitFor(() => {
619
+ expect(screen.getByText('Your data')).toBeInTheDocument();
620
+ });
621
+ expect(screen.getByText('Marketing')).toBeInTheDocument();
622
+ expect(screen.queryByText('No products available')).not.toBeInTheDocument();
623
+ });
624
+
625
+ it('keeps the empty state when the flat purposes carry no data elements', async () => {
626
+ // Nothing selectable survives `buildGroupPurposes`, so the synthetic group
627
+ // would render an unusable panel — the empty state is the honest answer.
628
+ setupSession();
629
+ fetchUserConsents.mockResolvedValue(null);
630
+ getFormData.mockResolvedValue(
631
+ makeFormData({
632
+ direct_groups: [],
633
+ nominated_groups: [],
634
+ purposes: [{ purpose: basePurpose('purp-flat', 'Marketing', []) }],
635
+ }),
636
+ );
637
+
638
+ render(<Form onCreateRequest={vi.fn()} />);
639
+ await waitFor(() => expect(getFormData).toHaveBeenCalled());
640
+
641
+ pickRequestType(RequestTypeEnum.ACCESS);
642
+
643
+ await waitFor(() => {
644
+ expect(screen.getByText('No products available')).toBeInTheDocument();
645
+ });
646
+ expect(screen.queryByText('Your data')).not.toBeInTheDocument();
647
+ });
648
+
649
+ it('submits the legacy flat fallback as an org-level case without a product anchor', async () => {
650
+ // The synthetic group has no server-side product, so the case must go up
651
+ // in the pre-grouping org-level shape rather than carrying a made-up
652
+ // `product_uuid` the backend cannot resolve.
653
+ setupSession();
654
+ fetchUserConsents.mockResolvedValue(null);
655
+ getFormData.mockResolvedValue(
656
+ makeFormData({
657
+ direct_groups: [],
658
+ nominated_groups: [],
659
+ purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
660
+ }),
661
+ );
662
+ createCaseRequest.mockResolvedValueOnce({
663
+ case_id: 'CASE-FLAT',
664
+ uuid: 'uuid-flat',
665
+ });
666
+
667
+ render(<Form onCreateRequest={vi.fn()} />);
668
+ await waitFor(() => expect(getFormData).toHaveBeenCalled());
669
+
670
+ pickRequestType(RequestTypeEnum.ACCESS);
671
+
672
+ await waitFor(() => {
673
+ expect(screen.getByText('Marketing')).toBeInTheDocument();
674
+ });
675
+
676
+ const marketingCheckbox = screen
677
+ .getByText('Marketing')
678
+ .closest('label')
679
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
680
+ expect(marketingCheckbox).toBeTruthy();
681
+ fireEvent.click(marketingCheckbox!);
682
+
683
+ const confirmCheckbox = (
684
+ screen.getAllByRole('checkbox') as HTMLInputElement[]
685
+ ).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
686
+ expect(confirmCheckbox).toBeTruthy();
687
+ fireEvent.click(confirmCheckbox!);
688
+
689
+ const submitButton = screen.getByRole('button', { name: /^submit$/i });
690
+ expect(submitButton).not.toBeDisabled();
691
+ await act(async () => {
692
+ fireEvent.click(submitButton);
693
+ });
694
+
695
+ await waitFor(() => expect(createCaseRequest).toHaveBeenCalledTimes(1));
696
+
697
+ const payload = createCaseRequest.mock.calls[0][2];
698
+ expect('product_uuid' in payload).toBe(false);
699
+ expect('data_principal_uuid' in payload).toBe(false);
700
+ expect(payload.request_details.purposes).toEqual([
701
+ { purpose_uuid: 'purp-flat', data_element_uuids: ['purp-flat-de1'] },
702
+ ]);
703
+ });
704
+
568
705
  it('renders the product step for DSAR types when direct_groups is present', async () => {
569
706
  setupSession();
570
707
  const direct = makeGroup({
@@ -93,6 +93,24 @@ describe('createConsentClient — legacy mode (no ledgerBaseUrl)', () => {
93
93
  });
94
94
  });
95
95
 
96
+ it('records the selected language in the legacy manage-consent body', async () => {
97
+ await client.manageConsent(
98
+ baseRequest,
99
+ 'purp-1',
100
+ 'jane@example.com',
101
+ 'revoke',
102
+ undefined,
103
+ undefined,
104
+ undefined,
105
+ undefined,
106
+ undefined,
107
+ undefined,
108
+ undefined,
109
+ 'doi',
110
+ );
111
+ expect(fetchedBody()).toMatchObject({ language: 'doi' });
112
+ });
113
+
96
114
  it('routes getReceipts to the legacy dsar/privacy-center path', async () => {
97
115
  await client.getReceipts(baseRequest, { skip: 0, limit: 10 });
98
116
  expect(fetchedUrl()).toBe(`${LEGACY_PREFIX}/receipts?skip=0&limit=10`);
@@ -103,6 +121,16 @@ describe('createConsentClient — legacy mode (no ledgerBaseUrl)', () => {
103
121
  await client.getReceiptPdf(baseRequest, 'r-1');
104
122
  expect(fetchedUrl()).toBe(`${LEGACY_PREFIX}/receipts/r-1/pdf`);
105
123
  });
124
+
125
+ it('appends the language query param to getActivities when provided', async () => {
126
+ await client.getActivities(baseRequest, 0, 15, 'doi');
127
+ expect(fetchedUrl()).toContain('language=doi');
128
+ });
129
+
130
+ it('omits the language query param from getActivities for the default language', async () => {
131
+ await client.getActivities(baseRequest, 0, 15);
132
+ expect(fetchedUrl()).not.toContain('language=');
133
+ });
106
134
  });
107
135
 
108
136
  describe('createConsentClient — ledger mode (ledgerBaseUrl set)', () => {
@@ -151,6 +179,24 @@ describe('createConsentClient — ledger mode (ledgerBaseUrl set)', () => {
151
179
  });
152
180
  });
153
181
 
182
+ it('records the selected language in the ledger partial submit body', async () => {
183
+ await client.manageConsent(
184
+ baseRequest,
185
+ 'purp-1',
186
+ 'jane@example.com',
187
+ 'revoke',
188
+ undefined,
189
+ undefined,
190
+ undefined,
191
+ undefined,
192
+ undefined,
193
+ 'notice-1',
194
+ [{ uuid: 'de-1', selected: true }],
195
+ 'doi',
196
+ );
197
+ expect(fetchedBody()).toMatchObject({ language: 'doi' });
198
+ });
199
+
154
200
  it('throws from manageConsent when noticeUuid is missing', async () => {
155
201
  await expect(
156
202
  client.manageConsent(baseRequest, 'purp-1', 'jane@example.com', 'revoke'),