@redacto.io/consent-sdk-react 10.0.0-beta.1 → 10.0.0-beta.11

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 (84) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +32 -4
  6. package/dist/index.d.ts +32 -4
  7. package/dist/index.js +340 -230
  8. package/dist/index.mjs +339 -231
  9. package/dist/privacy-center.d.mts +55 -12
  10. package/dist/privacy-center.d.ts +55 -12
  11. package/dist/privacy-center.js +848 -950
  12. package/dist/privacy-center.mjs +698 -802
  13. package/package.json +1 -1
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
  15. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
  16. package/src/RedactoNoticeAssisted/api/index.ts +7 -1
  17. package/src/RedactoNoticeAssisted/api/types.ts +2 -0
  18. package/src/RedactoNoticeAssisted/i18n.ts +0 -55
  19. package/src/RedactoNoticeAssisted/types.ts +2 -0
  20. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
  21. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +74 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +88 -175
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +58 -43
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +28 -2
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +85 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +10 -1
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +2 -0
  31. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  32. package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
  33. package/src/RedactoPrivacyCenter/api/client.ts +122 -25
  34. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  35. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  36. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +47 -45
  37. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  38. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  39. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  40. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +25 -13
  41. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -6
  42. package/src/RedactoPrivacyCenter/components/constant.ts +3 -3
  43. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  44. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  45. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  46. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  47. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  48. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  49. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  50. package/src/RedactoPrivacyCenter/lib/types.ts +25 -1
  51. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  52. package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -3
  53. package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -3
  54. package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -3
  55. package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -3
  56. package/src/RedactoPrivacyCenter/locales/en/translation.json +4 -3
  57. package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -3
  58. package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -3
  59. package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -3
  60. package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -3
  61. package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -3
  62. package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -3
  63. package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -3
  64. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -3
  65. package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -3
  66. package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -3
  67. package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -3
  68. package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -3
  69. package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -3
  70. package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -3
  71. package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -3
  72. package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -3
  73. package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -3
  74. package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -3
  75. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
  76. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  77. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  78. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  79. package/src/shared/notice-consent-status.ts +190 -0
  80. package/src/shared/sandbox.ts +6 -0
  81. package/tests/Form.test.tsx +142 -5
  82. package/tests/RevokeWarningMessage.test.tsx +151 -0
  83. package/tests/consentClient.test.ts +46 -0
  84. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -340
@@ -10,10 +10,10 @@ 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
12
  import { PCModal } from '../ui/PCModal';
13
- import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup } from './groupHelpers';
13
+ import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup, legacyFlatGroup } from './groupHelpers';
14
14
  import { pcStyles } from '../styles/pcStyles';
15
15
  import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
16
- import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
16
+ import { FORM_PICKER_GROUP_LIMIT, LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
17
17
  import { ensureValidToken } from '../lib/utils';
18
18
  import { useAuth } from '../context/AuthContext';
19
19
  import { useBaseUrl } from '../context/BaseUrlContext';
@@ -143,7 +143,7 @@ export default function Form({
143
143
  const [isSubmitted, setIsSubmitted] = useState(false);
144
144
  const { t } = useTranslation();
145
145
  const { accessToken, refreshToken, onError, updateTokens } = useAuth();
146
- const { consentClient, baseUrl, ledgerBaseUrl } = useBaseUrl();
146
+ const { consentClient, baseUrl } = useBaseUrl();
147
147
  const organizationId = organization?.uuid;
148
148
  const workspaceId = organization?.workspaces[0]?.uuid;
149
149
 
@@ -235,12 +235,31 @@ export default function Form({
235
235
 
236
236
  // Union of direct + nominated product groups exposed by the backend.
237
237
  // Drives both the product-cards step and the per-product picker beneath.
238
+ // When the backend exposes no groups at all but did return the legacy flat
239
+ // `purposes` list (pre-grouping Python deployments, and the popup /
240
+ // case-management contexts that cannot read user-consents), fall back to a
241
+ // single synthetic group so the purposes stay reachable instead of the
242
+ // picker claiming the principal has no consents.
238
243
  const allGroups = useMemo<FormProductGroup[]>(() => {
239
244
  if (!data) {
240
245
  return [];
241
246
  }
242
- return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
243
- }, [data]);
247
+ const groups = [
248
+ ...(data.direct_groups ?? []),
249
+ ...(data.nominated_groups ?? []),
250
+ ];
251
+ if (groups.length > 0) {
252
+ return groups;
253
+ }
254
+ const flatGroup = legacyFlatGroup(data, t('yourData'));
255
+ // `buildGroupPurposes` is what the panel renders from — it drops purposes
256
+ // with no data elements, so a flat list that survives nothing must keep
257
+ // showing the empty state rather than an unusable panel.
258
+ if (buildGroupPurposes(flatGroup).length === 0) {
259
+ return [];
260
+ }
261
+ return [flatGroup];
262
+ }, [data, t]);
244
263
 
245
264
  // TECH-3712 UX: when the DP has exactly one consent group total
246
265
  // (direct + nominated combined), the product-selection step is friction —
@@ -275,21 +294,22 @@ export default function Form({
275
294
  [],
276
295
  );
277
296
 
278
- // Approach A: source the DSAR request-form product picker from the Go
279
- // ledger's user-consents. After the consent cutover, consents live in the
280
- // ledger, so Python's form/data returns no product groups; we overlay the
281
- // ledger's product-grouped consents onto the form data instead. Only the
282
- // end-user privacy-center path is eligible — popup / case-management resolve
283
- // an arbitrary contact, which the JWT-scoped ledger endpoint cannot serve,
284
- // so those keep the Python form/data groups. Returns the form data unchanged
285
- // when ineligible or when the ledger read fails (graceful fallback to
286
- // whatever groups form/data supplied). Without a configured ledgerBaseUrl,
287
- // consents still live in Python and form/data groups are authoritative, so
288
- // the overlay must not run (fetchUserConsents would return the legacy Python
289
- // shape, which consentGroupToFormProductGroup does not map).
290
- const withLedgerPickerGroups = useCallback(
297
+ // Approach A: source the DSAR request-form product picker from user-consents
298
+ // rather than from Python's form/data, which stopped emitting product groups
299
+ // after the consent cutover. `consentClient.getUserConsents` serves the same
300
+ // grouped payload from either backend — the Go ledger's
301
+ // `/public/.../user-consents` when a ledgerBaseUrl is configured, Python's
302
+ // `/dsar/privacy-center/user-consents` otherwise (the same source the
303
+ // Manage Consent view reads in both modes) — so the overlay runs regardless
304
+ // of which backend the SDK points at. Only the end-user privacy-center path
305
+ // is eligible: popup / case-management resolve an arbitrary contact, which
306
+ // the JWT-scoped user-consents endpoint cannot serve, so those keep the
307
+ // form/data groups. Returns the form data unchanged when ineligible, when
308
+ // the read fails, or when it comes back with no groups at all — an empty
309
+ // result must never overwrite groups form/data did supply.
310
+ const withUserConsentPickerGroups = useCallback(
291
311
  async (form: FormData, language?: string): Promise<FormData> => {
292
- if (!ledgerBaseUrl || isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
312
+ if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
293
313
  return form;
294
314
  }
295
315
  const consents = await fetchUserConsents({
@@ -304,19 +324,18 @@ export default function Form({
304
324
  nominatedLimit: FORM_PICKER_GROUP_LIMIT,
305
325
  language,
306
326
  });
307
- if (!consents) {
327
+ const direct = consents?.direct ?? [];
328
+ const nominated = consents?.nominated ?? [];
329
+ if (direct.length === 0 && nominated.length === 0) {
308
330
  return form;
309
331
  }
310
332
  return {
311
333
  ...form,
312
- direct_groups: (consents.direct ?? []).map(consentGroupToFormProductGroup),
313
- nominated_groups: (consents.nominated ?? []).map(
314
- consentGroupToFormProductGroup,
315
- ),
334
+ direct_groups: direct.map(consentGroupToFormProductGroup),
335
+ nominated_groups: nominated.map(consentGroupToFormProductGroup),
316
336
  };
317
337
  },
318
338
  [
319
- ledgerBaseUrl,
320
339
  isPopup,
321
340
  isCaseManagementContext,
322
341
  organizationId,
@@ -366,7 +385,7 @@ export default function Form({
366
385
  return;
367
386
  }
368
387
 
369
- setData(await withLedgerPickerGroups(translatedFormData, langValue));
388
+ setData(await withUserConsentPickerGroups(translatedFormData, langValue));
370
389
 
371
390
  if (translatedFormData.grievance_options) {
372
391
  const currentGrievanceSelections = { ...selectedGrievances };
@@ -385,7 +404,7 @@ export default function Form({
385
404
  } finally {
386
405
  setIsTranslating(false);
387
406
  }
388
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withLedgerPickerGroups]);
407
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withUserConsentPickerGroups]);
389
408
 
390
409
  // Clear product / org-level selections when request type changes.
391
410
  // Per-product state (selectedPurposes, selectedCorrectionElements,
@@ -538,7 +557,7 @@ export default function Form({
538
557
  }
539
558
  }
540
559
 
541
- const pickerData = await withLedgerPickerGroups(
560
+ const pickerData = await withUserConsentPickerGroups(
542
561
  displayData,
543
562
  langParam || undefined,
544
563
  );
@@ -565,7 +584,7 @@ export default function Form({
565
584
  };
566
585
 
567
586
  void initializePrivacyCenterForm();
568
- }, [organizationId, workspaceId, isPopup, authContext, consentClient, withLedgerPickerGroups]);
587
+ }, [organizationId, workspaceId, isPopup, authContext, consentClient, withUserConsentPickerGroups]);
569
588
 
570
589
  /**
571
590
  * Maps translated correction element names back to English names using UUID lookup.
@@ -671,7 +690,13 @@ export default function Form({
671
690
  }
672
691
 
673
692
  const isProductScoped = isProductScopedRequestType(requestType);
674
- const anchorGroup = isProductScoped ? selectedGroup : null;
693
+ // The synthetic legacy-flat group has no server-side product, so it must
694
+ // not anchor the case — those submissions keep the pre-grouping
695
+ // org-level shape (`product_uuid` is optional on the backend).
696
+ const isLegacyFlatSelection =
697
+ selectedGroup?.product_uuid === LEGACY_PRODUCT_GROUP_UUID;
698
+ const anchorGroup =
699
+ isProductScoped && !isLegacyFlatSelection ? selectedGroup : null;
675
700
 
676
701
  const requestDetails: {
677
702
  purposes: UpdatePurpose[];
@@ -32,7 +32,7 @@ export const ModifyConsentModal = ({
32
32
  action: 'revoke' as ConsentAction,
33
33
  buttonLabel: t('revokeConsent'),
34
34
  buttonVariant: 'danger' as const,
35
- warningMessage: t('revokeConsentWarning'),
35
+ warningMessage: selectedRecord.revoke_warning_message || t('revokeConsentWarning'),
36
36
  description: nominatorLabel
37
37
  ? t('aboutToModifyConsentOnBehalf', { nominator: nominatorLabel })
38
38
  : t('aboutToModifyConsent'),
@@ -11,16 +11,12 @@ import type { SelectUserProps } from '../lib/types';
11
11
  * (.pc-select-user*).
12
12
  */
13
13
  export const SelectUser = ({
14
- email,
15
14
  candidates,
16
15
  onSelect,
17
16
  busyOrgUserId,
18
17
  error,
19
18
  }: SelectUserProps) => {
20
19
  const { t } = useTranslation();
21
- const subtitle = email
22
- ? t('emailLinkedToMultipleProfiles', { email })
23
- : t('contactLinkedToMultipleProfiles');
24
20
 
25
21
  // A candidate with no uuid can't be selected (it's the value we submit),
26
22
  // so drop it rather than render an unclickable row.
@@ -31,17 +27,33 @@ export const SelectUser = ({
31
27
  <div className="pc-select-user__header">
32
28
  <PCTitle as="h2">{t('chooseYourProfile')}</PCTitle>
33
29
  <PCText as="p" size="base" color="secondary" align="center">
34
- {subtitle}
30
+ {t('loginIdentifierLinkedToMultipleProfiles')}
35
31
  </PCText>
36
32
  </div>
37
33
 
38
34
  <div className="pc-select-user__list">
39
35
  {selectable.map((candidate) => {
40
36
  const principalUuid = candidate.uuid || '';
41
- const primaryLabel =
42
- candidate.primary_email || candidate.primary_mobile || candidate.uuid;
43
- const secondaryLabel =
44
- candidate.primary_mobile || candidate.primary_email || '';
37
+ const email = candidate.primary_email || '';
38
+ const mobile = candidate.primary_mobile || '';
39
+ const orgUserId = candidate.org_user_id || '';
40
+ // org_user_id is unique per principal (ledger-enforced within the
41
+ // org/workspace), so it leads the row as the reliable way to tell
42
+ // shared-contact profiles apart when the email/mobile are identical.
43
+ // Fall back to a contact when a principal has no org_user_id, so the
44
+ // row still shows something identifiable.
45
+ const primaryLabel = orgUserId
46
+ ? t('userIdLabel', { id: orgUserId })
47
+ : email || mobile || candidate.uuid;
48
+ // Email and mobile drop below the id. Skip any value that already
49
+ // surfaced as the primary label (the fallback case) or duplicates the
50
+ // other contact, so we never echo the same value twice.
51
+ const secondaryLabels = [email, mobile].filter(
52
+ (value, index, all) =>
53
+ value !== '' &&
54
+ value !== primaryLabel &&
55
+ all.indexOf(value) === index
56
+ );
45
57
  const isBusy = busyOrgUserId === principalUuid;
46
58
  return (
47
59
  <button
@@ -55,11 +67,11 @@ export const SelectUser = ({
55
67
  <span className="pc-select-user__item-primary">
56
68
  {primaryLabel}
57
69
  </span>
58
- {secondaryLabel ? (
59
- <span className="pc-select-user__item-secondary">
60
- {secondaryLabel}
70
+ {secondaryLabels.map((label) => (
71
+ <span key={label} className="pc-select-user__item-secondary">
72
+ {label}
61
73
  </span>
62
- ) : null}
74
+ ))}
63
75
  </span>
64
76
  {isBusy ? (
65
77
  <span className="pc-select-user__item-status">
@@ -12,6 +12,7 @@ import { useAuth } from '../context/AuthContext';
12
12
  import { ProfileSwitchProvider } from '../context/ProfileSwitchContext';
13
13
  import { PCLoader } from '../ui';
14
14
  import { decodeJwtToken } from '../lib/utils';
15
+ import { isSandboxMode } from '../lib/sandbox';
15
16
  import type {
16
17
  GatePhase,
17
18
  IdentityCandidate,
@@ -54,6 +55,13 @@ export const SelectUserGate = ({ children }: SelectUserGateProps) => {
54
55
  useEffect(() => {
55
56
  let cancelled = false;
56
57
 
58
+ // Sandbox PoC: there is no JWT and no shared-contact concept — the subject is
59
+ // fixed by props. Skip the identity lookup and render children directly.
60
+ if (isSandboxMode()) {
61
+ setPhase('resolved');
62
+ return;
63
+ }
64
+
57
65
  if (!organizationId || !workspaceId || !accessToken) {
58
66
  setPhase('resolved');
59
67
  return;
@@ -171,14 +179,8 @@ export const SelectUserGate = ({ children }: SelectUserGateProps) => {
171
179
  </div>
172
180
  );
173
181
  } else if (phase === 'picking') {
174
- const decoded = decodeJwtToken(accessToken);
175
- const email =
176
- (decoded?.user_data?.primary_email as string) ||
177
- (decoded?.primary_email as string) ||
178
- null;
179
182
  inner = (
180
183
  <SelectUser
181
- email={email}
182
184
  candidates={candidates}
183
185
  onSelect={handleSelect}
184
186
  busyOrgUserId={busyOrgUserId}
@@ -47,6 +47,6 @@ export const ACTIVITY_FETCH_ERROR_MESSAGE = 'Failed to fetch activity data';
47
47
  export const PICKED_ORG_USER_KEY = 'pc_picked_org_user_id';
48
48
 
49
49
  // The shared-contact picker/switch user-facing strings are localized via i18n —
50
- // see the chooseYourProfile / emailLinkedToMultipleProfiles /
51
- // contactLinkedToMultipleProfiles / signingInEllipsis / couldNotSwitchProfile /
52
- // switchProfile keys in locales/*/translation.json.
50
+ // see the chooseYourProfile / loginIdentifierLinkedToMultipleProfiles /
51
+ // signingInEllipsis / couldNotSwitchProfile / switchProfile keys in
52
+ // locales/*/translation.json.
@@ -1,17 +1,21 @@
1
+ import { LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
1
2
  import type {
2
3
  ConsentDataElement,
3
4
  ConsentGroup,
4
5
  DataElement,
6
+ FormData,
5
7
  FormProductGroup,
6
8
  Purpose,
7
9
  PurposeItem,
8
10
  } from '../lib/types';
9
11
 
10
12
  /**
11
- * Map a Go-ledger `ConsentGroup` (from the user-consents endpoint) onto the
12
- * DSAR request form's `FormProductGroup`. Approach A: the request-form product
13
- * picker is now sourced from the ledger instead of Python's `form/data`, since
14
- * consents live in the ledger after the cutover.
13
+ * Map a `ConsentGroup` (from the user-consents endpoint — served by the Go
14
+ * ledger, or by Python's `/dsar/privacy-center/user-consents` when no
15
+ * ledgerBaseUrl is configured) onto the DSAR request form's
16
+ * `FormProductGroup`. Approach A: the request-form product picker is sourced
17
+ * from user-consents instead of Python's `form/data`, since after the cutover
18
+ * `form/data` no longer emits product groups.
15
19
  *
16
20
  * The shapes are near-identical; the only adaptations are:
17
21
  * - purpose id rename: ledger `purpose_uuid` -> form `uuid` (which the picker
@@ -36,8 +40,11 @@ export const consentGroupToFormProductGroup = (
36
40
  });
37
41
 
38
42
  const purposeItemToFormPurpose = (purpose: PurposeItem): Purpose => ({
39
- uuid: purpose.purpose_uuid,
40
- purpose_uuid: purpose.purpose_uuid,
43
+ // The ledger keys purposes as `purpose_uuid`; the legacy Python
44
+ // user-consents payload keys some of them as `uuid`. Accept either so the
45
+ // picker and the submitted `purpose_uuid` resolve on both backends.
46
+ uuid: purpose.purpose_uuid ?? purpose.uuid ?? '',
47
+ purpose_uuid: purpose.purpose_uuid ?? purpose.uuid ?? '',
41
48
  name: purpose.name,
42
49
  description: purpose.description ?? '',
43
50
  industries: '',
@@ -51,6 +58,35 @@ const purposeItemToFormPurpose = (purpose: PurposeItem): Purpose => ({
51
58
  ),
52
59
  });
53
60
 
61
+ /**
62
+ * Build a single synthetic product group from a backend's legacy flat
63
+ * `form/data.purposes` list.
64
+ *
65
+ * Used as the last fallback for the DSAR picker: pre-grouping Python
66
+ * deployments (and the popup / case-management contexts, which resolve an
67
+ * arbitrary contact that the JWT-scoped user-consents endpoint cannot serve)
68
+ * return purposes without any product grouping. Rendering them under one
69
+ * group keeps the picker usable instead of showing "No products available"
70
+ * while the response actually carries consents — it is the lone group, so the
71
+ * product-selection step is skipped and the purpose list renders directly,
72
+ * matching the pre-grouping UX.
73
+ *
74
+ * The group carries `LEGACY_PRODUCT_GROUP_UUID`, which the submit path uses to
75
+ * omit the product anchor (there is no real product to scope the case to).
76
+ */
77
+ export const legacyFlatGroup = (
78
+ form: FormData,
79
+ label: string,
80
+ ): FormProductGroup => ({
81
+ product_uuid: LEGACY_PRODUCT_GROUP_UUID,
82
+ product_name: label,
83
+ product_description: null,
84
+ nominator: null,
85
+ purposes: (form.purposes ?? [])
86
+ .map((wrapper) => wrapper?.purpose)
87
+ .filter((purpose): purpose is Purpose => Boolean(purpose)),
88
+ });
89
+
54
90
  const consentDataElementToFormDataElement = (
55
91
  element: ConsentDataElement,
56
92
  ): DataElement => ({
@@ -1,17 +1,74 @@
1
- import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
1
+ import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
2
2
  import { setRefreshTokenCookie, decodeJwtToken, resolveContactFromJwt, setSessionExpiredListener } from '../lib/utils';
3
- import type { AuthContextValue } from './types';
3
+ import { setSandboxConfig } from '../lib/sandbox';
4
+ import type {
5
+ AuthContextValue,
6
+ AuthProviderProps,
7
+ SeedSessionStorageData,
8
+ } from './types';
4
9
 
5
10
  export type { AuthContextValue } from './types';
6
11
 
7
12
  const AuthContext = createContext<AuthContextValue | null>(null);
8
13
 
9
- interface AuthProviderProps {
10
- children: ReactNode;
11
- accessToken: string;
12
- refreshToken: string;
13
- onError: (error: Error) => boolean;
14
- }
14
+ /**
15
+ * Raw sessionStorage writer for the five keys the Privacy Center reads
16
+ * (access_token / pc_org_id / pc_workspace_id / pc_org_slug / email). Single
17
+ * source of truth for those key names so the JWT and sandbox seeding paths can
18
+ * never drift. Each key is written only when its value is a non-empty string;
19
+ * `accessToken` is optional because the JWT path stores it separately. The
20
+ * `contact` value goes under the `email` key even when it is a mobile number
21
+ * (matching resolveContactFromJwt, which also stores mobile/org_user_id
22
+ * subjects there). No-ops when `window` is unavailable (SSR).
23
+ */
24
+ const seedSessionStorage = (data: SeedSessionStorageData): void => {
25
+ if (typeof window === 'undefined') {
26
+ return;
27
+ }
28
+ if (data.accessToken) sessionStorage.setItem('access_token', data.accessToken);
29
+ if (data.orgId) sessionStorage.setItem('pc_org_id', data.orgId);
30
+ if (data.workspaceId) sessionStorage.setItem('pc_workspace_id', data.workspaceId);
31
+ if (data.slug) sessionStorage.setItem('pc_org_slug', data.slug);
32
+ if (data.contact) sessionStorage.setItem('email', data.contact);
33
+ };
34
+
35
+ /**
36
+ * Seed the sessionStorage keys the Privacy Center reads (org/workspace/slug/
37
+ * email/access_token) from the sandbox props and register the module-level
38
+ * sandbox config, so the fetch + auth layers switch to sandbox headers and skip
39
+ * token refresh. Returns true when sandbox mode was configured.
40
+ */
41
+ const configureSandbox = (props: AuthProviderProps): boolean => {
42
+ const token = props.token?.trim();
43
+ if (!token) {
44
+ setSandboxConfig(null);
45
+ return false;
46
+ }
47
+
48
+ const organisationUuid = props.organisationUuid?.trim() ?? '';
49
+ const workspaceUuid = props.workspaceUuid?.trim() ?? '';
50
+ const ucic = props.sandboxUcic?.trim() ?? '';
51
+ const subject = props.sandboxSubject?.trim() ?? '';
52
+ const slug = props.slug?.trim() ?? '';
53
+
54
+ setSandboxConfig({
55
+ token,
56
+ ucic: ucic || undefined,
57
+ subject,
58
+ organisationUuid,
59
+ workspaceUuid,
60
+ slug,
61
+ });
62
+
63
+ seedSessionStorage({
64
+ accessToken: token,
65
+ orgId: organisationUuid,
66
+ workspaceId: workspaceUuid,
67
+ slug,
68
+ contact: ucic || subject,
69
+ });
70
+ return true;
71
+ };
15
72
 
16
73
  /**
17
74
  * Extract and store organization/auth data from JWT token to sessionStorage
@@ -31,55 +88,68 @@ const syncJwtDataToSessionStorage = (accessToken: string): void => {
31
88
  const contact = resolveContactFromJwt(decoded)
32
89
  || sessionStorage.getItem('email')
33
90
  || (typeof localStorage !== 'undefined' ? localStorage.getItem('userEmail') : null);
34
- if (contact && typeof contact === 'string') {
35
- sessionStorage.setItem('email', contact);
36
- }
37
91
 
38
- // Extract organization UUID
39
- const orgUuid = decoded.organisation_uuid;
40
- if (orgUuid && typeof orgUuid === 'string') {
41
- sessionStorage.setItem('pc_org_id', orgUuid);
42
- }
43
-
44
- // Extract workspace UUID
45
- const workspaceUuid = decoded.workspace_uuid;
46
- if (workspaceUuid && typeof workspaceUuid === 'string') {
47
- sessionStorage.setItem('pc_workspace_id', workspaceUuid);
48
- }
49
-
50
- // Extract organization slug
51
- const orgSlug = decoded.organisation_slug;
52
- if (orgSlug && typeof orgSlug === 'string') {
53
- sessionStorage.setItem('pc_org_slug', orgSlug);
54
- }
92
+ // The access_token is stored separately by the caller (state initializer /
93
+ // updateTokens), so it is intentionally omitted here.
94
+ seedSessionStorage({
95
+ orgId: typeof decoded.organisation_uuid === 'string' ? decoded.organisation_uuid : undefined,
96
+ workspaceId: typeof decoded.workspace_uuid === 'string' ? decoded.workspace_uuid : undefined,
97
+ slug: typeof decoded.organisation_slug === 'string' ? decoded.organisation_slug : undefined,
98
+ contact: typeof contact === 'string' ? contact : undefined,
99
+ });
55
100
  };
56
101
 
57
- export const AuthProvider = ({
58
- children,
59
- accessToken: initialAccessToken,
60
- refreshToken: initialRefreshToken,
61
- onError,
62
- }: AuthProviderProps) => {
102
+ export const AuthProvider = (props: AuthProviderProps) => {
103
+ const {
104
+ children,
105
+ accessToken: initialAccessToken,
106
+ refreshToken: initialRefreshToken,
107
+ token: sandboxToken,
108
+ sandboxUcic,
109
+ sandboxSubject,
110
+ organisationUuid,
111
+ workspaceUuid,
112
+ slug,
113
+ onError,
114
+ } = props;
115
+
116
+ // Sandbox PoC: configure the module singleton + seed sessionStorage BEFORE any
117
+ // child renders (same reason the JWT sync runs in the state initializer). This
118
+ // MUST run during render, not in a useEffect: child effects (which fire the
119
+ // first data fetch and read `sandboxAuthHeaders()` synchronously) run on commit
120
+ // BEFORE the parent's effects, so an effect here would configure the singleton
121
+ // too late. A useState lazy initializer runs once on the first render, ahead of
122
+ // any child, and is not re-run on subsequent parent re-renders — so it sets the
123
+ // config before the first fetch without the every-render side effect. When
124
+ // active, the JWT path below is skipped entirely.
125
+ const [sandboxActive] = useState(() => configureSandbox(props));
126
+
63
127
  // Sync to sessionStorage synchronously during initialization so child
64
128
  // components (ConsentManager, SideBarPC) find the data when they mount.
65
129
  // useEffect runs AFTER children render — too late for components that
66
130
  // read sessionStorage during their render phase.
67
131
  const [accessToken, setAccessToken] = useState(() => {
132
+ if (sandboxActive) {
133
+ return sandboxToken ?? '';
134
+ }
68
135
  if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
69
136
  if (typeof window !== 'undefined') {
70
137
  sessionStorage.setItem('access_token', initialAccessToken);
71
138
  syncJwtDataToSessionStorage(initialAccessToken);
72
139
  }
73
140
  }
74
- return initialAccessToken;
141
+ return initialAccessToken ?? '';
75
142
  });
76
143
  const [refreshToken, setRefreshToken] = useState(() => {
144
+ if (sandboxActive) {
145
+ return '';
146
+ }
77
147
  if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
78
148
  if (typeof window !== 'undefined') {
79
149
  setRefreshTokenCookie(initialRefreshToken);
80
150
  }
81
151
  }
82
- return initialRefreshToken;
152
+ return initialRefreshToken ?? '';
83
153
  });
84
154
 
85
155
  // Terminal "session expired" flag. Set by utils' refresh routines (via the
@@ -97,8 +167,12 @@ export const AuthProvider = ({
97
167
  return () => setSessionExpiredListener(null);
98
168
  }, [markSessionExpired]);
99
169
 
100
- // Re-sync when props change (for token refresh scenarios)
170
+ // Re-sync when props change (for token refresh scenarios). Sandbox mode has no
171
+ // JWT and no refresh, so these are no-ops there.
101
172
  useEffect(() => {
173
+ if (sandboxActive) {
174
+ return;
175
+ }
102
176
  if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
103
177
  setAccessToken(initialAccessToken);
104
178
  if (typeof window !== 'undefined') {
@@ -106,9 +180,12 @@ export const AuthProvider = ({
106
180
  syncJwtDataToSessionStorage(initialAccessToken);
107
181
  }
108
182
  }
109
- }, [initialAccessToken]);
183
+ }, [initialAccessToken, sandboxActive]);
110
184
 
111
185
  useEffect(() => {
186
+ if (sandboxActive) {
187
+ return;
188
+ }
112
189
  if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
113
190
  setRefreshToken(initialRefreshToken);
114
191
  // A fresh refresh token means the user re-authenticated — clear the
@@ -118,7 +195,22 @@ export const AuthProvider = ({
118
195
  setRefreshTokenCookie(initialRefreshToken);
119
196
  }
120
197
  }
121
- }, [initialRefreshToken]);
198
+ }, [initialRefreshToken, sandboxActive]);
199
+
200
+ // Sandbox PoC: re-configure the module singleton when the sandbox inputs
201
+ // change after mount (the lazy initializer above only runs once, on first
202
+ // render — this mirrors the JWT re-sync effects for prop changes). Clears the
203
+ // singleton on unmount so a later JWT-mode mount never inherits a stale sandbox
204
+ // config. First-fetch ordering is unaffected: the initializer already set the
205
+ // config before any child effect ran.
206
+ useEffect(() => {
207
+ if (!sandboxActive) {
208
+ return;
209
+ }
210
+ configureSandbox(props);
211
+ return () => setSandboxConfig(null);
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [sandboxActive, sandboxToken, sandboxUcic, sandboxSubject, organisationUuid, workspaceUuid, slug]);
122
214
 
123
215
  // Internal method to update tokens after refresh
124
216
  const updateTokens = useCallback((newAccessToken: string, newRefreshToken?: string) => {
@@ -1,5 +1,43 @@
1
+ import type { ReactNode } from 'react';
1
2
  import type { createConsentClient } from '../api/client';
2
3
 
4
+ export type AuthProviderProps = Readonly<{
5
+ children: ReactNode;
6
+ accessToken?: string;
7
+ refreshToken?: string;
8
+ /**
9
+ * Sandbox PoC: when set, the Privacy Center skips the JWT entirely — it seeds
10
+ * org/workspace/subject from the props below, sends the single
11
+ * `X-Consent-Token` header, carries the acting identity in each request's own
12
+ * payload, and never calls `/otp/refresh`.
13
+ */
14
+ token?: string;
15
+ /** Sandbox UCIC — the client's own user id (`org_user_id`); takes precedence over the subject. */
16
+ sandboxUcic?: string;
17
+ /** Sandbox subject (email or mobile) — carried in the request payload. */
18
+ sandboxSubject?: string;
19
+ /** Sandbox org UUID (replaces the JWT-decoded org). */
20
+ organisationUuid?: string;
21
+ /** Sandbox workspace UUID (replaces the JWT-decoded workspace). */
22
+ workspaceUuid?: string;
23
+ /** Organisation slug — used to seed sandbox sessionStorage. */
24
+ slug?: string;
25
+ onError: (error: Error) => boolean;
26
+ }>;
27
+
28
+ /**
29
+ * The five-key sessionStorage payload the Privacy Center seeds (access_token /
30
+ * pc_org_id / pc_workspace_id / pc_org_slug / email). `contact` lands under the
31
+ * `email` key even when it is a mobile/UCIC subject.
32
+ */
33
+ export type SeedSessionStorageData = {
34
+ accessToken?: string;
35
+ orgId?: string;
36
+ workspaceId?: string;
37
+ slug?: string;
38
+ contact?: string;
39
+ };
40
+
3
41
  export type AuthContextValue = Readonly<{
4
42
  accessToken: string;
5
43
  refreshToken: string;