@redacto.io/consent-sdk-react 10.0.0-beta.8 → 10.0.0-beta.9

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 (30) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  3. package/dist/index.d.mts +29 -3
  4. package/dist/index.d.ts +29 -3
  5. package/dist/index.js +162 -56
  6. package/dist/index.mjs +161 -57
  7. package/dist/privacy-center.d.mts +51 -10
  8. package/dist/privacy-center.d.ts +51 -10
  9. package/dist/privacy-center.js +250 -70
  10. package/dist/privacy-center.mjs +249 -71
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
  13. package/src/RedactoNoticeConsent/api/index.ts +78 -48
  14. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  15. package/src/RedactoNoticeConsent/api/types.ts +57 -11
  16. package/src/RedactoNoticeConsent/types.ts +28 -2
  17. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  18. package/src/RedactoPrivacyCenter/api/client.ts +116 -24
  19. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  20. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  21. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
  22. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  23. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  24. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  25. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -5
  26. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  27. package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
  28. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  29. package/src/shared/sandbox.ts +6 -0
  30. package/tests/Form.test.tsx +142 -5
@@ -17,8 +17,31 @@ interface RedactoPrivacyCenterProps {
17
17
  baseUrl: string; // Consent server base URL
18
18
  ledgerBaseUrl?: string; // Go ledger base URL — serves user-consents; omit to use the legacy Python privacy-center consent endpoints
19
19
  slug: string;
20
- accessToken: string;
21
- refreshToken: string;
20
+ /** JWT access token. Optional so sandbox mode (see `token`) can omit it. */
21
+ accessToken?: string;
22
+ /** JWT refresh token. Optional; used by the OTP-refresh loop in the JWT flow. */
23
+ refreshToken?: string;
24
+ /**
25
+ * Sandbox PoC: a pasted static token. When set, the Privacy Center runs
26
+ * against the sandbox with no JWT — it sends the single `X-Consent-Token`
27
+ * header, carries the acting identity (`email` || `mobile`) in each request's
28
+ * own payload, takes org/workspace from `organisationUuid`/`workspaceUuid`,
29
+ * and never calls `/otp/refresh`.
30
+ */
31
+ token?: string;
32
+ /** Sandbox test subject email (carried in the request payload when set). */
33
+ email?: string;
34
+ /** Sandbox test subject mobile (fallback subject when `email` is absent). */
35
+ mobile?: string;
36
+ /**
37
+ * Sandbox UCIC: the client's own user id / org_user_id, e.g. a UUID or
38
+ * customer code; optional third identifier alongside email/mobile.
39
+ */
40
+ ucic?: string;
41
+ /** Sandbox org UUID (replaces the JWT-decoded org in sandbox mode). */
42
+ organisationUuid?: string;
43
+ /** Sandbox workspace UUID (replaces the JWT-decoded workspace in sandbox mode). */
44
+ workspaceUuid?: string;
22
45
  onError: (error: Error) => boolean;
23
46
  initialPage?: InternalPage;
24
47
  onBack?: 'back' | 'signout';
@@ -30,6 +53,12 @@ export function RedactoPrivacyCenter({
30
53
  slug,
31
54
  accessToken,
32
55
  refreshToken,
56
+ token,
57
+ email,
58
+ mobile,
59
+ ucic,
60
+ organisationUuid,
61
+ workspaceUuid,
33
62
  onError,
34
63
  initialPage = 'consent-manager',
35
64
  onBack,
@@ -41,6 +70,12 @@ export function RedactoPrivacyCenter({
41
70
  <AuthProvider
42
71
  accessToken={accessToken}
43
72
  refreshToken={refreshToken}
73
+ token={token}
74
+ sandboxUcic={ucic}
75
+ sandboxSubject={email || mobile}
76
+ organisationUuid={organisationUuid}
77
+ workspaceUuid={workspaceUuid}
78
+ slug={slug}
44
79
  onError={onError}
45
80
  >
46
81
  <NavigationProvider initialPage={initialPage}>
@@ -1,5 +1,11 @@
1
1
  import { apiFetch, ApiError } from "./fetcher";
2
2
  import type { ApiFetchOptions, ApiResponse } from "./fetcher";
3
+ import {
4
+ isSandboxMode,
5
+ sandboxAuthHeaders,
6
+ sandboxContact,
7
+ sandboxQueryIdentity,
8
+ } from "../lib/sandbox";
3
9
  import type {
4
10
  CaseMessage,
5
11
  IdentityListResponse,
@@ -33,7 +39,11 @@ export type BaseRequest = {
33
39
  * integrations without a deployed ledger keep working.
34
40
  */
35
41
  export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
36
- const useLedger = Boolean(ledgerBaseUrl);
42
+ const ledgerConfigured = Boolean(ledgerBaseUrl);
43
+ // Sandbox mode never routes to the Go ledger (it has no sandbox / test::
44
+ // support). A sandbox token forces the legacy consent-server path regardless
45
+ // of ledgerBaseUrl, so isolation can't be bypassed by also configuring a ledger.
46
+ const useLedger = () => ledgerConfigured && !isSandboxMode();
37
47
  const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
38
48
  /**
39
49
  * Privacy Center API fetch wrapper that uses Authorization: Bearer header.
@@ -72,15 +82,26 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
72
82
  }
73
83
  }
74
84
 
75
- // Add Authorization Bearer token for privacy center APIs
76
- const token = authToken || (getAuthToken ? await getAuthToken() : null);
77
- if (token) {
78
- const trimmedToken = token.trim();
79
- // Format token with Bearer prefix if not already present
80
- const bearerToken = trimmedToken.startsWith('Bearer ')
81
- ? trimmedToken
82
- : `Bearer ${trimmedToken}`;
83
- requestHeaders.set("Authorization", bearerToken);
85
+ // Sandbox PoC: send only the X-Consent-Token credential (no JWT bearer). Set
86
+ // last so it wins over any caller-supplied Authorization header. The acting
87
+ // identity travels in the request payload -- query params on reads, the body
88
+ // on writes -- never a header.
89
+ const sandboxHeaders = sandboxAuthHeaders();
90
+ if (sandboxHeaders) {
91
+ Object.entries(sandboxHeaders).forEach(([key, value]) => {
92
+ requestHeaders.set(key, value);
93
+ });
94
+ } else {
95
+ // Add Authorization Bearer token for privacy center APIs
96
+ const token = authToken || (getAuthToken ? await getAuthToken() : null);
97
+ if (token) {
98
+ const trimmedToken = token.trim();
99
+ // Format token with Bearer prefix if not already present
100
+ const bearerToken = trimmedToken.startsWith('Bearer ')
101
+ ? trimmedToken
102
+ : `Bearer ${trimmedToken}`;
103
+ requestHeaders.set("Authorization", bearerToken);
104
+ }
84
105
  }
85
106
 
86
107
  // Build fetch options
@@ -180,7 +201,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
180
201
  }
181
202
 
182
203
  function ledgerUrl(baseRequest: BaseRequest, path: string): string {
183
- if (!useLedger) {
204
+ if (!useLedger()) {
184
205
  throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
185
206
  }
186
207
  return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
@@ -191,13 +212,47 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
191
212
  }
192
213
 
193
214
  function notificationUrl(baseRequest: BaseRequest, path = ''): string {
194
- return privacyCenterUrl(baseRequest, `/notifications${path}`);
215
+ const identitySuffix = sandboxIdentitySuffix();
216
+ if (!identitySuffix) {
217
+ return privacyCenterUrl(baseRequest, `/notifications${path}`);
218
+ }
219
+ const separator = path.includes('?') ? `&${identitySuffix.slice(1)}` : identitySuffix;
220
+ return privacyCenterUrl(baseRequest, `/notifications${path}${separator}`);
195
221
  }
196
222
 
197
223
  function resolveAuthToken(authToken: string | undefined, baseRequest: BaseRequest): string {
198
224
  return authToken || baseRequest.authToken;
199
225
  }
200
226
 
227
+ /**
228
+ * Append the sandbox acting identity (`primary_email` / `primary_mobile`) to a
229
+ * query-param bag when sandbox mode is active; a no-op otherwise. The server
230
+ * resolves the acting test identity from these query params on reads (there is
231
+ * no subject header). The credential is the X-Consent-Token header set in
232
+ * privacyCenterApiFetch.
233
+ */
234
+ function appendSandboxIdentity(queryParams: URLSearchParams): void {
235
+ const identity = sandboxQueryIdentity();
236
+ if (identity) {
237
+ Object.entries(identity).forEach(([key, value]) => {
238
+ queryParams.append(key, value);
239
+ });
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The sandbox acting-identity query suffix (`?primary_email=...` /
245
+ * `?primary_mobile=...`) for endpoints that build a bare URL with no other
246
+ * query params (the case-communication reads/POSTs). Empty string outside
247
+ * sandbox mode, so the JWT path is unchanged.
248
+ */
249
+ function sandboxIdentitySuffix(): string {
250
+ const queryParams = new URLSearchParams();
251
+ appendSandboxIdentity(queryParams);
252
+ const queryString = queryParams.toString();
253
+ return queryString ? `?${queryString}` : "";
254
+ }
255
+
201
256
  return {
202
257
  /**
203
258
  * Get form data (privacy center)
@@ -247,13 +302,23 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
247
302
  const url = privacyCenterUrl(baseRequest, '/case');
248
303
  const { useSessionAuth = false, getAuthToken, ...fetchOptions } = options || {};
249
304
 
305
+ // Sandbox mode: the acting test identity is the single `contact` field
306
+ // (the server has no subject header; it namespaces this identity `test::`).
307
+ // Merge it into the body only when sandbox mode is active and the payload
308
+ // is a plain object; the JWT path is left byte-for-byte unchanged.
309
+ const contact = sandboxContact();
310
+ const requestPayload =
311
+ contact && payload && typeof payload === "object"
312
+ ? { ...(payload as Record<string, unknown>), contact }
313
+ : payload;
314
+
250
315
  // Case management uses session-based auth (X-Auth-Token)
251
316
  // Privacy center uses JWT Bearer tokens (Authorization: Bearer)
252
317
  if (useSessionAuth) {
253
318
  return apiFetch(url, {
254
319
  ...fetchOptions,
255
320
  method: "POST",
256
- body: JSON.stringify(payload),
321
+ body: JSON.stringify(requestPayload),
257
322
  getAuthToken,
258
323
  });
259
324
  }
@@ -262,7 +327,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
262
327
  return privacyCenterApiFetch(url, {
263
328
  ...fetchOptions,
264
329
  method: "POST",
265
- body: JSON.stringify(payload),
330
+ body: JSON.stringify(requestPayload),
266
331
  authToken: resolveAuthToken(authToken, baseRequest),
267
332
  });
268
333
  },
@@ -388,9 +453,12 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
388
453
  if (params.status) queryParams.append("status", params.status);
389
454
  if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
390
455
  if (params.language) queryParams.append("language", params.language);
456
+ // Sandbox mode: the acting identity travels as query params
457
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
458
+ appendSandboxIdentity(queryParams);
391
459
  const queryString = queryParams.toString();
392
460
  const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
393
- const url = useLedger
461
+ const url = useLedger()
394
462
  ? ledgerUrl(baseRequest, suffix)
395
463
  : privacyCenterUrl(baseRequest, suffix);
396
464
  return privacyCenterApiFetch(url, {
@@ -426,9 +494,12 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
426
494
  if (params.search) queryParams.append("search", params.search);
427
495
  if (params.language) queryParams.append("language", params.language);
428
496
  if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
497
+ // Sandbox mode: the acting identity travels as query params
498
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
499
+ appendSandboxIdentity(queryParams);
429
500
  const queryString = queryParams.toString();
430
501
  const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
431
- const url = useLedger
502
+ const url = useLedger()
432
503
  ? ledgerUrl(baseRequest, suffix)
433
504
  : privacyCenterUrl(baseRequest, suffix);
434
505
  return privacyCenterApiFetch(url, {
@@ -470,7 +541,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
470
541
  dataElements?: Array<{ uuid: string; selected: boolean }>,
471
542
  language?: string
472
543
  ): Promise<unknown> {
473
- if (!useLedger) {
544
+ if (!useLedger()) {
474
545
  const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
475
546
  const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
476
547
  if (nominatorContact) {
@@ -511,7 +582,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
511
582
  }
512
583
  return { uuid: de.uuid, selected };
513
584
  });
514
- const body = {
585
+ const body: Record<string, unknown> = {
515
586
  notice_uuid: noticeUuid,
516
587
  partial: true,
517
588
  declined: false,
@@ -550,6 +621,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
550
621
  if (offset !== undefined) queryParams.append("offset", offset.toString());
551
622
  if (limit !== undefined) queryParams.append("limit", limit.toString());
552
623
  if (language) queryParams.append("language", language);
624
+ // Sandbox mode: the acting identity travels as query params
625
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
626
+ appendSandboxIdentity(queryParams);
553
627
  const queryString = queryParams.toString();
554
628
  const url = privacyCenterUrl(baseRequest, `/activities${queryString ? `?${queryString}` : ""}`);
555
629
  return privacyCenterApiFetch<unknown>(url, {
@@ -628,7 +702,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
628
702
  // (paginated list under /public/...); otherwise the legacy Python
629
703
  // privacy-center list is used. normalizeReceipts tolerates both envelopes.
630
704
  const receiptsSuffix = `/receipts${queryString ? `?${queryString}` : ""}`;
631
- const url = useLedger
705
+ const url = useLedger()
632
706
  ? ledgerUrl(baseRequest, receiptsSuffix)
633
707
  : privacyCenterUrl(baseRequest, receiptsSuffix);
634
708
  return privacyCenterApiFetch<unknown>(url, {
@@ -653,13 +727,22 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
653
727
  // The ledger serves the receipt PDF at /receipts/{uuid}/download, the
654
728
  // legacy Python service at /receipts/{uuid}/pdf. The receiptUuid always
655
729
  // comes from the same-mode list above, so list and download stay consistent.
656
- const url = useLedger
730
+ const url = useLedger()
657
731
  ? ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`)
658
732
  : privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
659
733
  const token = resolveAuthToken(authToken, baseRequest);
660
734
  const requestHeaders = new Headers();
661
735
  requestHeaders.set("Accept", "application/pdf");
662
- if (token) {
736
+ // Sandbox PoC: send the single X-Consent-Token header instead of the JWT
737
+ // bearer. The receipt PDF download identifies the principal by receiptUuid
738
+ // (already scoped to the sandbox subject via the same-mode list), so no
739
+ // extra identity payload is needed here.
740
+ const sandboxHeaders = sandboxAuthHeaders();
741
+ if (sandboxHeaders) {
742
+ Object.entries(sandboxHeaders).forEach(([key, value]) => {
743
+ requestHeaders.set(key, value);
744
+ });
745
+ } else if (token) {
663
746
  const trimmedToken = token.trim();
664
747
  requestHeaders.set(
665
748
  "Authorization",
@@ -702,6 +785,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
702
785
  if (offset !== undefined) queryParams.append("offset", offset.toString());
703
786
  if (limit !== undefined) queryParams.append("limit", limit.toString());
704
787
  if (language) queryParams.append("language", language);
788
+ // Sandbox mode: the acting identity travels as query params
789
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
790
+ appendSandboxIdentity(queryParams);
705
791
  const queryString = queryParams.toString();
706
792
  const url = privacyCenterUrl(baseRequest, `/cases?${queryString}`);
707
793
  return privacyCenterApiFetch<unknown>(url, {
@@ -720,7 +806,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
720
806
  caseUuid: string,
721
807
  authToken?: string
722
808
  ): Promise<CaseMessage[]> {
723
- const url = caseUrl(baseRequest, caseUuid, '/messages');
809
+ // Sandbox mode: the acting identity travels as query params
810
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
811
+ const url = caseUrl(baseRequest, caseUuid, `/messages${sandboxIdentitySuffix()}`);
724
812
  return privacyCenterApiFetch<CaseMessage[]>(url, {
725
813
  authToken: resolveAuthToken(authToken, baseRequest),
726
814
  });
@@ -736,7 +824,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
736
824
  documentUuids: string[],
737
825
  authToken?: string
738
826
  ): Promise<CaseMessage> {
739
- const url = caseUrl(baseRequest, caseUuid, '/messages');
827
+ // Sandbox mode: the acting identity travels as query params
828
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
829
+ const url = caseUrl(baseRequest, caseUuid, `/messages${sandboxIdentitySuffix()}`);
740
830
  return privacyCenterApiFetch<CaseMessage>(url, {
741
831
  method: 'POST',
742
832
  body: JSON.stringify({ body, document_uuids: documentUuids }),
@@ -753,7 +843,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
753
843
  file: File,
754
844
  authToken?: string
755
845
  ): Promise<UploadDocumentResponse> {
756
- const url = caseUrl(baseRequest, caseUuid, '/upload-document');
846
+ // Sandbox mode: the acting identity travels as query params
847
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
848
+ const url = caseUrl(baseRequest, caseUuid, `/upload-document${sandboxIdentitySuffix()}`);
757
849
  const formData = new FormData();
758
850
  formData.append('file', file, file.name); // field name must be exactly "file"
759
851
  // "payload" is required by the privacy-center endpoint (omitting it causes 422)
@@ -1,3 +1,5 @@
1
+ import { sandboxAuthHeaders } from "../lib/sandbox";
2
+
1
3
  /**
2
4
  * Options for apiFetch function
3
5
  */
@@ -125,16 +127,29 @@ export async function apiFetch<T>(
125
127
  }
126
128
  }
127
129
 
128
- // Add auth token AFTER custom headers to ensure it's never overridden
130
+ // Add auth AFTER custom headers so it's never overridden. Only for calls that
131
+ // opt into auth (a `getAuthToken` is supplied) — public calls like org
132
+ // branding (which hit the user server, not consent-server) stay unauthenticated
133
+ // and must NOT receive the sandbox headers.
129
134
  if (getAuthToken) {
130
- const token = await getAuthToken();
131
- if (token) {
132
- // Use the exact header format that works in other parts of the codebase
133
- // Other working API calls use 'X-Auth-Token' (capital A and T, rest lowercase)
134
- const trimmedToken = token.trim();
135
- requestHeaders.set("X-Auth-Token", trimmedToken);
136
- // Also set lowercase for compatibility (some servers are case-sensitive)
137
- requestHeaders.set("x-auth-token", trimmedToken);
135
+ // Sandbox PoC: send the single X-Consent-Token header instead of the session
136
+ // token. The acting identity travels in the request payload (injected by the
137
+ // client call sites), not a header.
138
+ const sandboxHeaders = sandboxAuthHeaders();
139
+ if (sandboxHeaders) {
140
+ Object.entries(sandboxHeaders).forEach(([key, value]) => {
141
+ requestHeaders.set(key, value);
142
+ });
143
+ } else {
144
+ const token = await getAuthToken();
145
+ if (token) {
146
+ // Use the exact header format that works in other parts of the codebase
147
+ // Other working API calls use 'X-Auth-Token' (capital A and T, rest lowercase)
148
+ const trimmedToken = token.trim();
149
+ requestHeaders.set("X-Auth-Token", trimmedToken);
150
+ // Also set lowercase for compatibility (some servers are case-sensitive)
151
+ requestHeaders.set("x-auth-token", trimmedToken);
152
+ }
138
153
  }
139
154
  }
140
155
 
@@ -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[];
@@ -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;
@@ -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 => ({