@redacto.io/consent-sdk-react 9.3.0-beta.0 → 9.3.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.
- package/CHANGELOG.md +6 -0
- package/dist/privacy-center.js +703 -622
- package/dist/privacy-center.mjs +704 -623
- package/package.json +1 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +127 -8
- package/tests/Form.test.tsx +93 -1
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
}
|
package/tests/Form.test.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
});
|