@redacto.io/consent-sdk-react 9.3.0-beta.0 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "9.3.0-beta.0",
3
+ "version": "9.3.0",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,7 @@ import { AiOutlineClose } from 'react-icons/ai';
9
9
  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
+ import { PCModal } from '../ui/PCModal';
12
13
  import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
13
14
  import { pcStyles } from '../styles/pcStyles';
14
15
  import { createCaseRequest, getFormData } from '../lib/api';
@@ -46,7 +47,8 @@ import {
46
47
  import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
47
48
  import RequestSubmit from './RequestSubmit';
48
49
  import type { CaseHistoryProps } from './types';
49
- import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
50
+ import { FiAlertCircle, FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
51
+ import { MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL } from './constant';
50
52
 
51
53
  const getVar = (varName: string): string => `var(${varName})`;
52
54
 
@@ -219,6 +221,10 @@ export default function Form({
219
221
  const [isTranslating, setIsTranslating] = useState(false);
220
222
  const [isRequestDetailsExpanded, setIsRequestDetailsExpanded] =
221
223
  useState(true);
224
+ const [isErasureConsentModalOpen, setIsErasureConsentModalOpen] =
225
+ useState(false);
226
+ const [revokeConsentOnFulfilment, setRevokeConsentOnFulfilment] =
227
+ useState(false);
222
228
 
223
229
  const resetFileInput = () => {
224
230
  if (fileInputRef.current) {
@@ -537,17 +543,52 @@ export default function Form({
537
543
  return translatedName;
538
544
  }, [data]);
539
545
 
546
+ // Purpose names selected in an ERASURE request — surfaced in the
547
+ // consent-revoke modal and the post-submit notice. When the list is
548
+ // empty or too long to enumerate, fall back to a generic phrase.
549
+ const selectedErasurePurposeList = useMemo(
550
+ () =>
551
+ selectedPurposes
552
+ .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
553
+ .map((p) => p.name)
554
+ .filter(Boolean),
555
+ [selectedPurposes]
556
+ );
557
+
558
+ const selectedErasurePurposeNames = useMemo(() => {
559
+ if (
560
+ selectedErasurePurposeList.length === 0 ||
561
+ selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL
562
+ ) {
563
+ return t('revokeConsentModalYourPurposes');
564
+ }
565
+ return selectedErasurePurposeList.join(', ');
566
+ }, [selectedErasurePurposeList, t]);
567
+
540
568
  const handleSubmitClick = () => {
541
569
  if (!isFormValid) {
542
570
  return;
543
571
  }
544
- // For ERASURE, set revoke_consent_on_fulfilment=true so the
545
- // backend's auto-revoke task fires when the case is fulfilled.
546
- // Erasing data without revoking the consent that authorized its
547
- // processing is incoherent per DPDPA Sec 12. Other request types
548
- // (ACCESS / CORRECTION / NOMINATION / GRIEVANCE) do not engage
549
- // the auto-revoke path on the backend and pass false here.
550
- void handleSubmit(requestType === RequestTypeEnum.ERASURE);
572
+ // ERASURE asks the data principal whether to also revoke consent on
573
+ // fulfilment — the choice is made in the modal and threaded into the
574
+ // payload via handleErasureModalChoice. Other request types
575
+ // (ACCESS / CORRECTION / NOMINATION / GRIEVANCE) submit directly.
576
+ if (requestType === RequestTypeEnum.ERASURE) {
577
+ setIsErasureConsentModalOpen(true);
578
+ return;
579
+ }
580
+ setRevokeConsentOnFulfilment(false);
581
+ void handleSubmit(false);
582
+ };
583
+
584
+ const handleErasureModalChoice = (revoke: boolean) => {
585
+ setRevokeConsentOnFulfilment(revoke);
586
+ setIsErasureConsentModalOpen(false);
587
+ void handleSubmit(revoke);
588
+ };
589
+
590
+ const handleErasureModalClose = () => {
591
+ setIsErasureConsentModalOpen(false);
551
592
  };
552
593
 
553
594
  /**
@@ -959,6 +1000,8 @@ export default function Form({
959
1000
  result={submittedCase}
960
1001
  contact={formData?.contact}
961
1002
  onCreateRequest={onCreateRequest}
1003
+ revokeConsentOnFulfilment={revokeConsentOnFulfilment}
1004
+ revokedPurposeNames={selectedErasurePurposeNames}
962
1005
  />
963
1006
  ) : (
964
1007
  <div>
@@ -1616,6 +1659,82 @@ export default function Form({
1616
1659
  </div>
1617
1660
  </div>
1618
1661
  )}
1662
+ <PCModal
1663
+ isOpen={isErasureConsentModalOpen}
1664
+ onClose={handleErasureModalClose}
1665
+ size="md"
1666
+ showCloseButton={false}
1667
+ footer={
1668
+ <div className="pc-erasure-modal-footer">
1669
+ <PCButton
1670
+ variant="secondary"
1671
+ onClick={() => handleErasureModalChoice(false)}
1672
+ disabled={isLoading}
1673
+ >
1674
+ {t('revokeConsentNo')}
1675
+ </PCButton>
1676
+ <PCButton
1677
+ variant="primary"
1678
+ onClick={() => handleErasureModalChoice(true)}
1679
+ disabled={isLoading}
1680
+ isLoading={isLoading}
1681
+ >
1682
+ {t('revokeConsentYes')}
1683
+ </PCButton>
1684
+ </div>
1685
+ }
1686
+ >
1687
+ <div className="pc-erasure-modal-content">
1688
+ <div className="pc-erasure-modal-header">
1689
+ <div className="pc-erasure-modal-icon">
1690
+ <FiAlertCircle size={24} />
1691
+ </div>
1692
+ <p className="pc-erasure-modal-title">
1693
+ {t('revokeConsentModalTitle')}
1694
+ </p>
1695
+ <p className="pc-erasure-modal-question">
1696
+ {t('revokeConsentModalQuestion')}
1697
+ </p>
1698
+ </div>
1699
+
1700
+ <div className="pc-erasure-modal-purposes">
1701
+ <p className="pc-erasure-modal-purposes-label">
1702
+ {t('revokeConsentModalPurposesLabel')}
1703
+ </p>
1704
+ {selectedErasurePurposeList.length > 0 ? (
1705
+ <>
1706
+ {selectedErasurePurposeList
1707
+ .slice(0, MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL)
1708
+ .map((name) => (
1709
+ <div key={name} className="pc-erasure-modal-purpose-item">
1710
+ {name}
1711
+ </div>
1712
+ ))}
1713
+ {selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL && (
1714
+ <span className="pc-erasure-modal-purposes-placeholder">
1715
+ {t('revokeConsentModalAndMore', {
1716
+ count: selectedErasurePurposeList.length - MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL,
1717
+ })}
1718
+ </span>
1719
+ )}
1720
+ </>
1721
+ ) : (
1722
+ <span className="pc-erasure-modal-purposes-placeholder">
1723
+ {t('revokeConsentModalYourPurposes')}
1724
+ </span>
1725
+ )}
1726
+ </div>
1727
+
1728
+ <div className="pc-erasure-modal-warning">
1729
+ <p className="pc-erasure-modal-warning-text">
1730
+ <span className="pc-erasure-modal-warning-label">
1731
+ {t('revokeConsentModalIfYesLabel')}
1732
+ </span>{' '}
1733
+ {t('revokeConsentModalIfYesBody')}
1734
+ </p>
1735
+ </div>
1736
+ </div>
1737
+ </PCModal>
1619
1738
  </>
1620
1739
  );
1621
1740
  }
@@ -694,6 +694,13 @@ describe('Form (P2-SDK-3)', () => {
694
694
  fireEvent.click(submitButton);
695
695
  });
696
696
 
697
+ // ERASURE asks whether to also revoke consent before it submits.
698
+ // Choosing "Yes" (revokeConsentYes) sets revoke_consent_on_fulfilment=true.
699
+ const revokeYesButton = await screen.findByRole('button', { name: 'Yes' });
700
+ await act(async () => {
701
+ fireEvent.click(revokeYesButton);
702
+ });
703
+
697
704
  await waitFor(() => {
698
705
  expect(createCaseRequest).toHaveBeenCalledTimes(1);
699
706
  });
@@ -703,7 +710,7 @@ describe('Form (P2-SDK-3)', () => {
703
710
  expect('data_principal_uuid' in payload).toBe(false);
704
711
  expect(payload.uuid).toBe('form-session-uuid');
705
712
  expect(payload.request_type).toBe('erasure');
706
- // ERASURE submits must set this flag so the backend's
713
+ // The user chose "Yes" in the consent-revoke modal, so the backend's
707
714
  // auto-revoke task fires when the case is fulfilled (DPDPA Sec 12:
708
715
  // erasing data implies revoking the consent that authorized
709
716
  // processing it).
@@ -714,6 +721,86 @@ describe('Form (P2-SDK-3)', () => {
714
721
  });
715
722
  });
716
723
 
724
+ it('opens the consent-revoke modal on erasure and sends revoke_consent_on_fulfilment=false when the user declines', async () => {
725
+ setupSession();
726
+ const directGroup = makeGroup({
727
+ product_uuid: 'prod-direct',
728
+ product_name: 'Acme Loyalty',
729
+ purposes: [basePurpose('purp-direct', 'Marketing')],
730
+ });
731
+ const nominatedGroup = makeGroup({
732
+ product_uuid: 'prod-nominated',
733
+ product_name: 'Partner Bank',
734
+ nominator: {
735
+ uuid: 'nominator-uuid',
736
+ org_user_id: 'org-user-1',
737
+ name: 'Alice',
738
+ email: 'alice@example.com',
739
+ },
740
+ purposes: [basePurpose('purp-nominated', 'Analytics')],
741
+ });
742
+ getFormData.mockResolvedValue(
743
+ makeFormData({
744
+ direct_groups: [directGroup],
745
+ nominated_groups: [nominatedGroup],
746
+ }),
747
+ );
748
+ createCaseRequest.mockResolvedValueOnce({
749
+ case_id: 'CASE-2',
750
+ uuid: 'uuid-2',
751
+ });
752
+
753
+ render(<Form onCreateRequest={vi.fn()} />);
754
+ await waitFor(() => expect(getFormData).toHaveBeenCalled());
755
+
756
+ pickRequestType(RequestTypeEnum.ERASURE);
757
+
758
+ await waitFor(() =>
759
+ expect(screen.getByText('Acme Loyalty')).toBeInTheDocument(),
760
+ );
761
+ clickProductChip('prod-direct');
762
+
763
+ await waitFor(() => {
764
+ expect(
765
+ screen.getByTestId('per-product-purposes-prod-direct'),
766
+ ).toBeInTheDocument();
767
+ });
768
+
769
+ const marketingCheckbox = screen
770
+ .getByText('Marketing')
771
+ .closest('label')
772
+ ?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
773
+ expect(marketingCheckbox).toBeTruthy();
774
+ fireEvent.click(marketingCheckbox!);
775
+
776
+ const confirmCheckbox = (
777
+ screen.getAllByRole('checkbox') as HTMLInputElement[]
778
+ ).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
779
+ expect(confirmCheckbox).toBeTruthy();
780
+ fireEvent.click(confirmCheckbox!);
781
+
782
+ await act(async () => {
783
+ fireEvent.click(screen.getByRole('button', { name: /^submit$/i }));
784
+ });
785
+
786
+ // The consent-revoke modal must appear, and no request fires until the
787
+ // user makes a choice. ('Revoke consent?' === revokeConsentModalTitle stub.)
788
+ expect(await screen.findByText('Revoke consent?')).toBeInTheDocument();
789
+ expect(createCaseRequest).not.toHaveBeenCalled();
790
+
791
+ // Declining ('No' === revokeConsentNo) submits with the flag explicitly false.
792
+ await act(async () => {
793
+ fireEvent.click(screen.getByRole('button', { name: 'No' }));
794
+ });
795
+
796
+ await waitFor(() => {
797
+ expect(createCaseRequest).toHaveBeenCalledTimes(1);
798
+ });
799
+ const payload = createCaseRequest.mock.calls[0][2];
800
+ expect(payload.request_type).toBe('erasure');
801
+ expect(payload.revoke_consent_on_fulfilment).toBe(false);
802
+ });
803
+
717
804
  it('switches the inline purpose panel when another product chip is selected', async () => {
718
805
  setupSession();
719
806
  const creditCard = makeGroup({
@@ -1052,6 +1139,11 @@ describe('Form (P2-SDK-3)', () => {
1052
1139
  fireEvent.click(screen.getByRole('button', { name: /^submit$/i }));
1053
1140
  });
1054
1141
 
1142
+ // ERASURE routes through the consent-revoke modal before submitting.
1143
+ await act(async () => {
1144
+ fireEvent.click(await screen.findByRole('button', { name: 'Yes' }));
1145
+ });
1146
+
1055
1147
  await waitFor(() => {
1056
1148
  expect(createCaseRequest).toHaveBeenCalledTimes(1);
1057
1149
  });