@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.
Files changed (51) hide show
  1. package/.turbo/turbo-build.log +15 -10
  2. package/CHANGELOG.md +12 -0
  3. package/dist/privacy-center.d.mts +51 -1
  4. package/dist/privacy-center.d.ts +51 -1
  5. package/dist/privacy-center.js +3463 -1148
  6. package/dist/privacy-center.mjs +3399 -1084
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  9. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  10. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  11. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  12. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  13. package/src/RedactoPrivacyCenter/components/Form.tsx +402 -259
  14. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  15. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +95 -57
  16. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  17. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  18. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  19. package/src/RedactoPrivacyCenter/components/types.ts +20 -2
  20. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  21. package/src/RedactoPrivacyCenter/lib/api.ts +67 -8
  22. package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
  23. package/src/RedactoPrivacyCenter/lib/types.ts +74 -0
  24. package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
  25. package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
  26. package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
  27. package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
  28. package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
  29. package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
  30. package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
  31. package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
  32. package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
  33. package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
  34. package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
  35. package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
  36. package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
  37. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
  38. package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
  39. package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
  40. package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
  41. package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
  42. package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
  43. package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
  44. package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
  45. package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
  46. package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
  47. package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
  48. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  49. package/tests/Form.test.tsx +1144 -0
  50. package/tests/ProductSelectionSection.test.tsx +328 -0
  51. 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
- DataElement,
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
- const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
93
- const [accessPurposes, setAccessPurposes] = useState<Purpose[]>([]);
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
- const [caseId, setCaseId] = useState<string | null>(null);
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
- useEffect(() => {
143
- if (data?.purposes) {
144
- // Validate purposes array structure
145
- if (!Array.isArray(data.purposes)) {
146
- console.warn('Form data purposes is not an array:', data.purposes);
147
- return;
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
- setUniqueDataElements(
184
- Array.from(uniqueElements.values()) as DataElement[]
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
- }, [data]);
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, accessPurposes, erasurePurposes, selectedGrievances, t]);
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
- // Clear previous selections when request type changes
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
- setNominationData({ nominee_email: '', nominee_mobile: '' });
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
- void handleSubmit(false);
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
- return
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 (requestType === RequestTypeEnum.ACCESS) {
530
- requestDetails.purposes = accessPurposes
531
- .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
532
- .map((p) => ({
533
- purpose_uuid: p.uuid,
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
- // privacyCenterApiFetch unwraps { code, status, detail } responses and returns just the detail
607
- // So response will be { uuid, case_id } if successful (privacy center), or null if error
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
- setCaseId(caseId);
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
- toast.error(t('submissionFailed'));
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
- return false
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
- case RequestTypeEnum.CORRECTION:
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(nominationData.nominee_email.trim());
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
- accessPurposes,
686
- erasurePurposes,
687
- selectedGrievances,
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
- caseId={caseId || ''}
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 === RequestTypeEnum.ACCESS ||
1094
- requestType === RequestTypeEnum.ERASURE ? (
1095
- <PurposeSelectionSection
1096
- purposes={
1097
- requestType === RequestTypeEnum.ACCESS
1098
- ? accessPurposes
1099
- : erasurePurposes
1100
- }
1101
- setPurposes={
1102
- requestType === RequestTypeEnum.ACCESS
1103
- ? setAccessPurposes
1104
- : setErasurePurposes
1105
- }
1106
- requestType={requestType}
1107
- isTranslating={isTranslating}
1108
- />
1109
- ) : requestType === RequestTypeEnum.CORRECTION ? (
1110
- <CorrectionDataSection
1111
- dataElements={uniqueDataElements}
1112
- selected={selectedCorrectionElements}
1113
- setSelected={setSelectedCorrectionElements}
1114
- correctionValues={correctionValues}
1115
- setCorrectionValues={setCorrectionValues}
1116
- isTranslating={isTranslating}
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}