@redacto.io/consent-sdk-react 10.0.0-beta.0 → 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 (89) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +123 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +43 -8
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.js +345 -237
  8. package/dist/index.mjs +344 -238
  9. package/dist/privacy-center.d.mts +113 -37
  10. package/dist/privacy-center.d.ts +113 -37
  11. package/dist/privacy-center.js +2317 -1910
  12. package/dist/privacy-center.mjs +2245 -1835
  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 +75 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +90 -177
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +60 -44
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +29 -3
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
  31. package/src/RedactoNoticeConsentInline/types.ts +1 -1
  32. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  33. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
  34. package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
  35. package/src/RedactoPrivacyCenter/api/client.ts +216 -39
  36. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  37. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  38. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
  39. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  40. package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
  41. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  42. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
  43. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
  44. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  45. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  46. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  47. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
  48. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  49. package/src/RedactoPrivacyCenter/context/types.ts +39 -1
  50. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  51. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  52. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  53. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  54. package/src/RedactoPrivacyCenter/lib/types.ts +78 -2
  55. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  56. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  58. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  59. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  60. package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
  61. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  62. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  63. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  64. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  65. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  66. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  67. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  68. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  69. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  70. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  71. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  72. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  73. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  74. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  75. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  76. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  77. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  78. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  79. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -2
  80. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  81. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  82. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  83. package/src/shared/notice-consent-status.ts +190 -0
  84. package/src/shared/sandbox.ts +6 -0
  85. package/tests/Form.test.tsx +190 -7
  86. package/tests/RevokeWarningMessage.test.tsx +151 -0
  87. package/tests/consentClient.test.ts +217 -0
  88. package/tests/manageConsentServer.test.ts +38 -0
  89. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
@@ -1,12 +1,20 @@
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,
11
+ IdentityListResponse,
5
12
  MessageDocument,
6
13
  NotificationActionResponse,
7
14
  NotificationListResponse,
8
15
  NotificationQueryParams,
9
16
  NotificationSummary,
17
+ SelectPrincipalResponse,
10
18
  UnreadCountResponse,
11
19
  UploadDocumentResponse,
12
20
  } from "../lib/types";
@@ -25,13 +33,18 @@ export type BaseRequest = {
25
33
  * Creates a client instance with a specific base URL.
26
34
  * @param baseUrl - Consent server base URL (Python service, e.g. http://host/consent)
27
35
  * @param ledgerBaseUrl - Go ledger base URL (e.g. http://host:8090/consent-ledger).
28
- * Used for user-consents reads which are served from the ledger's `/public/...` paths.
36
+ * Used for user-consents reads/writes served from the ledger's `/public/...` paths.
37
+ * When omitted, those calls fall back to the legacy Python
38
+ * `/dsar/privacy-center` endpoints and payloads (pre-ledger behavior), so
39
+ * integrations without a deployed ledger keep working.
29
40
  */
30
- export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
31
- if (!ledgerBaseUrl) {
32
- throw new Error("ledgerBaseUrl is required to create the consent client");
33
- }
34
- const ledgerBase = ledgerBaseUrl.replace(/\/$/, "");
41
+ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
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();
47
+ const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
35
48
  /**
36
49
  * Privacy Center API fetch wrapper that uses Authorization: Bearer header.
37
50
  * Privacy center endpoints require JWT tokens in Authorization header, not X-Auth-Token.
@@ -69,15 +82,26 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
69
82
  }
70
83
  }
71
84
 
72
- // Add Authorization Bearer token for privacy center APIs
73
- const token = authToken || (getAuthToken ? await getAuthToken() : null);
74
- if (token) {
75
- const trimmedToken = token.trim();
76
- // Format token with Bearer prefix if not already present
77
- const bearerToken = trimmedToken.startsWith('Bearer ')
78
- ? trimmedToken
79
- : `Bearer ${trimmedToken}`;
80
- 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
+ }
81
105
  }
82
106
 
83
107
  // Build fetch options
@@ -177,6 +201,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
177
201
  }
178
202
 
179
203
  function ledgerUrl(baseRequest: BaseRequest, path: string): string {
204
+ if (!useLedger()) {
205
+ throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
206
+ }
180
207
  return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
181
208
  }
182
209
 
@@ -185,13 +212,47 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
185
212
  }
186
213
 
187
214
  function notificationUrl(baseRequest: BaseRequest, path = ''): string {
188
- 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}`);
189
221
  }
190
222
 
191
223
  function resolveAuthToken(authToken: string | undefined, baseRequest: BaseRequest): string {
192
224
  return authToken || baseRequest.authToken;
193
225
  }
194
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
+
195
256
  return {
196
257
  /**
197
258
  * Get form data (privacy center)
@@ -241,13 +302,23 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
241
302
  const url = privacyCenterUrl(baseRequest, '/case');
242
303
  const { useSessionAuth = false, getAuthToken, ...fetchOptions } = options || {};
243
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
+
244
315
  // Case management uses session-based auth (X-Auth-Token)
245
316
  // Privacy center uses JWT Bearer tokens (Authorization: Bearer)
246
317
  if (useSessionAuth) {
247
318
  return apiFetch(url, {
248
319
  ...fetchOptions,
249
320
  method: "POST",
250
- body: JSON.stringify(payload),
321
+ body: JSON.stringify(requestPayload),
251
322
  getAuthToken,
252
323
  });
253
324
  }
@@ -256,7 +327,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
256
327
  return privacyCenterApiFetch(url, {
257
328
  ...fetchOptions,
258
329
  method: "POST",
259
- body: JSON.stringify(payload),
330
+ body: JSON.stringify(requestPayload),
260
331
  authToken: resolveAuthToken(authToken, baseRequest),
261
332
  });
262
333
  },
@@ -382,9 +453,14 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
382
453
  if (params.status) queryParams.append("status", params.status);
383
454
  if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
384
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);
385
459
  const queryString = queryParams.toString();
386
460
  const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
387
- const url = ledgerUrl(baseRequest, suffix);
461
+ const url = useLedger()
462
+ ? ledgerUrl(baseRequest, suffix)
463
+ : privacyCenterUrl(baseRequest, suffix);
388
464
  return privacyCenterApiFetch(url, {
389
465
  ...options,
390
466
  method: "GET",
@@ -418,9 +494,14 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
418
494
  if (params.search) queryParams.append("search", params.search);
419
495
  if (params.language) queryParams.append("language", params.language);
420
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);
421
500
  const queryString = queryParams.toString();
422
501
  const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
423
- const url = ledgerUrl(baseRequest, suffix);
502
+ const url = useLedger()
503
+ ? ledgerUrl(baseRequest, suffix)
504
+ : privacyCenterUrl(baseRequest, suffix);
424
505
  return privacyCenterApiFetch(url, {
425
506
  ...options,
426
507
  method: "GET",
@@ -432,18 +513,23 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
432
513
  /**
433
514
  * Manage consent (privacy center) - revoke, regrant, or renew.
434
515
  *
435
- * The Go ledger has no manage-consent route, so this is expressed as a
436
- * `partial` submit-consent on the ledger — mirroring the Python dual-write
437
- * relay (`build_consent_management_payload`): the affected purpose is
438
- * re-submitted with `selected: false` to revoke or `selected: true` to
439
- * regrant/renew, and `partial: true` so the rest of the notice's purposes
440
- * are left untouched. The principal is resolved from the JWT, so no
441
- * `contact`/identifiers are sent in the body, and `consent_event_uuid` is
442
- * omitted so the ledger mints a fresh event.
516
+ * The Go ledger has no manage-consent route, so with a ledger configured
517
+ * this is expressed as a `partial` submit-consent on the ledger — mirroring
518
+ * the Python dual-write relay (`build_consent_management_payload`): the
519
+ * affected purpose is re-submitted with `selected: false` to revoke or
520
+ * `selected: true` to regrant/renew, and `partial: true` so the rest of the
521
+ * notice's purposes are left untouched. The principal is resolved from the
522
+ * JWT, so no `contact`/identifiers are sent in the body, and
523
+ * `consent_event_uuid` is omitted so the ledger mints a fresh event.
524
+ *
525
+ * Without a ledger, the legacy Python `/manage-consent?status=` endpoint is
526
+ * used with its `{ purpose_uuid, contact, ... }` payload.
443
527
  */
444
528
  async manageConsent(
445
529
  baseRequest: BaseRequest,
446
530
  purposeId: string,
531
+ // Sent to the legacy Python endpoint only; the ledger resolves the
532
+ // principal from the JWT, so no identifiers are sent in ledger mode.
447
533
  contact: string,
448
534
  status: 'revoke' | 'regrant' | 'renew',
449
535
  authToken?: string,
@@ -452,8 +538,31 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
452
538
  dataElementUuids?: string[],
453
539
  productUuid?: string,
454
540
  noticeUuid?: string,
455
- dataElements?: Array<{ uuid: string; selected: boolean }>
541
+ dataElements?: Array<{ uuid: string; selected: boolean }>,
542
+ language?: string
456
543
  ): Promise<unknown> {
544
+ if (!useLedger()) {
545
+ const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
546
+ const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
547
+ if (nominatorContact) {
548
+ body.nominator_contact = nominatorContact;
549
+ }
550
+ if (Array.isArray(dataElementUuids) && dataElementUuids.length > 0) {
551
+ body.data_element_uuids = dataElementUuids;
552
+ }
553
+ if (productUuid) {
554
+ body.product_uuid = productUuid;
555
+ }
556
+ if (language) {
557
+ body.language = language;
558
+ }
559
+ return privacyCenterApiFetch(url, {
560
+ ...options,
561
+ method: "POST",
562
+ body: JSON.stringify(body),
563
+ authToken: resolveAuthToken(authToken, baseRequest),
564
+ });
565
+ }
457
566
  if (!noticeUuid) {
458
567
  throw new Error("notice_uuid is required to manage consent via the ledger");
459
568
  }
@@ -473,7 +582,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
473
582
  }
474
583
  return { uuid: de.uuid, selected };
475
584
  });
476
- const body = {
585
+ const body: Record<string, unknown> = {
477
586
  notice_uuid: noticeUuid,
478
587
  partial: true,
479
588
  declined: false,
@@ -486,6 +595,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
486
595
  data_elements: elements,
487
596
  },
488
597
  ],
598
+ ...(language ? { language } : {}),
489
599
  };
490
600
  const url = ledgerUrl(baseRequest, "/submit-consent");
491
601
  return privacyCenterApiFetch(url, {
@@ -511,6 +621,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
511
621
  if (offset !== undefined) queryParams.append("offset", offset.toString());
512
622
  if (limit !== undefined) queryParams.append("limit", limit.toString());
513
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);
514
627
  const queryString = queryParams.toString();
515
628
  const url = privacyCenterUrl(baseRequest, `/activities${queryString ? `?${queryString}` : ""}`);
516
629
  return privacyCenterApiFetch<unknown>(url, {
@@ -519,6 +632,44 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
519
632
  });
520
633
  },
521
634
 
635
+ /**
636
+ * List all data principals that share this session's contact
637
+ * (shared-contact profile picker). Identity is derived server-side from the
638
+ * JWT — the caller sends nothing but its bearer token.
639
+ */
640
+ async listIdentities(
641
+ baseRequest: BaseRequest,
642
+ authToken?: string,
643
+ options?: Omit<ApiFetchOptions, "getAuthToken">
644
+ ): Promise<IdentityListResponse> {
645
+ const url = privacyCenterUrl(baseRequest, '/identities');
646
+ return privacyCenterApiFetch<IdentityListResponse>(url, {
647
+ ...options,
648
+ cache: 'no-store',
649
+ authToken: resolveAuthToken(authToken, baseRequest),
650
+ });
651
+ },
652
+
653
+ /**
654
+ * Re-scope the session to a chosen principal uuid (must be one of the
655
+ * principals sharing this session's contact). Returns fresh access/refresh
656
+ * tokens scoped to that principal.
657
+ */
658
+ async selectPrincipal(
659
+ baseRequest: BaseRequest,
660
+ principalUuid: string,
661
+ authToken?: string,
662
+ options?: Omit<ApiFetchOptions, "getAuthToken">
663
+ ): Promise<SelectPrincipalResponse> {
664
+ const url = privacyCenterUrl(baseRequest, '/identities/select');
665
+ return privacyCenterApiFetch<SelectPrincipalResponse>(url, {
666
+ ...options,
667
+ method: 'POST',
668
+ body: JSON.stringify({ uuid: principalUuid }),
669
+ authToken: resolveAuthToken(authToken, baseRequest),
670
+ });
671
+ },
672
+
522
673
  /**
523
674
  * Get receipts (privacy center) — consent receipts for the authenticated user
524
675
  */
@@ -547,8 +698,13 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
547
698
  if (params.created_before) queryParams.append("created_before", params.created_before);
548
699
  if (params.language) queryParams.append("language", params.language);
549
700
  const queryString = queryParams.toString();
550
- // Receipts are served by the Go ledger (paginated list under /public/...).
551
- const url = ledgerUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
701
+ // With a ledger configured, receipts are served by the Go ledger
702
+ // (paginated list under /public/...); otherwise the legacy Python
703
+ // privacy-center list is used. normalizeReceipts tolerates both envelopes.
704
+ const receiptsSuffix = `/receipts${queryString ? `?${queryString}` : ""}`;
705
+ const url = useLedger()
706
+ ? ledgerUrl(baseRequest, receiptsSuffix)
707
+ : privacyCenterUrl(baseRequest, receiptsSuffix);
552
708
  return privacyCenterApiFetch<unknown>(url, {
553
709
  ...options,
554
710
  method: "GET",
@@ -568,13 +724,25 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
568
724
  receiptUuid: string,
569
725
  authToken?: string
570
726
  ): Promise<Blob> {
571
- // The ledger serves the receipt PDF at /receipts/{uuid}/download; the
572
- // receipt_uuid comes from the ledger list above, so both must be the ledger.
573
- const url = ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`);
727
+ // The ledger serves the receipt PDF at /receipts/{uuid}/download, the
728
+ // legacy Python service at /receipts/{uuid}/pdf. The receiptUuid always
729
+ // comes from the same-mode list above, so list and download stay consistent.
730
+ const url = useLedger()
731
+ ? ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`)
732
+ : privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
574
733
  const token = resolveAuthToken(authToken, baseRequest);
575
734
  const requestHeaders = new Headers();
576
735
  requestHeaders.set("Accept", "application/pdf");
577
- 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) {
578
746
  const trimmedToken = token.trim();
579
747
  requestHeaders.set(
580
748
  "Authorization",
@@ -617,6 +785,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
617
785
  if (offset !== undefined) queryParams.append("offset", offset.toString());
618
786
  if (limit !== undefined) queryParams.append("limit", limit.toString());
619
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);
620
791
  const queryString = queryParams.toString();
621
792
  const url = privacyCenterUrl(baseRequest, `/cases?${queryString}`);
622
793
  return privacyCenterApiFetch<unknown>(url, {
@@ -635,7 +806,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
635
806
  caseUuid: string,
636
807
  authToken?: string
637
808
  ): Promise<CaseMessage[]> {
638
- 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()}`);
639
812
  return privacyCenterApiFetch<CaseMessage[]>(url, {
640
813
  authToken: resolveAuthToken(authToken, baseRequest),
641
814
  });
@@ -651,7 +824,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
651
824
  documentUuids: string[],
652
825
  authToken?: string
653
826
  ): Promise<CaseMessage> {
654
- 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()}`);
655
830
  return privacyCenterApiFetch<CaseMessage>(url, {
656
831
  method: 'POST',
657
832
  body: JSON.stringify({ body, document_uuids: documentUuids }),
@@ -668,7 +843,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
668
843
  file: File,
669
844
  authToken?: string
670
845
  ): Promise<UploadDocumentResponse> {
671
- 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()}`);
672
849
  const formData = new FormData();
673
850
  formData.append('file', file, file.name); // field name must be exactly "file"
674
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
 
@@ -214,6 +214,34 @@ export default function AppShell({
214
214
  var(--pc-bg-shell) 100%
215
215
  );
216
216
  }
217
+ .pc-shell-user-menu-identity {
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: 2px;
221
+ min-width: 0;
222
+ flex: 1;
223
+ }
224
+ .pc-shell-user-menu-identity-email {
225
+ font-size: 13px;
226
+ font-weight: 600;
227
+ color: var(--pc-text-primary);
228
+ line-height: 1.3;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ white-space: nowrap;
232
+ min-width: 0;
233
+ }
234
+ .pc-shell-user-menu-identity-id {
235
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
236
+ font-size: 11px;
237
+ font-weight: 500;
238
+ color: var(--pc-text-secondary);
239
+ line-height: 1.3;
240
+ overflow: hidden;
241
+ text-overflow: ellipsis;
242
+ white-space: nowrap;
243
+ min-width: 0;
244
+ }
217
245
  .pc-popover.pc-shell-user-menu-popover {
218
246
  border-radius: 12px !important;
219
247
  overflow: hidden !important;