@redacto.io/consent-sdk-react 9.2.0-beta.0 → 9.2.0-beta.2
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/.turbo/turbo-build.log +15 -10
- package/CHANGELOG.md +12 -0
- package/dist/privacy-center.d.mts +51 -1
- package/dist/privacy-center.d.ts +51 -1
- package/dist/privacy-center.js +3463 -1148
- package/dist/privacy-center.mjs +3399 -1084
- package/package.json +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
- package/src/RedactoPrivacyCenter/api/client.ts +79 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +402 -259
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +95 -57
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
- package/src/RedactoPrivacyCenter/components/types.ts +20 -2
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
- package/src/RedactoPrivacyCenter/lib/api.ts +67 -8
- package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +74 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
- package/tests/Form.test.tsx +1144 -0
- package/tests/ProductSelectionSection.test.tsx +328 -0
- package/tests/RequestSubmit.test.tsx +135 -0
|
@@ -8,7 +8,8 @@ import { useTranslation } from 'react-i18next';
|
|
|
8
8
|
import { AiOutlineClose } from 'react-icons/ai';
|
|
9
9
|
import { PiUploadBold } from 'react-icons/pi';
|
|
10
10
|
import { toast } from '../utils/toast';
|
|
11
|
-
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCTextarea } from '../ui';
|
|
11
|
+
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
|
|
12
|
+
import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
|
|
12
13
|
import { pcStyles } from '../styles/pcStyles';
|
|
13
14
|
import { createCaseRequest, getFormData } from '../lib/api';
|
|
14
15
|
import { ensureValidToken } from '../lib/utils';
|
|
@@ -22,8 +23,9 @@ import { useOrganization } from '../context/OrganizationContext';
|
|
|
22
23
|
import i18n from '../lib/i18n';
|
|
23
24
|
import { getLangParam } from '../lib/useCurrentLanguage';
|
|
24
25
|
import type {
|
|
25
|
-
|
|
26
|
+
CreateCaseResponse,
|
|
26
27
|
FormData,
|
|
28
|
+
FormProductGroup,
|
|
27
29
|
GrievanceOptions,
|
|
28
30
|
GrievanceTypeElement,
|
|
29
31
|
GrievanceValue,
|
|
@@ -37,6 +39,10 @@ import { RequestTypeEnum } from '../lib/types';
|
|
|
37
39
|
import CorrectionDataSection from './DataRequest/CorrectionDataSection';
|
|
38
40
|
import GrievanceDataSection from './DataRequest/GrievanceDataSection';
|
|
39
41
|
import NominationDataSection from './DataRequest/NominationDataSection';
|
|
42
|
+
import {
|
|
43
|
+
ProductSelectionSection,
|
|
44
|
+
productGroupKey,
|
|
45
|
+
} from './DataRequest/ProductSelectionSection';
|
|
40
46
|
import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
|
|
41
47
|
import RequestSubmit from './RequestSubmit';
|
|
42
48
|
import type { CaseHistoryProps } from './types';
|
|
@@ -44,6 +50,74 @@ import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
|
|
|
44
50
|
|
|
45
51
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Narrow shape of the `case_id`/`uuid` fields extracted from a
|
|
55
|
+
* `CreateCaseResponse` (or its unwrapped privacy-center variant). Both
|
|
56
|
+
* are stringly returned — empty strings mean "not present on the
|
|
57
|
+
* response" rather than `null`, which keeps the call sites simple.
|
|
58
|
+
*/
|
|
59
|
+
type ExtractedCaseRef = { uuid: string; case_id: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The two response shapes `extractCaseId` walks before resolving an
|
|
63
|
+
* `ExtractedCaseRef`. Privacy-center returns the unwrapped detail;
|
|
64
|
+
* case-management wraps it in `{ code, status, detail }`.
|
|
65
|
+
*/
|
|
66
|
+
type CaseIdContainer = { case_id?: string; uuid?: string };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Request types that engage the per-product picker and require a
|
|
70
|
+
* product anchor (`product_uuid`) on the submitted case. Nomination
|
|
71
|
+
* and grievance are org-level and skip the product step entirely.
|
|
72
|
+
*/
|
|
73
|
+
const PRODUCT_SCOPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
|
|
74
|
+
RequestTypeEnum.ACCESS,
|
|
75
|
+
RequestTypeEnum.ERASURE,
|
|
76
|
+
RequestTypeEnum.CORRECTION,
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const isProductScopedRequestType = (
|
|
80
|
+
rt: RequestTypeEnum | null,
|
|
81
|
+
): rt is RequestTypeEnum =>
|
|
82
|
+
rt !== null && PRODUCT_SCOPED_REQUEST_TYPES.has(rt);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Result shape rendered by RequestSubmit after a successful submission.
|
|
86
|
+
* Product fields are populated only for product-scoped request types
|
|
87
|
+
* (ACCESS / ERASURE / CORRECTION); nomination and grievance leave them
|
|
88
|
+
* as empty strings so the success card omits the product label.
|
|
89
|
+
*/
|
|
90
|
+
type SubmittedCase = {
|
|
91
|
+
case_id: string;
|
|
92
|
+
uuid: string;
|
|
93
|
+
product_name: string;
|
|
94
|
+
product_uuid: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pull the `case_id` out of a `createCaseRequest` response, handling both
|
|
99
|
+
* the privacy-center shape (unwrapped detail) and the case-management shape
|
|
100
|
+
* (wrapped { code, status, detail }).
|
|
101
|
+
*/
|
|
102
|
+
const extractCaseId = (
|
|
103
|
+
response: CreateCaseResponse | null,
|
|
104
|
+
): ExtractedCaseRef | null => {
|
|
105
|
+
if (!response) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if ('detail' in response && response.detail) {
|
|
109
|
+
const detail = response.detail as CaseIdContainer;
|
|
110
|
+
if (detail.case_id || detail.uuid) {
|
|
111
|
+
return { case_id: detail.case_id ?? '', uuid: detail.uuid ?? '' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const direct = response as unknown as CaseIdContainer;
|
|
115
|
+
if (direct.case_id || direct.uuid) {
|
|
116
|
+
return { case_id: direct.case_id ?? '', uuid: direct.uuid ?? '' };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
47
121
|
export default function Form({
|
|
48
122
|
onCreateRequest,
|
|
49
123
|
isPopup = false,
|
|
@@ -89,26 +163,39 @@ export default function Form({
|
|
|
89
163
|
additionalNote: '',
|
|
90
164
|
confirmCheckBox: false,
|
|
91
165
|
});
|
|
92
|
-
|
|
93
|
-
|
|
166
|
+
// Selected product-group key on the product-cards step. Keyed by
|
|
167
|
+
// `productGroupKey(group)` so the same product reached both directly
|
|
168
|
+
// AND via a nominator stays addressable as two distinct selections.
|
|
169
|
+
// null when no product is selected (or for org-level request types).
|
|
170
|
+
const [selectedProductKey, setSelectedProductKey] = useState<string | null>(
|
|
171
|
+
null,
|
|
172
|
+
);
|
|
173
|
+
// Purpose state for the currently-selected product group. Seeded from
|
|
174
|
+
// `buildGroupPurposes(group)` whenever `selectedProductKey` changes, then
|
|
175
|
+
// mutated in place by `PurposeSelectionSection` as the user picks
|
|
176
|
+
// purposes / data elements. Single-select means we never need a Map.
|
|
177
|
+
const [selectedPurposes, setSelectedPurposes] = useState<Purpose[]>([]);
|
|
178
|
+
// CORRECTION selections for the currently-selected product group. These
|
|
179
|
+
// are flat (not keyed by product) because the picker is single-select —
|
|
180
|
+
// switching products re-seeds them via the same effect that re-seeds
|
|
181
|
+
// `selectedPurposes`. Names are the data-element names visible in the
|
|
182
|
+
// UI; they're reverse-translated to English at submit time.
|
|
94
183
|
const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
|
|
95
184
|
string[]
|
|
96
185
|
>([]);
|
|
97
|
-
const [uniqueDataElements, setUniqueDataElements] = useState<DataElement[]>(
|
|
98
|
-
[]
|
|
99
|
-
);
|
|
100
186
|
const [correctionValues, setCorrectionValues] = useState<
|
|
101
187
|
Record<string, { current: string; updated: string }>
|
|
102
188
|
>({});
|
|
103
|
-
|
|
189
|
+
// Result rendered by `RequestSubmit` after a successful submission.
|
|
190
|
+
// Replaces the prior batch-shape now that submit is 1-form / 1-case.
|
|
191
|
+
// Product fields are empty strings for nomination / grievance (no
|
|
192
|
+
// product anchor) so the success card omits the product label.
|
|
193
|
+
const [submittedCase, setSubmittedCase] = useState<SubmittedCase | null>(
|
|
194
|
+
null,
|
|
195
|
+
);
|
|
104
196
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
105
197
|
// Store the original English form data for submission — translations are display-only
|
|
106
198
|
const englishDataRef = useRef<FormData | null>(null);
|
|
107
|
-
// Store selections to restore after translation
|
|
108
|
-
const selectionsToRestoreRef = useRef<{
|
|
109
|
-
access: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
|
|
110
|
-
erasure: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
|
|
111
|
-
} | null>(null);
|
|
112
199
|
const [uploadedDocumentId, setUploadedDocumentId] = useState<string[]>([]);
|
|
113
200
|
const [uploadedDocuments, setUploadedDocuments] = useState<
|
|
114
201
|
{ uuid: string; name: string }[]
|
|
@@ -139,77 +226,47 @@ export default function Form({
|
|
|
139
226
|
}
|
|
140
227
|
};
|
|
141
228
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const extractedPurposes = data.purposes
|
|
151
|
-
.map((p) => p?.purpose)
|
|
152
|
-
.filter((p) => p !== null && p !== undefined);
|
|
153
|
-
|
|
154
|
-
if (extractedPurposes.length === 0) {
|
|
155
|
-
console.warn('No valid purposes found in form data');
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const uniqueElements = new Map();
|
|
160
|
-
extractedPurposes.forEach((purpose) => {
|
|
161
|
-
// Ensure data_elements is an array and filter out any null/undefined elements.
|
|
162
|
-
// Include every data element the org holds for this user, regardless of current
|
|
163
|
-
// consent state. Revoking consent does not delete data (TECH-4089), so purposes
|
|
164
|
-
// with revoked/withdrawn consent must remain raisable for access/erasure/correction.
|
|
165
|
-
const validDataElements = Array.isArray(purpose.data_elements)
|
|
166
|
-
? purpose.data_elements.filter(
|
|
167
|
-
(element) => element !== null && element !== undefined && element.uuid
|
|
168
|
-
)
|
|
169
|
-
: [];
|
|
170
|
-
|
|
171
|
-
// Data elements are validated and processed
|
|
172
|
-
|
|
173
|
-
validDataElements.forEach((element) => {
|
|
174
|
-
if (element.uuid && !uniqueElements.has(element.uuid)) {
|
|
175
|
-
uniqueElements.set(element.uuid, {
|
|
176
|
-
...element,
|
|
177
|
-
purposeName: purpose.name,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
});
|
|
229
|
+
// Union of direct + nominated product groups exposed by the backend.
|
|
230
|
+
// Drives both the product-cards step and the per-product picker beneath.
|
|
231
|
+
const allGroups = useMemo<FormProductGroup[]>(() => {
|
|
232
|
+
if (!data) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
|
|
236
|
+
}, [data]);
|
|
182
237
|
|
|
183
|
-
|
|
184
|
-
|
|
238
|
+
// TECH-3712 UX: when the DP has exactly one consent group total
|
|
239
|
+
// (direct + nominated combined), the product-selection step is friction —
|
|
240
|
+
// there's nothing to choose. Skip rendering ProductSelectionSection
|
|
241
|
+
// entirely and auto-select the lone group below so the per-product
|
|
242
|
+
// picker renders without an intermediate card. With zero groups we
|
|
243
|
+
// still render the section so the empty-state message is visible.
|
|
244
|
+
const skipProductStep = allGroups.length === 1;
|
|
245
|
+
|
|
246
|
+
// The single product-group the user is currently working with. Null
|
|
247
|
+
// when no product is selected (the picker is shown empty / unselected)
|
|
248
|
+
// or when the request type is org-level (nomination / grievance).
|
|
249
|
+
const selectedGroup = useMemo<FormProductGroup | null>(() => {
|
|
250
|
+
if (selectedProductKey) {
|
|
251
|
+
return (
|
|
252
|
+
allGroups.find((g) => productGroupKey(g) === selectedProductKey) ??
|
|
253
|
+
null
|
|
185
254
|
);
|
|
186
|
-
|
|
187
|
-
// Ensure data_elements are properly preserved with all properties.
|
|
188
|
-
// Include data elements regardless of consent state (revocation ≠ deletion,
|
|
189
|
-
// TECH-4089); only drop purposes that genuinely have no data elements.
|
|
190
|
-
const processedPurposes = extractedPurposes
|
|
191
|
-
.map((p) => {
|
|
192
|
-
const validDataElements = Array.isArray(p.data_elements)
|
|
193
|
-
? p.data_elements
|
|
194
|
-
.filter((de) => de !== null && de !== undefined && de.uuid)
|
|
195
|
-
.map((de) => ({
|
|
196
|
-
...de,
|
|
197
|
-
selected: false,
|
|
198
|
-
}))
|
|
199
|
-
: [];
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
...p,
|
|
203
|
-
selected: false,
|
|
204
|
-
data_elements: validDataElements,
|
|
205
|
-
};
|
|
206
|
-
})
|
|
207
|
-
.filter((p) => p.data_elements.length > 0);
|
|
208
|
-
|
|
209
|
-
setAccessPurposes(processedPurposes);
|
|
210
|
-
setErasurePurposes(processedPurposes);
|
|
211
255
|
}
|
|
212
|
-
|
|
256
|
+
if (skipProductStep) {
|
|
257
|
+
return allGroups[0] ?? null;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}, [allGroups, selectedProductKey, skipProductStep]);
|
|
261
|
+
|
|
262
|
+
// Stable select handler for ProductSelectionSection. Single-group
|
|
263
|
+
// auto-select fires this on mount via the child's effect.
|
|
264
|
+
const handleProductSelect = useCallback(
|
|
265
|
+
(key: string | null, _group: FormProductGroup | null) => {
|
|
266
|
+
setSelectedProductKey(key);
|
|
267
|
+
},
|
|
268
|
+
[],
|
|
269
|
+
);
|
|
213
270
|
|
|
214
271
|
const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
|
|
215
272
|
if (!organizationId || !workspaceId || !data) {
|
|
@@ -243,37 +300,6 @@ export default function Form({
|
|
|
243
300
|
return;
|
|
244
301
|
}
|
|
245
302
|
|
|
246
|
-
const currentAccessSelections = new Map<string, {
|
|
247
|
-
purposeSelected: boolean;
|
|
248
|
-
dataElementSelections: Map<string, boolean>;
|
|
249
|
-
}>();
|
|
250
|
-
const currentErasureSelections = new Map<string, {
|
|
251
|
-
purposeSelected: boolean;
|
|
252
|
-
dataElementSelections: Map<string, boolean>;
|
|
253
|
-
}>();
|
|
254
|
-
|
|
255
|
-
accessPurposes.forEach((purpose) => {
|
|
256
|
-
const dataElementSelections = new Map<string, boolean>();
|
|
257
|
-
purpose.data_elements.forEach((de) => {
|
|
258
|
-
dataElementSelections.set(de.uuid, de.selected);
|
|
259
|
-
});
|
|
260
|
-
currentAccessSelections.set(purpose.uuid, {
|
|
261
|
-
purposeSelected: purpose.selected,
|
|
262
|
-
dataElementSelections,
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
erasurePurposes.forEach((purpose) => {
|
|
267
|
-
const dataElementSelections = new Map<string, boolean>();
|
|
268
|
-
purpose.data_elements.forEach((de) => {
|
|
269
|
-
dataElementSelections.set(de.uuid, de.selected);
|
|
270
|
-
});
|
|
271
|
-
currentErasureSelections.set(purpose.uuid, {
|
|
272
|
-
purposeSelected: purpose.selected,
|
|
273
|
-
dataElementSelections,
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
303
|
if (!translatedFormData.purposes ||
|
|
278
304
|
!Array.isArray(translatedFormData.purposes) ||
|
|
279
305
|
translatedFormData.purposes.length === 0) {
|
|
@@ -294,11 +320,6 @@ export default function Form({
|
|
|
294
320
|
});
|
|
295
321
|
setSelectedGrievances(updatedGrievanceSelections);
|
|
296
322
|
}
|
|
297
|
-
|
|
298
|
-
selectionsToRestoreRef.current = {
|
|
299
|
-
access: currentAccessSelections,
|
|
300
|
-
erasure: currentErasureSelections,
|
|
301
|
-
};
|
|
302
323
|
} catch (error) {
|
|
303
324
|
console.error('Error fetching translation:', error);
|
|
304
325
|
// Fallback to English when translation API fails
|
|
@@ -306,58 +327,71 @@ export default function Form({
|
|
|
306
327
|
} finally {
|
|
307
328
|
setIsTranslating(false);
|
|
308
329
|
}
|
|
309
|
-
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient,
|
|
330
|
+
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t]);
|
|
331
|
+
|
|
332
|
+
// Clear product / org-level selections when request type changes.
|
|
333
|
+
// Per-product state (selectedPurposes, selectedCorrectionElements,
|
|
334
|
+
// correctionValues) is owned by the seed effect below — clearing it
|
|
335
|
+
// here too would race with that effect's re-seed when skipProductStep
|
|
336
|
+
// is true (the sole group stays selected across request-type changes
|
|
337
|
+
// because the auto-select effect refires on requestType).
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!skipProductStep) {
|
|
340
|
+
setSelectedProductKey(null);
|
|
341
|
+
}
|
|
342
|
+
setNominationData({ nominee_email: '', nominee_mobile: '' });
|
|
310
343
|
|
|
311
|
-
|
|
344
|
+
if (
|
|
345
|
+
requestType === RequestTypeEnum.GRIEVANCE &&
|
|
346
|
+
grievanceOptions.length > 0
|
|
347
|
+
) {
|
|
348
|
+
const resetGrievances = grievanceOptions.reduce(
|
|
349
|
+
(acc, option) => {
|
|
350
|
+
acc[option.value] = false;
|
|
351
|
+
return acc;
|
|
352
|
+
},
|
|
353
|
+
{} as Record<string, boolean>,
|
|
354
|
+
);
|
|
355
|
+
setSelectedGrievances(resetGrievances);
|
|
356
|
+
}
|
|
357
|
+
}, [requestType, grievanceOptions, skipProductStep]);
|
|
358
|
+
|
|
359
|
+
// TECH-3712 UX: when `skipProductStep` is true (DP has exactly one
|
|
360
|
+
// consent group total), auto-select that group so the per-product
|
|
361
|
+
// picker renders directly without the user clicking a card. Placed
|
|
362
|
+
// AFTER the request-type-change clearing effect above so a request-type
|
|
363
|
+
// switch re-establishes the selection on the next tick. The child
|
|
364
|
+
// ProductSelectionSection has an equivalent mount-time effect, but
|
|
365
|
+
// that effect never fires here because the child is not rendered.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!skipProductStep) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const onlyGroup = allGroups[0];
|
|
371
|
+
if (!onlyGroup) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const key = productGroupKey(onlyGroup);
|
|
375
|
+
setSelectedProductKey((prev) => (prev === key ? prev : key));
|
|
376
|
+
}, [skipProductStep, allGroups, requestType]);
|
|
377
|
+
|
|
378
|
+
// Seed (and re-seed on product switch or request-type switch) the
|
|
379
|
+
// purpose / correction state from the currently-selected group.
|
|
380
|
+
// Single-select means one re-seed per product change — no Map
|
|
381
|
+
// reconciliation needed. Clears selections entirely when the user
|
|
382
|
+
// de-selects the chip (selectedGroup -> null) or when the request
|
|
383
|
+
// type switches into NOMINATION / GRIEVANCE.
|
|
312
384
|
useEffect(() => {
|
|
385
|
+
if (!selectedGroup || !isProductScopedRequestType(requestType)) {
|
|
386
|
+
setSelectedPurposes([]);
|
|
387
|
+
setSelectedCorrectionElements([]);
|
|
388
|
+
setCorrectionValues({});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
setSelectedPurposes(buildGroupPurposes(selectedGroup));
|
|
313
392
|
setSelectedCorrectionElements([]);
|
|
314
393
|
setCorrectionValues({});
|
|
315
|
-
|
|
316
|
-
if (data?.purposes && Array.isArray(data.purposes)) {
|
|
317
|
-
const extractedPurposes = data.purposes
|
|
318
|
-
.map((p) => p?.purpose)
|
|
319
|
-
.filter((p) => p !== null && p !== undefined);
|
|
320
|
-
|
|
321
|
-
// Ensure data_elements are properly preserved with all properties.
|
|
322
|
-
// Include data elements regardless of consent state (revocation ≠ deletion,
|
|
323
|
-
// TECH-4089); only drop purposes that genuinely have no data elements.
|
|
324
|
-
const processedPurposes = extractedPurposes
|
|
325
|
-
.map((p) => {
|
|
326
|
-
const validDataElements = Array.isArray(p.data_elements)
|
|
327
|
-
? p.data_elements
|
|
328
|
-
.filter((de) => de !== null && de !== undefined && de.uuid)
|
|
329
|
-
.map((de) => ({
|
|
330
|
-
...de,
|
|
331
|
-
selected: false,
|
|
332
|
-
}))
|
|
333
|
-
: [];
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
...p,
|
|
337
|
-
selected: false,
|
|
338
|
-
data_elements: validDataElements,
|
|
339
|
-
};
|
|
340
|
-
})
|
|
341
|
-
.filter((p) => p.data_elements.length > 0);
|
|
342
|
-
|
|
343
|
-
setAccessPurposes(processedPurposes);
|
|
344
|
-
setErasurePurposes(processedPurposes);
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
requestType === RequestTypeEnum.GRIEVANCE &&
|
|
348
|
-
grievanceOptions.length > 0
|
|
349
|
-
) {
|
|
350
|
-
const resetGrievances = grievanceOptions.reduce(
|
|
351
|
-
(acc, option) => {
|
|
352
|
-
acc[option.value] = false;
|
|
353
|
-
return acc;
|
|
354
|
-
},
|
|
355
|
-
{} as Record<string, boolean>
|
|
356
|
-
);
|
|
357
|
-
setSelectedGrievances(resetGrievances);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}, [requestType, data, grievanceOptions]);
|
|
394
|
+
}, [selectedGroup, requestType]);
|
|
361
395
|
|
|
362
396
|
|
|
363
397
|
// Fetch API translations when language changes (purposes, data elements, grievance options)
|
|
@@ -507,13 +541,40 @@ export default function Form({
|
|
|
507
541
|
if (!isFormValid) {
|
|
508
542
|
return;
|
|
509
543
|
}
|
|
510
|
-
|
|
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);
|
|
511
551
|
};
|
|
512
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Submit the form as a single case. Builds the payload from the
|
|
555
|
+
* currently-selected product group (for ACCESS/ERASURE/CORRECTION) or
|
|
556
|
+
* from the org-level state (for NOMINATION/GRIEVANCE), then POSTs once
|
|
557
|
+
* via `createCaseRequest`. The 1-form -> 1-case rule means no
|
|
558
|
+
* fan-out and no partial-failure UI to maintain.
|
|
559
|
+
*
|
|
560
|
+
* Anchors:
|
|
561
|
+
* - `product_uuid` is set ONLY for product-scoped request types and
|
|
562
|
+
* ONLY from `selectedGroup.product_uuid`. NOMINATION/GRIEVANCE
|
|
563
|
+
* never carry a product anchor.
|
|
564
|
+
* - `data_principal_uuid` is set ONLY for nominated cards (acting
|
|
565
|
+
* on someone else's behalf — the nominator's ConsentUser uuid).
|
|
566
|
+
* Direct cards omit the field so the backend defaults it from
|
|
567
|
+
* the JWT-derived requestor_uuid. `formData.uuid` is the
|
|
568
|
+
* FormDataOut session uuid, NOT the requestor's ConsentUser uuid,
|
|
569
|
+
* and would be rejected by the backend's ConsentUser lookup.
|
|
570
|
+
*/
|
|
513
571
|
const handleSubmit = async (revokeConsent: boolean = false) => {
|
|
514
|
-
if (!isFormValid){
|
|
515
|
-
|
|
516
|
-
|
|
572
|
+
if (!isFormValid) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const isProductScoped = isProductScopedRequestType(requestType);
|
|
577
|
+
const anchorGroup = isProductScoped ? selectedGroup : null;
|
|
517
578
|
|
|
518
579
|
const requestDetails: {
|
|
519
580
|
purposes: UpdatePurpose[];
|
|
@@ -526,19 +587,11 @@ export default function Form({
|
|
|
526
587
|
grievance_types: [],
|
|
527
588
|
};
|
|
528
589
|
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
data_element_uuids: p.data_elements
|
|
535
|
-
.filter((de) => de.selected)
|
|
536
|
-
.map((de) => de.uuid),
|
|
537
|
-
}));
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (requestType === RequestTypeEnum.ERASURE) {
|
|
541
|
-
requestDetails.purposes = erasurePurposes
|
|
590
|
+
if (
|
|
591
|
+
requestType === RequestTypeEnum.ACCESS ||
|
|
592
|
+
requestType === RequestTypeEnum.ERASURE
|
|
593
|
+
) {
|
|
594
|
+
requestDetails.purposes = selectedPurposes
|
|
542
595
|
.filter((p) => p.selected || p.data_elements.some((de) => de.selected))
|
|
543
596
|
.map((p) => ({
|
|
544
597
|
purpose_uuid: p.uuid,
|
|
@@ -552,9 +605,9 @@ export default function Form({
|
|
|
552
605
|
requestDetails.correction_data = selectedCorrectionElements.map(
|
|
553
606
|
(name) => ({
|
|
554
607
|
name: getEnglishDataElementName(name),
|
|
555
|
-
curr_value: correctionValues[name]?.current
|
|
556
|
-
new_value: correctionValues[name]?.updated
|
|
557
|
-
})
|
|
608
|
+
curr_value: correctionValues[name]?.current ?? '',
|
|
609
|
+
new_value: correctionValues[name]?.updated ?? '',
|
|
610
|
+
}),
|
|
558
611
|
);
|
|
559
612
|
}
|
|
560
613
|
|
|
@@ -590,6 +643,10 @@ export default function Form({
|
|
|
590
643
|
...(requestType === RequestTypeEnum.ERASURE
|
|
591
644
|
? { revoke_consent_on_fulfilment: revokeConsent }
|
|
592
645
|
: {}),
|
|
646
|
+
...(anchorGroup ? { product_uuid: anchorGroup.product_uuid } : {}),
|
|
647
|
+
...(anchorGroup?.nominator
|
|
648
|
+
? { data_principal_uuid: anchorGroup.nominator.uuid }
|
|
649
|
+
: {}),
|
|
593
650
|
};
|
|
594
651
|
|
|
595
652
|
try {
|
|
@@ -601,92 +658,107 @@ export default function Form({
|
|
|
601
658
|
isPopup,
|
|
602
659
|
isCaseManagementContext, // Use session auth if in case management context
|
|
603
660
|
authContext,
|
|
604
|
-
consentClient
|
|
661
|
+
consentClient,
|
|
605
662
|
);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
// Case management returns the full CreateCaseResponse with { code, status, detail: { uuid, case_id } }
|
|
609
|
-
let caseId: string | null = null;
|
|
610
|
-
if (response) {
|
|
611
|
-
if ('detail' in response && response.detail) {
|
|
612
|
-
// Case management: wrapped response
|
|
613
|
-
caseId =
|
|
614
|
-
(response.detail as { case_id?: string; uuid?: string }).case_id ||
|
|
615
|
-
(response.detail as { case_id?: string; uuid?: string }).uuid ||
|
|
616
|
-
null;
|
|
617
|
-
} else if ('case_id' in response || 'uuid' in response) {
|
|
618
|
-
// Privacy center: unwrapped response
|
|
619
|
-
caseId =
|
|
620
|
-
(response as { case_id?: string; uuid?: string }).case_id ||
|
|
621
|
-
(response as { case_id?: string; uuid?: string }).uuid ||
|
|
622
|
-
null;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
663
|
+
const extracted = extractCaseId(response);
|
|
664
|
+
const caseId = extracted?.case_id || extracted?.uuid || null;
|
|
625
665
|
|
|
626
666
|
if (caseId) {
|
|
627
667
|
queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
|
|
628
|
-
|
|
668
|
+
setSubmittedCase({
|
|
669
|
+
case_id: caseId,
|
|
670
|
+
uuid: extracted?.uuid ?? caseId,
|
|
671
|
+
product_name: anchorGroup?.product_name ?? '',
|
|
672
|
+
product_uuid: anchorGroup?.product_uuid ?? '',
|
|
673
|
+
});
|
|
629
674
|
setIsSubmitted(true);
|
|
630
675
|
toast.success(t('requestSubmittedSuccess'));
|
|
631
676
|
|
|
632
677
|
if (isPopup) {
|
|
633
678
|
onCreateRequest?.();
|
|
679
|
+
// Only dismiss the popup on success. Closing in `finally`
|
|
680
|
+
// would discard the operator's filled form on a transient
|
|
681
|
+
// failure with no way to retry.
|
|
682
|
+
onClose?.();
|
|
634
683
|
}
|
|
635
684
|
} else {
|
|
636
685
|
toast.error(t('requestSubmissionFailed'));
|
|
637
686
|
}
|
|
638
687
|
} catch (error) {
|
|
688
|
+
// `createCaseRequest` returns null on non-auth errors (per its
|
|
689
|
+
// documented contract) — those land in the `else` branch above
|
|
690
|
+
// with a generic toast. This catch only fires on unexpected
|
|
691
|
+
// exceptions; surface whatever message the error carries.
|
|
639
692
|
console.error('Error submitting request:', error);
|
|
640
|
-
|
|
693
|
+
const message =
|
|
694
|
+
error instanceof Error && error.message
|
|
695
|
+
? error.message
|
|
696
|
+
: t('submissionFailed');
|
|
697
|
+
toast.error(message);
|
|
641
698
|
} finally {
|
|
642
699
|
setIsLoading(false);
|
|
643
|
-
if (isPopup) {
|
|
644
|
-
onClose?.();
|
|
645
|
-
}
|
|
646
700
|
}
|
|
647
701
|
};
|
|
648
702
|
|
|
649
703
|
const isRequestDetailsValid = useCallback(() => {
|
|
650
|
-
if (!requestType){
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
switch (requestType) {
|
|
654
|
-
case RequestTypeEnum.ACCESS:
|
|
655
|
-
return accessPurposes.some(
|
|
656
|
-
(p) => p.selected || p.data_elements.some((de) => de.selected)
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
case RequestTypeEnum.ERASURE:
|
|
660
|
-
return erasurePurposes.some(
|
|
661
|
-
(p) => p.selected || p.data_elements.some((de) => de.selected)
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
case RequestTypeEnum.GRIEVANCE:
|
|
665
|
-
return Object.values(selectedGrievances).some(Boolean);
|
|
704
|
+
if (!requestType) {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
666
707
|
|
|
667
|
-
|
|
708
|
+
// Product-anchor validity gate: ACCESS/ERASURE/CORRECTION require a
|
|
709
|
+
// selected product before submit is allowed. The backend leaves
|
|
710
|
+
// `product_uuid` Optional for back-compat, so we enforce the anchor
|
|
711
|
+
// on the client — a product-scoped case without an anchor would
|
|
712
|
+
// land as an org-level case with no scoping, which violates the
|
|
713
|
+
// 1-form -> 1-case -> 1-product contract.
|
|
714
|
+
if (isProductScopedRequestType(requestType)) {
|
|
715
|
+
if (allGroups.length === 0) {
|
|
716
|
+
// No consent groups for this DP at all — the picker shows its
|
|
717
|
+
// empty state and we block submit. The DP has nothing they can
|
|
718
|
+
// raise a product-scoped case against.
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
if (!selectedGroup) {
|
|
722
|
+
// Groups exist but the user hasn't picked one. With the
|
|
723
|
+
// single-group auto-select effect this should be rare in
|
|
724
|
+
// practice, but the gate must still reject.
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
if (requestType === RequestTypeEnum.CORRECTION) {
|
|
668
728
|
return (
|
|
669
729
|
selectedCorrectionElements.length > 0 &&
|
|
670
730
|
selectedCorrectionElements.every(
|
|
671
731
|
(name) =>
|
|
672
732
|
correctionValues[name]?.current?.trim() &&
|
|
673
|
-
correctionValues[name]?.updated?.trim()
|
|
733
|
+
correctionValues[name]?.updated?.trim(),
|
|
674
734
|
)
|
|
675
735
|
);
|
|
736
|
+
}
|
|
737
|
+
return selectedPurposes.some(
|
|
738
|
+
(p) => p.selected || p.data_elements.some((de) => de.selected),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
switch (requestType) {
|
|
743
|
+
case RequestTypeEnum.GRIEVANCE:
|
|
744
|
+
return Object.values(selectedGrievances).some(Boolean);
|
|
676
745
|
|
|
677
746
|
case RequestTypeEnum.NOMINATION:
|
|
678
|
-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
|
747
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
|
748
|
+
nominationData.nominee_email.trim(),
|
|
749
|
+
);
|
|
679
750
|
|
|
680
751
|
default:
|
|
681
752
|
return false;
|
|
682
753
|
}
|
|
683
754
|
}, [
|
|
684
755
|
requestType,
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
756
|
+
allGroups,
|
|
757
|
+
selectedGroup,
|
|
758
|
+
selectedPurposes,
|
|
688
759
|
selectedCorrectionElements,
|
|
689
760
|
correctionValues,
|
|
761
|
+
selectedGrievances,
|
|
690
762
|
nominationData,
|
|
691
763
|
]);
|
|
692
764
|
|
|
@@ -882,9 +954,9 @@ export default function Form({
|
|
|
882
954
|
|
|
883
955
|
return (
|
|
884
956
|
<>
|
|
885
|
-
{isSubmitted && !isPopup ? (
|
|
957
|
+
{isSubmitted && submittedCase && !isPopup ? (
|
|
886
958
|
<RequestSubmit
|
|
887
|
-
|
|
959
|
+
result={submittedCase}
|
|
888
960
|
contact={formData?.contact}
|
|
889
961
|
onCreateRequest={onCreateRequest}
|
|
890
962
|
/>
|
|
@@ -1090,31 +1162,102 @@ export default function Form({
|
|
|
1090
1162
|
<div style={{
|
|
1091
1163
|
padding: getVar('--pc-spacing-md'),
|
|
1092
1164
|
}}>
|
|
1093
|
-
{requestType
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1165
|
+
{isProductScopedRequestType(requestType) ? (
|
|
1166
|
+
<div
|
|
1167
|
+
style={{
|
|
1168
|
+
...pcStyles.flex,
|
|
1169
|
+
...pcStyles.flexCol,
|
|
1170
|
+
gap: getVar('--pc-spacing-md'),
|
|
1171
|
+
}}
|
|
1172
|
+
>
|
|
1173
|
+
{!skipProductStep && (
|
|
1174
|
+
<ProductSelectionSection
|
|
1175
|
+
directGroups={data?.direct_groups ?? []}
|
|
1176
|
+
nominatedGroups={data?.nominated_groups ?? []}
|
|
1177
|
+
selectedProductKey={selectedProductKey}
|
|
1178
|
+
onSelect={handleProductSelect}
|
|
1179
|
+
requestTypeLabel={requestTypeLabels[requestType]}
|
|
1180
|
+
isTranslating={isTranslating}
|
|
1181
|
+
/>
|
|
1182
|
+
)}
|
|
1183
|
+
{selectedGroup && (() => {
|
|
1184
|
+
const key = productGroupKey(selectedGroup);
|
|
1185
|
+
const isCorrection =
|
|
1186
|
+
requestType === RequestTypeEnum.CORRECTION;
|
|
1187
|
+
const groupDataElements = isCorrection
|
|
1188
|
+
? buildGroupDataElements(selectedGroup)
|
|
1189
|
+
: [];
|
|
1190
|
+
const nominatorLabel = selectedGroup.nominator
|
|
1191
|
+
? selectedGroup.nominator.name ??
|
|
1192
|
+
selectedGroup.nominator.email ??
|
|
1193
|
+
''
|
|
1194
|
+
: '';
|
|
1195
|
+
|
|
1196
|
+
return (
|
|
1197
|
+
<div
|
|
1198
|
+
className="pc-product-purpose-panel"
|
|
1199
|
+
data-testid={`per-product-purposes-${key}`}
|
|
1200
|
+
>
|
|
1201
|
+
<div className="pc-product-purpose-panel-header">
|
|
1202
|
+
<div className="pc-product-purpose-panel-title-row">
|
|
1203
|
+
<PCText
|
|
1204
|
+
as="span"
|
|
1205
|
+
size="base"
|
|
1206
|
+
weight="semibold"
|
|
1207
|
+
color="primary"
|
|
1208
|
+
className="pc-product-purpose-panel-title"
|
|
1209
|
+
>
|
|
1210
|
+
{selectedGroup.product_name}
|
|
1211
|
+
</PCText>
|
|
1212
|
+
{selectedGroup.nominator && (
|
|
1213
|
+
<PCText
|
|
1214
|
+
as="span"
|
|
1215
|
+
size="xs"
|
|
1216
|
+
weight="medium"
|
|
1217
|
+
color="secondary"
|
|
1218
|
+
className="pc-product-purpose-panel-nominator"
|
|
1219
|
+
>
|
|
1220
|
+
{t('actingOnBehalfOfShort', {
|
|
1221
|
+
nominator: nominatorLabel,
|
|
1222
|
+
})}
|
|
1223
|
+
</PCText>
|
|
1224
|
+
)}
|
|
1225
|
+
</div>
|
|
1226
|
+
<PCText
|
|
1227
|
+
as="span"
|
|
1228
|
+
size="xs"
|
|
1229
|
+
color="secondary"
|
|
1230
|
+
className="pc-product-purpose-panel-subtitle"
|
|
1231
|
+
>
|
|
1232
|
+
{t('selectPurposesForThisProduct')}
|
|
1233
|
+
</PCText>
|
|
1234
|
+
</div>
|
|
1235
|
+
<div
|
|
1236
|
+
className="pc-product-purpose-panel-body"
|
|
1237
|
+
data-testid={`per-product-purposes-body-${key}`}
|
|
1238
|
+
>
|
|
1239
|
+
{isCorrection ? (
|
|
1240
|
+
<CorrectionDataSection
|
|
1241
|
+
dataElements={groupDataElements}
|
|
1242
|
+
selected={selectedCorrectionElements}
|
|
1243
|
+
setSelected={setSelectedCorrectionElements}
|
|
1244
|
+
correctionValues={correctionValues}
|
|
1245
|
+
setCorrectionValues={setCorrectionValues}
|
|
1246
|
+
isTranslating={isTranslating}
|
|
1247
|
+
/>
|
|
1248
|
+
) : (
|
|
1249
|
+
<PurposeSelectionSection
|
|
1250
|
+
purposes={selectedPurposes}
|
|
1251
|
+
setPurposes={setSelectedPurposes}
|
|
1252
|
+
requestType={requestType}
|
|
1253
|
+
isTranslating={isTranslating}
|
|
1254
|
+
/>
|
|
1255
|
+
)}
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
);
|
|
1259
|
+
})()}
|
|
1260
|
+
</div>
|
|
1118
1261
|
) : requestType === RequestTypeEnum.GRIEVANCE ? (
|
|
1119
1262
|
<GrievanceDataSection
|
|
1120
1263
|
grievanceOptions={grievanceOptions}
|