@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "10.0.0-beta.0",
3
+ "version": "10.0.0-beta.11",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -18,6 +18,7 @@ import {
18
18
  } from "./api";
19
19
  import type { ConsentContent, TTSAudioUrlsResponse } from "./api/types";
20
20
  import { ApiError } from "../shared/api-errors";
21
+ import { fetchNoticeConsentStatusForScope } from "../shared/notice-consent-status";
21
22
  import { makeStaticT } from "./i18n";
22
23
 
23
24
  vi.mock("./api", () => ({
@@ -28,12 +29,26 @@ vi.mock("./api", () => ({
28
29
  submitAssistedConsent: vi.fn(),
29
30
  }));
30
31
 
32
+ // Mock only the ledger check-consent network read; keep the real
33
+ // isNoticeFullyConsented predicate so the already-consented decision is exercised.
34
+ vi.mock("../shared/notice-consent-status", async (importActual) => {
35
+ const actual =
36
+ await importActual<typeof import("../shared/notice-consent-status")>();
37
+ return {
38
+ ...actual,
39
+ fetchNoticeConsentStatusForScope: vi.fn(),
40
+ };
41
+ });
42
+
43
+ const LEDGER_BASE_URL = "https://ledger.example.test/consent-ledger";
44
+
31
45
  const otpLength = ASSISTED_CONFIG.otpLength;
32
46
  const mockedFetch = vi.mocked(fetchAssistedNotice);
33
47
  const mockedFetchTTS = vi.mocked(fetchAssistedTTSAudioUrls);
34
48
  const mockedCreateOtp = vi.mocked(createOtp);
35
49
  const mockedVerifyOtp = vi.mocked(verifyOtp);
36
50
  const mockedSubmitConsent = vi.mocked(submitAssistedConsent);
51
+ const mockedFetchScope = vi.mocked(fetchNoticeConsentStatusForScope);
37
52
 
38
53
  const OTP_DETAIL = {
39
54
  uuid: "otp-1",
@@ -226,6 +241,9 @@ describe("RedactoNoticeAssisted", () => {
226
241
  mockedCreateOtp.mockResolvedValue(OTP_DETAIL);
227
242
  mockedVerifyOtp.mockResolvedValue(VERIFY_DETAIL);
228
243
  mockedSubmitConsent.mockResolvedValue(null);
244
+ // Default: ledger reports no prior full consent, so flows proceed to submit.
245
+ mockedFetchScope.mockReset();
246
+ mockedFetchScope.mockResolvedValue(null);
229
247
  });
230
248
 
231
249
  it("fetches with the public-notice props (no auth)", async () => {
@@ -604,6 +622,75 @@ describe("RedactoNoticeAssisted", () => {
604
622
  ).not.toBeInTheDocument();
605
623
  });
606
624
 
625
+ // ── Go ledger (ledgerBaseUrl) ─────────────────────────────────────────────
626
+ // Drives the full notice → OTP → Confirm flow with a notice that carries a
627
+ // notice_uuid (so the check-consent read runs), and returns the onComplete spy.
628
+ const runConfirmFlow = async (extraProps: Record<string, unknown>) => {
629
+ mockedFetch.mockResolvedValue(NOTICE_WITH_AUDIO);
630
+ const onComplete = vi.fn();
631
+ render(
632
+ <RedactoNoticeAssisted {...PROPS} onComplete={onComplete} {...extraProps} />,
633
+ );
634
+ await screen.findByRole("heading", { name: "EA Sports" });
635
+ selectRequired();
636
+ fireEvent.click(screen.getByRole("button", { name: "Agree" }));
637
+ fireEvent.change(screen.getByLabelText("Mobile number"), {
638
+ target: { value: "9876543210" },
639
+ });
640
+ fireEvent.click(screen.getByRole("button", { name: "Send OTP" }));
641
+ await screen.findByText(/OTP sent to/);
642
+ typeOtp("1".repeat(otpLength));
643
+ fireEvent.click(screen.getByRole("button", { name: "Confirm OTP" }));
644
+ return onComplete;
645
+ };
646
+
647
+ it("routes the write through ledgerBaseUrl and submits when not already consented", async () => {
648
+ const onComplete = await runConfirmFlow({ ledgerBaseUrl: LEDGER_BASE_URL });
649
+
650
+ await waitFor(() => expect(mockedSubmitConsent).toHaveBeenCalledTimes(1));
651
+ // The check-consent read runs with props org/workspace + the OTP token.
652
+ expect(mockedFetchScope).toHaveBeenCalledWith(
653
+ expect.objectContaining({
654
+ accessToken: "access-token-1",
655
+ ledgerBaseUrl: LEDGER_BASE_URL,
656
+ organisationUuid: "org-1",
657
+ workspaceUuid: "ws-1",
658
+ noticeUuid: "ntc-1",
659
+ }),
660
+ );
661
+ // The submit carries ledgerBaseUrl so the api layer routes it to the ledger.
662
+ expect(mockedSubmitConsent).toHaveBeenCalledWith(
663
+ expect.objectContaining({ ledgerBaseUrl: LEDGER_BASE_URL }),
664
+ );
665
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
666
+ });
667
+
668
+ it("short-circuits an already fully-consented principal via the ledger (no submit)", async () => {
669
+ mockedFetchScope.mockResolvedValue({
670
+ consented: true,
671
+ all_mandatory_active: true,
672
+ purposes: [],
673
+ });
674
+ const onComplete = await runConfirmFlow({ ledgerBaseUrl: LEDGER_BASE_URL });
675
+
676
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
677
+ expect(mockedSubmitConsent).not.toHaveBeenCalled();
678
+ });
679
+
680
+ it("skips the ledger check-consent and stays on baseUrl when ledgerBaseUrl is absent", async () => {
681
+ const onComplete = await runConfirmFlow({});
682
+
683
+ await waitFor(() => expect(mockedSubmitConsent).toHaveBeenCalledTimes(1));
684
+ expect(mockedFetchScope).not.toHaveBeenCalled();
685
+ expect(mockedSubmitConsent).toHaveBeenCalledWith(
686
+ expect.objectContaining({
687
+ ledgerBaseUrl: undefined,
688
+ baseUrl: "https://api.example.test/consent",
689
+ }),
690
+ );
691
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
692
+ });
693
+
607
694
  // The verify endpoint returns English copy with no error_code, so the SDK
608
695
  // localizes by HTTP status + the `should_request_new` flag instead of echoing
609
696
  // the server message.
@@ -23,6 +23,10 @@ import type {
23
23
  TTSAudioUrlsResponse,
24
24
  } from "./api/types";
25
25
  import { ApiError } from "../shared/api-errors";
26
+ import {
27
+ fetchNoticeConsentStatusForScope,
28
+ isNoticeFullyConsented,
29
+ } from "../shared/notice-consent-status";
26
30
  import {
27
31
  toNativeLabel,
28
32
  toBcp47Code,
@@ -251,6 +255,7 @@ export const RedactoNoticeAssisted = ({
251
255
  workspaceUuid,
252
256
  noticeUuid,
253
257
  baseUrl,
258
+ ledgerBaseUrl,
254
259
  onDecline,
255
260
  onComplete,
256
261
  }: RedactoNoticeAssistedProps) => {
@@ -1207,6 +1212,26 @@ export const RedactoNoticeAssisted = ({
1207
1212
  }
1208
1213
  const token = verifyDetail.access_token;
1209
1214
 
1215
+ // Post-cutover, consent records live in the Go ledger, so Python's 409
1216
+ // CONSENT_ALREADY_PROVIDED can no longer be relied on. When a ledger is
1217
+ // configured, ask it directly (org/workspace from props, OTP token as
1218
+ // bearer) whether this principal has already fully consented and, if so,
1219
+ // hand off without writing a duplicate consent event. Any failure returns
1220
+ // null and falls through to the normal submit (graceful degradation).
1221
+ if (ledgerBaseUrl) {
1222
+ const ledgerStatus = await fetchNoticeConsentStatusForScope({
1223
+ accessToken: token,
1224
+ ledgerBaseUrl,
1225
+ organisationUuid,
1226
+ workspaceUuid,
1227
+ noticeUuid: content.notice_uuid,
1228
+ });
1229
+ if (ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
1230
+ handleSuccess();
1231
+ return;
1232
+ }
1233
+ }
1234
+
1210
1235
  // Map current selection onto the submit payload shape (required → selected).
1211
1236
  const submitPurposes = content.purposes.map((purpose) => ({
1212
1237
  ...purpose,
@@ -1224,6 +1249,7 @@ export const RedactoNoticeAssisted = ({
1224
1249
  organisationUuid,
1225
1250
  workspaceUuid,
1226
1251
  baseUrl,
1252
+ ledgerBaseUrl,
1227
1253
  noticeUuid: content.notice_uuid,
1228
1254
  purposes: submitPurposes,
1229
1255
  language: toBcp47Code(selectedLanguage),
@@ -1255,6 +1281,7 @@ export const RedactoNoticeAssisted = ({
1255
1281
  otpUuid,
1256
1282
  otpValue,
1257
1283
  baseUrl,
1284
+ ledgerBaseUrl,
1258
1285
  selectedPurposes,
1259
1286
  selectedDataElements,
1260
1287
  selectedLanguage,
@@ -160,6 +160,7 @@ export const submitAssistedConsent = async ({
160
160
  organisationUuid,
161
161
  workspaceUuid,
162
162
  baseUrl,
163
+ ledgerBaseUrl,
163
164
  noticeUuid,
164
165
  purposes,
165
166
  language,
@@ -196,7 +197,12 @@ export const submitAssistedConsent = async ({
196
197
  language: language || undefined,
197
198
  };
198
199
 
199
- const url = `${publicScope(baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
200
+ // Route the write to the Go ledger when configured, else the legacy consent
201
+ // server. publicScope already applies `|| BASE_URL`, so this yields the
202
+ // standard `ledgerBaseUrl || baseUrl || BASE_URL` fallback used by the other
203
+ // notice components. Only submit-consent routes to the ledger; notice/OTP/TTS
204
+ // stay on baseUrl.
205
+ const url = `${publicScope(ledgerBaseUrl || baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
200
206
 
201
207
  const response = await fetch(url, {
202
208
  method: "POST",
@@ -94,6 +94,8 @@ export type SubmitAssistedConsentParams = {
94
94
  organisationUuid: string;
95
95
  workspaceUuid: string;
96
96
  baseUrl?: string;
97
+ /** Go ledger base URL — routes the write to the ledger; omit to use the legacy consent server. */
98
+ ledgerBaseUrl?: string;
97
99
  /** `notice_uuid` from the fetched active_config. */
98
100
  noticeUuid: string;
99
101
  /** Purposes carrying their current `selected` state + data elements. */
@@ -1248,60 +1248,6 @@ const as: StaticStrings = {
1248
1248
  close: "বন্ধ কৰক",
1249
1249
  };
1250
1250
 
1251
- const bho: StaticStrings = {
1252
- useEmailInstead: "ईमेल इस्तेमाल करीं",
1253
- useMobileInstead: "मोबाइल इस्तेमाल करीं",
1254
- emailAddress: "ईमेल पता",
1255
- otpHelperEmail: "हमनी एह ईमेल पर एक बेर इस्तेमाल होखे वाला पासवर्ड भेजब।",
1256
- changeEmail: "ईमेल बदलीं",
1257
- expandDetails: "{name} के विवरण फैलाईं",
1258
- collapseDetails: "{name} के विवरण समेटीं",
1259
- selectAllFor: "{name} खातिर सभ डेटा तत्व चुनीं",
1260
- selectElement: "{name} चुनीं",
1261
- selectElementRequired: "{name} चुनीं (जरूरी)",
1262
- required: "जरूरी",
1263
- logoAlt: "लोगो",
1264
- selectLanguage: "भाषा चुनीं",
1265
- selectLanguageCurrent: "भाषा चुनीं. मौजूदा भाषा: {lang}",
1266
- opensInNewTab: "नया टैब में खुलेला",
1267
- audioStart: "ऑडियो प्लेबैक शुरू करीं",
1268
- audioPause: "ऑडियो प्लेबैक रोकीं",
1269
- talkbackStart: "टॉकबैक शुरू करीं",
1270
- talkbackPause: "टॉकबैक रोकीं",
1271
- mobileNumber: "मोबाइल नंबर",
1272
- otpHelper: "हमनी एह नंबर पर एक बेर इस्तेमाल होखे वाला पासवर्ड भेजब।",
1273
- otpSentTo: "ओटीपी (OTP) भेजल गइल",
1274
- changeMobile: "मोबाइल नंबर बदलीं",
1275
- enterNDigitOtp: "{count}-अंक के ओटीपी (OTP) डालीं",
1276
- otpDigit: "ओटीपी (OTP) अंक {index}",
1277
- resendIn: "{seconds} सेकंड में ओटीपी (OTP) फेर से भेजीं",
1278
- sending: "भेजल जा रहल बा…",
1279
- resendOtp: "ओटीपी (OTP) फेर से भेजीं",
1280
- getOtp: "ओटीपी (OTP) पाईं",
1281
- verifying: "सत्यापित कइल जा रहल बा…",
1282
- verifyOtp: "ओटीपी (OTP) सत्यापित करीं",
1283
- sendOtp: "ओटीपी (OTP) भेजीं",
1284
- confirmOtp: "ओटीपी (OTP) के पुष्टि करीं",
1285
- verifyWithOtp: "ओटीपी (OTP) से सत्यापित करीं",
1286
- editSelections: "चुनाव में बदलाव करीं",
1287
- otpIncorrect:
1288
- "रउरा डालल सत्यापन कोड गलत बा। कृपया जाँच करीं आ फेर से कोशिश करीं।",
1289
- otpExpired: "रउरा सत्यापन कोड के मियाद खतम हो गइल बा। कृपया नया कोड मांगीं।",
1290
- otpNotFound: "सत्यापन कोड ना मिलल। कृपया नया कोड मांगीं।",
1291
- otpVerifyFailed: "ओटीपी (OTP) के सत्यापन ना हो सकल। कृपया फेर से कोशिश करीं।",
1292
- otpSendFailed: "ओटीपी (OTP) ना भेजल जा सकल। कृपया फेर से कोशिश करीं।",
1293
- consentConfirmed: "सहमति के पुष्टि भइल",
1294
- recordedUnder: "ओटीपी (OTP) सत्यापित · {regulation} के तहत दर्ज भइल",
1295
- recordedUnderAt:
1296
- "ओटीपी (OTP) सत्यापित · {regulation} के तहत {time} बजे दर्ज भइल",
1297
- consentReferenceId: "सहमति संदर्भ आईडी",
1298
- proceed: "आगे बढ़ीं",
1299
- loadingNotice: "नोटिस लोड हो रहल बा",
1300
- loadFailedTitle: "सहमति नोटिस लोड ना हो सकल",
1301
- tryAgain: "कृपया फेर से कोसिस करीं।",
1302
- close: "बंद करीं",
1303
- };
1304
-
1305
1251
  const brx: StaticStrings = {
1306
1252
  useEmailInstead: "इमेल बाहाय",
1307
1253
  useMobileInstead: "मबाइल बाहाय",
@@ -1435,7 +1381,6 @@ export const STATIC_STRINGS: Record<string, StaticStrings> = {
1435
1381
  te,
1436
1382
  ur,
1437
1383
  as,
1438
- bho,
1439
1384
  brx,
1440
1385
  doi,
1441
1386
  };
@@ -17,6 +17,8 @@ export type RedactoNoticeAssistedProps = {
17
17
  noticeUuid: string;
18
18
  /** API base URL (defaults to the production consent API). */
19
19
  baseUrl?: string;
20
+ /** Go ledger base URL — routes the consent write to the ledger; omit to use the legacy consent server. */
21
+ ledgerBaseUrl?: string;
20
22
  /** Called when the agent/customer declines (host should unmount the modal). */
21
23
  onDecline?: () => void;
22
24
  /** Called when the agent clicks "Proceed" on the confirmed screen. */
@@ -26,11 +26,6 @@ vi.mock("./api", () => ({
26
26
  fetchTTSAudioUrls: vi.fn(),
27
27
  initiateGuardianVerification: vi.fn(),
28
28
  verifyGuardianStatus: vi.fn(),
29
- // Defaults to resolving undefined, so the ledger consent-state overlay is
30
- // skipped and the banner falls back to the Python-provided values the
31
- // existing tests assert on.
32
- fetchNoticeConsentStatus: vi.fn(),
33
- buildLedgerConsentOverlay: vi.fn(),
34
29
  }));
35
30
 
36
31
  // Mock jwt-decode
@@ -529,7 +524,7 @@ describe("RedactoNoticeConsent", () => {
529
524
  noticeId: "notice-123",
530
525
  accessToken: "mock-token",
531
526
  refreshToken: "mock-refresh-token",
532
- baseUrl: undefined,
527
+ baseUrl: defaultProps.ledgerBaseUrl,
533
528
  language: "en",
534
529
  specific_uuid: undefined,
535
530
  validate_against: "required",
@@ -551,7 +546,7 @@ describe("RedactoNoticeConsent", () => {
551
546
  noticeId: "notice-123",
552
547
  accessToken: "mock-token",
553
548
  refreshToken: "mock-refresh-token",
554
- baseUrl: undefined,
549
+ baseUrl: defaultProps.ledgerBaseUrl,
555
550
  language: "en",
556
551
  specific_uuid: undefined,
557
552
  validate_against: "all",
@@ -564,6 +559,45 @@ describe("RedactoNoticeConsent", () => {
564
559
  });
565
560
  });
566
561
 
562
+ describe("ledger routing", () => {
563
+ it("routes the standard notice fetch to the ledger base url", async () => {
564
+ await act(async () => {
565
+ render(<RedactoNoticeConsent {...defaultProps} />);
566
+ });
567
+ await waitFor(
568
+ () => {
569
+ expect(api.fetchConsentContent).toHaveBeenCalledWith(
570
+ expect.objectContaining({ baseUrl: defaultProps.ledgerBaseUrl })
571
+ );
572
+ },
573
+ { timeout: 3000 }
574
+ );
575
+ });
576
+
577
+ it("keeps the magic-link flow (applicationId) on the consent server", async () => {
578
+ await act(async () => {
579
+ render(
580
+ <RedactoNoticeConsent {...defaultProps} applicationId="app-xyz" />
581
+ );
582
+ });
583
+ await waitFor(
584
+ () => {
585
+ expect(api.fetchConsentContent).toHaveBeenCalledWith(
586
+ expect.objectContaining({
587
+ // defaultProps has no baseUrl, so the magic-link flow routes the
588
+ // fetch to baseUrl=undefined (the consent server / SDK default),
589
+ // i.e. NOT ledgerBaseUrl — same as the submitConsentEvent
590
+ // assertion above. The point is the ledger routing is bypassed.
591
+ baseUrl: undefined,
592
+ specific_uuid: "app-xyz",
593
+ })
594
+ );
595
+ },
596
+ { timeout: 3000 }
597
+ );
598
+ });
599
+ });
600
+
567
601
  describe("includeFullyConsentedData (magic link flow)", () => {
568
602
  beforeEach(() => {
569
603
  mockedApi.fetchTTSAudioUrls.mockResolvedValue({
@@ -604,7 +638,7 @@ describe("RedactoNoticeConsent", () => {
604
638
  noticeId: "notice-123",
605
639
  accessToken: "mock-token",
606
640
  refreshToken: "mock-refresh-token",
607
- baseUrl: undefined,
641
+ baseUrl: defaultProps.ledgerBaseUrl,
608
642
  language: "en",
609
643
  specific_uuid: undefined,
610
644
  validate_against: "all",
@@ -11,10 +11,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
11
11
 
12
12
  // API imports
13
13
  import {
14
- buildLedgerConsentOverlay,
15
14
  clearApiCache,
16
15
  fetchConsentContent,
17
- fetchNoticeConsentStatus,
18
16
  fetchTTSAudioUrls,
19
17
  initiateGuardianVerification,
20
18
  submitConsentEvent,
@@ -1297,6 +1295,12 @@ export const RedactoNoticeConsent = ({
1297
1295
  noticeId,
1298
1296
  accessToken,
1299
1297
  refreshToken,
1298
+ token,
1299
+ email,
1300
+ mobile,
1301
+ ucic,
1302
+ organisationUuid,
1303
+ workspaceUuid,
1300
1304
  baseUrl,
1301
1305
  ledgerBaseUrl,
1302
1306
  language = "en",
@@ -1312,6 +1316,25 @@ export const RedactoNoticeConsent = ({
1312
1316
  includeFullyConsentedData = false,
1313
1317
  reviewModeButtonText = "Continue",
1314
1318
  }: Props) => {
1319
+ // =============================================================================
1320
+ // SANDBOX MODE
1321
+ // =============================================================================
1322
+
1323
+ // Sandbox PoC: when `token` is set, every API call bypasses the JWT and sends
1324
+ // the single X-Consent-Token header, carrying the acting identity (subject) in
1325
+ // each request's own payload. Org/workspace/subject are taken straight from
1326
+ // props. Passed through to the API layer alongside `accessToken`.
1327
+ const sandboxParams = useMemo(
1328
+ () => ({
1329
+ token,
1330
+ sandboxUcic: ucic,
1331
+ sandboxSubject: email || mobile,
1332
+ organisationUuid,
1333
+ workspaceUuid,
1334
+ }),
1335
+ [token, ucic, email, mobile, organisationUuid, workspaceUuid]
1336
+ );
1337
+
1315
1338
  // =============================================================================
1316
1339
  // PROP VALIDATION
1317
1340
  // =============================================================================
@@ -1321,11 +1344,31 @@ export const RedactoNoticeConsent = ({
1321
1344
  if (!noticeId?.trim()) {
1322
1345
  return "RedactoNoticeConsent: 'noticeId' prop is required and cannot be empty";
1323
1346
  }
1347
+ // Sandbox mode: require a static token, org/workspace, and a subject instead
1348
+ // of a JWT access token.
1349
+ if (token?.trim()) {
1350
+ if (!organisationUuid?.trim() || !workspaceUuid?.trim()) {
1351
+ return "RedactoNoticeConsent: sandbox mode requires 'organisationUuid' and 'workspaceUuid'";
1352
+ }
1353
+ if (!email?.trim() && !mobile?.trim() && !ucic?.trim()) {
1354
+ return "RedactoNoticeConsent: sandbox mode requires 'email', 'mobile', or 'ucic'";
1355
+ }
1356
+ return null;
1357
+ }
1324
1358
  if (!accessToken?.trim()) {
1325
1359
  return "RedactoNoticeConsent: 'accessToken' prop is required and cannot be empty";
1326
1360
  }
1327
1361
  return null;
1328
- }, [noticeId, accessToken]);
1362
+ }, [
1363
+ noticeId,
1364
+ accessToken,
1365
+ token,
1366
+ organisationUuid,
1367
+ workspaceUuid,
1368
+ email,
1369
+ mobile,
1370
+ ucic,
1371
+ ]);
1329
1372
 
1330
1373
  // Show error UI if props are invalid
1331
1374
  if (propValidationError) {
@@ -1812,16 +1855,23 @@ export const RedactoNoticeConsent = ({
1812
1855
  setShowAgeVerification(false);
1813
1856
  setShowGuardianForm(false);
1814
1857
  try {
1858
+ // Standard flow reads the notice from the Go ledger. The magic-link flow
1859
+ // (applicationId/specific_uuid) and sandbox sessions stay on the consent
1860
+ // server: the ledger notice read supports neither (no specific_uuid, no
1861
+ // X-Consent-Token auth).
1862
+ const noticeBaseUrl =
1863
+ ledgerBaseUrl && !applicationId && !token ? ledgerBaseUrl : baseUrl;
1815
1864
  const consentContentData = await fetchConsentContent({
1816
1865
  noticeId,
1817
1866
  accessToken,
1818
1867
  refreshToken,
1819
- baseUrl,
1868
+ baseUrl: noticeBaseUrl,
1820
1869
  language,
1821
1870
  specific_uuid: applicationId,
1822
1871
  validate_against: validateAgainst,
1823
1872
  include_fully_consented_data: includeFullyConsentedData,
1824
1873
  signal: abortControllerRef.current?.signal,
1874
+ ...sandboxParams,
1825
1875
  });
1826
1876
  const activeConfig = consentContentData.detail.active_config;
1827
1877
  setContent(activeConfig);
@@ -1842,30 +1892,15 @@ export const RedactoNoticeConsent = ({
1842
1892
  setCollapsedPurposes(initialCollapsedState);
1843
1893
 
1844
1894
  // Initialize selected states - handle reconsent pre-population.
1845
- // Approach A: overlay the prior-consent / reconsent state from the Go
1846
- // ledger's check-consent. Consents live in the ledger post-cutover, so
1847
- // Python's notice-validation overlay is empty; the notice text/config
1848
- // still comes from Python (above). Falls back to the Python-provided
1849
- // values when the ledger is unavailable, and is skipped for the
1850
- // magic-link flow (specific_uuid), which pre-fills from a specific event.
1851
- let isReconsentRequired = consentContentData.detail.reconsent_required;
1852
- let purposeSelections = consentContentData.detail.purpose_selections;
1853
- if (ledgerBaseUrl && !applicationId) {
1854
- const ledgerStatus = await fetchNoticeConsentStatus({
1855
- accessToken,
1856
- ledgerBaseUrl,
1857
- noticeUuid: noticeId,
1858
- signal: abortControllerRef.current?.signal,
1859
- });
1860
- if (ledgerStatus) {
1861
- const overlay = buildLedgerConsentOverlay(
1862
- ledgerStatus,
1863
- activeConfig.purposes
1864
- );
1865
- isReconsentRequired = overlay?.reconsent_required ?? false;
1866
- purposeSelections = overlay?.purpose_selections;
1867
- }
1868
- }
1895
+ // Both notice reads return the per-principal reconsent overlay inline, so
1896
+ // we just consume it — same as the Python flow always did. The ledger
1897
+ // computes it from the very same consent-status read the standalone
1898
+ // check-consent route uses, so a second round-trip only duplicated that
1899
+ // work and produced a *weaker* overlay: it had no notice-version
1900
+ // awareness (a republished notice never re-prompted) and ignored
1901
+ // validate_against, both of which the inline overlay handles server-side.
1902
+ const isReconsentRequired = consentContentData.detail.reconsent_required;
1903
+ const purposeSelections = consentContentData.detail.purpose_selections;
1869
1904
 
1870
1905
  // Categorize purposes for reconsent UI
1871
1906
  const newCategorizedPurposes = isReconsentRequired
@@ -2033,8 +2068,10 @@ export const RedactoNoticeConsent = ({
2033
2068
  noticeId,
2034
2069
  accessToken,
2035
2070
  refreshToken,
2071
+ sandboxParams,
2036
2072
  language,
2037
2073
  applicationId,
2074
+ ledgerBaseUrl,
2038
2075
  validateAgainst,
2039
2076
  includeFullyConsentedData,
2040
2077
  // Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
@@ -2145,6 +2182,7 @@ export const RedactoNoticeConsent = ({
2145
2182
  if (content) {
2146
2183
  await submitConsentEvent({
2147
2184
  accessToken,
2185
+ baseUrl,
2148
2186
  ledgerBaseUrl,
2149
2187
  noticeUuid: content.notice_uuid,
2150
2188
  purposes: content.purposes.map((purpose) => {
@@ -2193,6 +2231,7 @@ export const RedactoNoticeConsent = ({
2193
2231
  guardianVerificationReference: verificationReference || undefined,
2194
2232
  selfDeclaredAdult: selfDeclaredAdult || undefined,
2195
2233
  signal: submitController.signal,
2234
+ ...sandboxParams,
2196
2235
  });
2197
2236
  }
2198
2237
  onAccept?.();
@@ -2436,7 +2475,7 @@ export const RedactoNoticeConsent = ({
2436
2475
  } finally {
2437
2476
  setIsSubmittingGuardian(false);
2438
2477
  }
2439
- }, [accessToken, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
2478
+ }, [accessToken, sandboxParams, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
2440
2479
 
2441
2480
  // Age verification handlers
2442
2481
  const handleAgeVerificationYes = useCallback(() => {
@@ -2515,7 +2554,7 @@ export const RedactoNoticeConsent = ({
2515
2554
  setIsPollingStatus(false);
2516
2555
  isPollingStatusRef.current = false;
2517
2556
  setIsInitiatingVerification(false);
2518
- }, [accessToken, baseUrl]);
2557
+ }, [accessToken, sandboxParams, baseUrl]);
2519
2558
 
2520
2559
  const startPopupMonitoring = useCallback((popup: Window) => {
2521
2560
  // Check every 500ms if popup is closed
@@ -2696,7 +2735,7 @@ export const RedactoNoticeConsent = ({
2696
2735
  // First poll after 3s
2697
2736
  statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
2698
2737
  },
2699
- [accessToken, baseUrl, getVerificationErrorMessage]
2738
+ [accessToken, sandboxParams, baseUrl, getVerificationErrorMessage]
2700
2739
  );
2701
2740
 
2702
2741
 
@@ -3224,13 +3263,16 @@ export const RedactoNoticeConsent = ({
3224
3263
  // Create abort controller for this request
3225
3264
  const ttsAbortController = new AbortController();
3226
3265
 
3227
- // Fetch TTS audio URLs
3266
+ // Prefer the Go ledger for audio; fetchTTSAudioUrls keeps sandbox sessions
3267
+ // on the consent server (the ledger has no X-Consent-Token support).
3228
3268
  fetchTTSAudioUrls({
3229
3269
  accessToken,
3230
3270
  baseUrl,
3271
+ ledgerBaseUrl,
3231
3272
  noticeUuid: content.notice_uuid,
3232
3273
  language: languageCode,
3233
3274
  signal: ttsAbortController.signal,
3275
+ ...sandboxParams,
3234
3276
  })
3235
3277
  .then((response) => {
3236
3278
  // Store the response and mark TTS as available
@@ -3249,7 +3291,7 @@ export const RedactoNoticeConsent = ({
3249
3291
  return () => {
3250
3292
  ttsAbortController.abort();
3251
3293
  };
3252
- }, [content, selectedLanguage, accessToken, baseUrl, getLanguageCode]);
3294
+ }, [content, selectedLanguage, accessToken, sandboxParams, baseUrl, ledgerBaseUrl, getLanguageCode]);
3253
3295
 
3254
3296
  const modalStyle = {
3255
3297
  borderRadius: settings?.borderRadius || "8px",