@redacto.io/consent-sdk-react 9.5.0-beta.0 → 10.0.0-beta.0

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 (32) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/index.d.mts +4 -2
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +122 -15
  5. package/dist/index.mjs +122 -15
  6. package/dist/privacy-center.d.mts +31 -7
  7. package/dist/privacy-center.d.ts +31 -7
  8. package/dist/privacy-center.js +173 -38
  9. package/dist/privacy-center.mjs +173 -38
  10. package/package.json +1 -1
  11. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +5 -0
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +29 -4
  13. package/src/RedactoNoticeConsent/api/index.ts +131 -2
  14. package/src/RedactoNoticeConsent/api/types.ts +33 -1
  15. package/src/RedactoNoticeConsent/types.ts +1 -0
  16. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +3 -1
  17. package/src/RedactoNoticeConsentInline/api/index.ts +5 -2
  18. package/src/RedactoNoticeConsentInline/api/types.ts +1 -1
  19. package/src/RedactoNoticeConsentInline/types.ts +1 -0
  20. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +3 -1
  21. package/src/RedactoPrivacyCenter/api/actions.ts +10 -3
  22. package/src/RedactoPrivacyCenter/api/client.ts +67 -21
  23. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +12 -1
  24. package/src/RedactoPrivacyCenter/components/Form.tsx +58 -6
  25. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +64 -1
  26. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +8 -2
  27. package/src/RedactoPrivacyCenter/context/types.ts +1 -0
  28. package/src/RedactoPrivacyCenter/lib/api.ts +8 -0
  29. package/src/RedactoPrivacyCenter/lib/constants.ts +9 -0
  30. package/src/RedactoPrivacyCenter/lib/types.ts +11 -1
  31. package/tests/Form.test.tsx +5 -0
  32. package/tests/mocks.ts +1 -0
@@ -8576,13 +8576,12 @@ async function fetchGroupPurposesServer(params) {
8576
8576
  }
8577
8577
  }
8578
8578
  async function manageConsentServer(params) {
8579
- var _a;
8580
8579
  try {
8581
8580
  if (!params.consentClient) {
8582
8581
  throw new Error("Consent client is required");
8583
8582
  }
8584
8583
  const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
8585
- const result = await params.consentClient.manageConsent(
8584
+ await params.consentClient.manageConsent(
8586
8585
  baseRequest,
8587
8586
  params.purposeId,
8588
8587
  params.contact,
@@ -8591,9 +8590,11 @@ async function manageConsentServer(params) {
8591
8590
  void 0,
8592
8591
  params.nominatorContact,
8593
8592
  params.dataElementUuids,
8594
- params.productUuid
8593
+ params.productUuid,
8594
+ params.noticeUuid,
8595
+ params.dataElements
8595
8596
  );
8596
- return (_a = result == null ? void 0 : result.success) != null ? _a : null;
8597
+ return true;
8597
8598
  } catch (error) {
8598
8599
  const errorMsg = (error == null ? void 0 : error.message) || "";
8599
8600
  if (errorMsg.includes("nominator_not_found")) {
@@ -9030,6 +9031,7 @@ var MIN_REFRESH_BACKOFF_MS = 3e4;
9030
9031
  var MAX_REFRESH_BACKOFF_MS = 3e5;
9031
9032
  var REFRESH_TOKEN_STORAGE_KEY = "redacto_pc_refresh_token";
9032
9033
  var LEGACY_REFRESH_TOKEN_COOKIE = "refresh_token";
9034
+ var FORM_PICKER_GROUP_LIMIT = 100;
9033
9035
  var POLL_INTERVAL = 3e4;
9034
9036
  var NOTIFICATION_PAGE_SIZE = 10;
9035
9037
  var NOTIFICATION_DROPDOWN_LIMIT = 5;
@@ -9693,7 +9695,9 @@ var manageConsent = async ({
9693
9695
  consentClient,
9694
9696
  nominatorContact,
9695
9697
  dataElementUuids,
9696
- productUuid
9698
+ productUuid,
9699
+ noticeUuid,
9700
+ dataElements
9697
9701
  }) => {
9698
9702
  try {
9699
9703
  const accessToken = await ensureValidToken(organizationId, workspaceId, void 0, authContext, consentClient);
@@ -9710,7 +9714,9 @@ var manageConsent = async ({
9710
9714
  consentClient,
9711
9715
  nominatorContact,
9712
9716
  dataElementUuids,
9713
- productUuid
9717
+ productUuid,
9718
+ noticeUuid,
9719
+ dataElements
9714
9720
  });
9715
9721
  return result;
9716
9722
  } catch (error) {
@@ -9730,7 +9736,9 @@ var manageConsent = async ({
9730
9736
  consentClient,
9731
9737
  nominatorContact,
9732
9738
  dataElementUuids,
9733
- productUuid
9739
+ productUuid,
9740
+ noticeUuid,
9741
+ dataElements
9734
9742
  });
9735
9743
  }
9736
9744
  console.error(`Error ${action} consent:`, error);
@@ -10039,7 +10047,11 @@ var useAuth = () => {
10039
10047
  import { createContext as createContext3, useContext as useContext3, useMemo as useMemo2 } from "react";
10040
10048
 
10041
10049
  // src/RedactoPrivacyCenter/api/client.ts
10042
- function createConsentClient(baseUrl) {
10050
+ function createConsentClient(baseUrl, ledgerBaseUrl) {
10051
+ if (!ledgerBaseUrl) {
10052
+ throw new Error("ledgerBaseUrl is required to create the consent client");
10053
+ }
10054
+ const ledgerBase = ledgerBaseUrl.replace(/\/$/, "");
10043
10055
  async function privacyCenterApiFetch(url, options) {
10044
10056
  const { authToken, getAuthToken, headers = {}, ...fetchOptions } = options;
10045
10057
  const requestHeaders = new Headers();
@@ -10132,6 +10144,9 @@ function createConsentClient(baseUrl) {
10132
10144
  function privacyCenterUrl(baseRequest, path) {
10133
10145
  return `${baseUrl}/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}/dsar/privacy-center${path}`;
10134
10146
  }
10147
+ function ledgerUrl(baseRequest, path) {
10148
+ return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
10149
+ }
10135
10150
  function caseUrl(baseRequest, caseUuid, path = "") {
10136
10151
  return privacyCenterUrl(baseRequest, `/cases/${caseUuid}${path}`);
10137
10152
  }
@@ -10265,7 +10280,8 @@ function createConsentClient(baseUrl) {
10265
10280
  if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
10266
10281
  if (params.language) queryParams.append("language", params.language);
10267
10282
  const queryString = queryParams.toString();
10268
- const url = privacyCenterUrl(baseRequest, `/user-consents${queryString ? `?${queryString}` : ""}`);
10283
+ const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
10284
+ const url = ledgerUrl(baseRequest, suffix);
10269
10285
  return privacyCenterApiFetch(url, {
10270
10286
  ...options,
10271
10287
  method: "GET",
@@ -10286,10 +10302,8 @@ function createConsentClient(baseUrl) {
10286
10302
  if (params.language) queryParams.append("language", params.language);
10287
10303
  if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
10288
10304
  const queryString = queryParams.toString();
10289
- const url = privacyCenterUrl(
10290
- baseRequest,
10291
- `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`
10292
- );
10305
+ const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
10306
+ const url = ledgerUrl(baseRequest, suffix);
10293
10307
  return privacyCenterApiFetch(url, {
10294
10308
  ...options,
10295
10309
  method: "GET",
@@ -10298,20 +10312,48 @@ function createConsentClient(baseUrl) {
10298
10312
  });
10299
10313
  },
10300
10314
  /**
10301
- * Manage consent (privacy center) - revoke, regrant, or renew
10315
+ * Manage consent (privacy center) - revoke, regrant, or renew.
10316
+ *
10317
+ * The Go ledger has no manage-consent route, so this is expressed as a
10318
+ * `partial` submit-consent on the ledger — mirroring the Python dual-write
10319
+ * relay (`build_consent_management_payload`): the affected purpose is
10320
+ * re-submitted with `selected: false` to revoke or `selected: true` to
10321
+ * regrant/renew, and `partial: true` so the rest of the notice's purposes
10322
+ * are left untouched. The principal is resolved from the JWT, so no
10323
+ * `contact`/identifiers are sent in the body, and `consent_event_uuid` is
10324
+ * omitted so the ledger mints a fresh event.
10302
10325
  */
10303
- async manageConsent(baseRequest, purposeId, contact, status, authToken, options, nominatorContact, dataElementUuids, productUuid) {
10304
- const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
10305
- const body = { purpose_uuid: purposeId, contact };
10306
- if (nominatorContact) {
10307
- body.nominator_contact = nominatorContact;
10308
- }
10309
- if (Array.isArray(dataElementUuids) && dataElementUuids.length > 0) {
10310
- body.data_element_uuids = dataElementUuids;
10311
- }
10312
- if (productUuid) {
10313
- body.product_uuid = productUuid;
10314
- }
10326
+ async manageConsent(baseRequest, purposeId, contact, status, authToken, options, nominatorContact, dataElementUuids, productUuid, noticeUuid, dataElements) {
10327
+ if (!noticeUuid) {
10328
+ throw new Error("notice_uuid is required to manage consent via the ledger");
10329
+ }
10330
+ const purposeSelected = status !== "revoke";
10331
+ const elements = (dataElements != null ? dataElements : []).map((de) => {
10332
+ let selected;
10333
+ if (status === "revoke") {
10334
+ selected = false;
10335
+ } else if (status === "regrant") {
10336
+ selected = Array.isArray(dataElementUuids) ? dataElementUuids.includes(de.uuid) : de.selected;
10337
+ } else {
10338
+ selected = de.selected;
10339
+ }
10340
+ return { uuid: de.uuid, selected };
10341
+ });
10342
+ const body = {
10343
+ notice_uuid: noticeUuid,
10344
+ partial: true,
10345
+ declined: false,
10346
+ select_all_mandatory: false,
10347
+ self_declared_adult: false,
10348
+ purposes: [
10349
+ {
10350
+ purpose_uuid: purposeId,
10351
+ selected: purposeSelected,
10352
+ data_elements: elements
10353
+ }
10354
+ ]
10355
+ };
10356
+ const url = ledgerUrl(baseRequest, "/submit-consent");
10315
10357
  return privacyCenterApiFetch(url, {
10316
10358
  ...options,
10317
10359
  method: "POST",
@@ -10348,7 +10390,7 @@ function createConsentClient(baseUrl) {
10348
10390
  if (params.created_before) queryParams.append("created_before", params.created_before);
10349
10391
  if (params.language) queryParams.append("language", params.language);
10350
10392
  const queryString = queryParams.toString();
10351
- const url = privacyCenterUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
10393
+ const url = ledgerUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
10352
10394
  return privacyCenterApiFetch(url, {
10353
10395
  ...options,
10354
10396
  method: "GET",
@@ -10363,7 +10405,7 @@ function createConsentClient(baseUrl) {
10363
10405
  * and the Authorization header must be sent (anchor navigation wouldn't carry it).
10364
10406
  */
10365
10407
  async getReceiptPdf(baseRequest, receiptUuid, authToken) {
10366
- const url = privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
10408
+ const url = ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`);
10367
10409
  const token = resolveAuthToken(authToken, baseRequest);
10368
10410
  const requestHeaders = new Headers();
10369
10411
  requestHeaders.set("Accept", "application/pdf");
@@ -10549,15 +10591,20 @@ import { jsx as jsx4 } from "react/jsx-runtime";
10549
10591
  var BaseUrlContext = createContext3(null);
10550
10592
  var BaseUrlProvider = ({
10551
10593
  children,
10552
- baseUrl
10594
+ baseUrl,
10595
+ ledgerBaseUrl
10553
10596
  }) => {
10554
- const consentClient = useMemo2(() => createConsentClient(baseUrl), [baseUrl]);
10597
+ const consentClient = useMemo2(
10598
+ () => createConsentClient(baseUrl, ledgerBaseUrl),
10599
+ [baseUrl, ledgerBaseUrl]
10600
+ );
10555
10601
  const value = useMemo2(
10556
10602
  () => ({
10557
10603
  baseUrl,
10604
+ ledgerBaseUrl,
10558
10605
  consentClient
10559
10606
  }),
10560
- [baseUrl, consentClient]
10607
+ [baseUrl, ledgerBaseUrl, consentClient]
10561
10608
  );
10562
10609
  return /* @__PURE__ */ jsx4(BaseUrlContext.Provider, { value, children });
10563
10610
  };
@@ -18112,6 +18159,7 @@ function ConsentManager({ variant }) {
18112
18159
  );
18113
18160
  const handleManageConsent = useCallback13(
18114
18161
  async (purposeId, action, nominatorContact, dataElementUuids, productUuid) => {
18162
+ var _a2, _b2;
18115
18163
  if (!organizationId || !workspaceId || !contact) {
18116
18164
  toast.error(t("missingInfoToManage"));
18117
18165
  return;
@@ -18128,6 +18176,11 @@ function ConsentManager({ variant }) {
18128
18176
  renew: t("unableToRenewConsent")
18129
18177
  };
18130
18178
  try {
18179
+ const noticeUuid = (_a2 = selectedRecord == null ? void 0 : selectedRecord.notice_uuid) != null ? _a2 : void 0;
18180
+ const dataElements = ((_b2 = selectedRecord == null ? void 0 : selectedRecord.data_elements) != null ? _b2 : []).map((el) => ({
18181
+ uuid: el.uuid,
18182
+ selected: el.selected
18183
+ }));
18131
18184
  const response = await manageConsent({
18132
18185
  organizationId,
18133
18186
  workspaceId,
@@ -18138,7 +18191,9 @@ function ConsentManager({ variant }) {
18138
18191
  consentClient,
18139
18192
  nominatorContact,
18140
18193
  dataElementUuids,
18141
- productUuid
18194
+ productUuid,
18195
+ noticeUuid,
18196
+ dataElements
18142
18197
  });
18143
18198
  if (response) {
18144
18199
  toast.success(successMessages[action]);
@@ -18160,7 +18215,7 @@ function ConsentManager({ variant }) {
18160
18215
  handleCloseModal();
18161
18216
  }
18162
18217
  },
18163
- [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient]
18218
+ [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord]
18164
18219
  );
18165
18220
  const getVar26 = (varName) => `var(${varName})`;
18166
18221
  const directBadge = (_c = (_b = consentDetail == null ? void 0 : consentDetail.status_summary) == null ? void 0 : _b.direct_purposes) != null ? _c : "";
@@ -18685,6 +18740,43 @@ import { AiOutlineClose } from "react-icons/ai";
18685
18740
  import { PiUploadBold } from "react-icons/pi";
18686
18741
 
18687
18742
  // src/RedactoPrivacyCenter/components/groupHelpers.ts
18743
+ var consentGroupToFormProductGroup = (group) => {
18744
+ var _a;
18745
+ return {
18746
+ product_uuid: group.product_uuid,
18747
+ product_name: group.product_name,
18748
+ product_description: group.product_description,
18749
+ nominator: group.nominator,
18750
+ purposes: ((_a = group.purposes) != null ? _a : []).map(purposeItemToFormPurpose)
18751
+ };
18752
+ };
18753
+ var purposeItemToFormPurpose = (purpose) => {
18754
+ var _a, _b;
18755
+ return {
18756
+ uuid: purpose.purpose_uuid,
18757
+ purpose_uuid: purpose.purpose_uuid,
18758
+ name: purpose.name,
18759
+ description: (_a = purpose.description) != null ? _a : "",
18760
+ industries: "",
18761
+ selected: false,
18762
+ given_consent: false,
18763
+ status: purpose.status,
18764
+ validity: null,
18765
+ enabled: true,
18766
+ data_elements: ((_b = purpose.data_elements) != null ? _b : []).map(
18767
+ consentDataElementToFormDataElement
18768
+ )
18769
+ };
18770
+ };
18771
+ var consentDataElementToFormDataElement = (element) => ({
18772
+ uuid: element.uuid,
18773
+ name: element.name,
18774
+ description: "",
18775
+ enabled: element.enabled,
18776
+ required: element.required,
18777
+ given_consent: element.selected,
18778
+ selected: element.selected
18779
+ });
18688
18780
  var buildGroupPurposes = (group) => group.purposes.filter((p) => p !== null && p !== void 0).map((p) => {
18689
18781
  const validDataElements = Array.isArray(p.data_elements) ? p.data_elements.filter((de) => de !== null && de !== void 0 && Boolean(de.uuid)).map((de) => ({ ...de, selected: false })) : [];
18690
18782
  return {
@@ -19913,6 +20005,44 @@ function Form({
19913
20005
  },
19914
20006
  []
19915
20007
  );
20008
+ const withLedgerPickerGroups = useCallback14(
20009
+ async (form, language) => {
20010
+ var _a2, _b2;
20011
+ if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
20012
+ return form;
20013
+ }
20014
+ const consents = await fetchUserConsents({
20015
+ organizationId,
20016
+ workspaceId,
20017
+ authContext,
20018
+ consentClient,
20019
+ // No status filter: include every status (active / expired / withdrawn
20020
+ // / declined), matching Python form/data, so a DSAR can target products
20021
+ // the user no longer actively consents to.
20022
+ limit: FORM_PICKER_GROUP_LIMIT,
20023
+ nominatedLimit: FORM_PICKER_GROUP_LIMIT,
20024
+ language
20025
+ });
20026
+ if (!consents) {
20027
+ return form;
20028
+ }
20029
+ return {
20030
+ ...form,
20031
+ direct_groups: ((_a2 = consents.direct) != null ? _a2 : []).map(consentGroupToFormProductGroup),
20032
+ nominated_groups: ((_b2 = consents.nominated) != null ? _b2 : []).map(
20033
+ consentGroupToFormProductGroup
20034
+ )
20035
+ };
20036
+ },
20037
+ [
20038
+ isPopup,
20039
+ isCaseManagementContext,
20040
+ organizationId,
20041
+ workspaceId,
20042
+ authContext,
20043
+ consentClient
20044
+ ]
20045
+ );
19916
20046
  const fetchAndApplyTranslations = useCallback14(async (langValue) => {
19917
20047
  if (!organizationId || !workspaceId || !data) {
19918
20048
  return;
@@ -19941,7 +20071,7 @@ function Form({
19941
20071
  toast.info(t("translationNotAvailable"));
19942
20072
  return;
19943
20073
  }
19944
- setData(translatedFormData);
20074
+ setData(await withLedgerPickerGroups(translatedFormData, langValue));
19945
20075
  if (translatedFormData.grievance_options) {
19946
20076
  const currentGrievanceSelections = { ...selectedGrievances };
19947
20077
  setGrievanceOptions(translatedFormData.grievance_options);
@@ -19957,7 +20087,7 @@ function Form({
19957
20087
  } finally {
19958
20088
  setIsTranslating(false);
19959
20089
  }
19960
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t]);
20090
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withLedgerPickerGroups]);
19961
20091
  useEffect22(() => {
19962
20092
  if (!skipProductStep) {
19963
20093
  setSelectedProductKey(null);
@@ -20063,7 +20193,11 @@ function Form({
20063
20193
  console.warn("No purposes with data elements found in API response");
20064
20194
  }
20065
20195
  }
20066
- setData(displayData);
20196
+ const pickerData = await withLedgerPickerGroups(
20197
+ displayData,
20198
+ langParam || void 0
20199
+ );
20200
+ setData(pickerData);
20067
20201
  setGrievanceOptions(displayData.grievance_options || []);
20068
20202
  const initialGrievances = (displayData.grievance_options || []).reduce(
20069
20203
  (acc, option) => {
@@ -20083,7 +20217,7 @@ function Form({
20083
20217
  }
20084
20218
  };
20085
20219
  void initializePrivacyCenterForm();
20086
- }, [organizationId, workspaceId, isPopup, authContext, consentClient]);
20220
+ }, [organizationId, workspaceId, isPopup, authContext, consentClient, withLedgerPickerGroups]);
20087
20221
  const getEnglishDataElementName = useCallback14((translatedName) => {
20088
20222
  var _a2, _b2, _c2;
20089
20223
  if (!((_a2 = englishDataRef.current) == null ? void 0 : _a2.purposes)) {
@@ -27502,6 +27636,7 @@ import { jsx as jsx90 } from "react/jsx-runtime";
27502
27636
  injectPCStyles();
27503
27637
  function RedactoPrivacyCenter({
27504
27638
  baseUrl,
27639
+ ledgerBaseUrl,
27505
27640
  slug,
27506
27641
  accessToken,
27507
27642
  refreshToken,
@@ -27509,7 +27644,7 @@ function RedactoPrivacyCenter({
27509
27644
  initialPage = "consent-manager",
27510
27645
  onBack
27511
27646
  }) {
27512
- return /* @__PURE__ */ jsx90(I18nextProvider, { i18n: i18n_default, children: /* @__PURE__ */ jsx90(BrandingProvider, { slug, baseUrl, children: /* @__PURE__ */ jsx90(BaseUrlProvider, { baseUrl, children: /* @__PURE__ */ jsx90(
27647
+ return /* @__PURE__ */ jsx90(I18nextProvider, { i18n: i18n_default, children: /* @__PURE__ */ jsx90(BrandingProvider, { slug, baseUrl, children: /* @__PURE__ */ jsx90(BaseUrlProvider, { baseUrl, ledgerBaseUrl, children: /* @__PURE__ */ jsx90(
27513
27648
  AuthProvider,
27514
27649
  {
27515
27650
  accessToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "9.5.0-beta.0",
3
+ "version": "10.0.0-beta.0",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -26,6 +26,11 @@ 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(),
29
34
  }));
30
35
 
31
36
  // Mock jwt-decode
@@ -11,8 +11,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
11
11
 
12
12
  // API imports
13
13
  import {
14
+ buildLedgerConsentOverlay,
14
15
  clearApiCache,
15
16
  fetchConsentContent,
17
+ fetchNoticeConsentStatus,
16
18
  fetchTTSAudioUrls,
17
19
  initiateGuardianVerification,
18
20
  submitConsentEvent,
@@ -1296,6 +1298,7 @@ export const RedactoNoticeConsent = ({
1296
1298
  accessToken,
1297
1299
  refreshToken,
1298
1300
  baseUrl,
1301
+ ledgerBaseUrl,
1299
1302
  language = "en",
1300
1303
  blockUI = true,
1301
1304
  onAccept,
@@ -1838,9 +1841,31 @@ export const RedactoNoticeConsent = ({
1838
1841
  );
1839
1842
  setCollapsedPurposes(initialCollapsedState);
1840
1843
 
1841
- // Initialize selected states - handle reconsent pre-population
1842
- const isReconsentRequired = consentContentData.detail.reconsent_required;
1843
- const purposeSelections = consentContentData.detail.purpose_selections;
1844
+ // 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
+ }
1844
1869
 
1845
1870
  // Categorize purposes for reconsent UI
1846
1871
  const newCategorizedPurposes = isReconsentRequired
@@ -2120,7 +2145,7 @@ export const RedactoNoticeConsent = ({
2120
2145
  if (content) {
2121
2146
  await submitConsentEvent({
2122
2147
  accessToken,
2123
- baseUrl,
2148
+ ledgerBaseUrl,
2124
2149
  noticeUuid: content.notice_uuid,
2125
2150
  purposes: content.purposes.map((purpose) => {
2126
2151
  // Check if this is an already-consented purpose that was modified
@@ -3,10 +3,14 @@ import { parseApiError } from "../../shared/api-errors";
3
3
  import type {
4
4
  ConsentContent,
5
5
  ConsentEventPayload,
6
+ DataElementSelection,
6
7
  FetchConsentContentParams,
8
+ FetchNoticeConsentStatusParams,
7
9
  FetchTTSAudioUrlsParams,
8
10
  InitiateGuardianVerificationParams,
9
11
  InitiateGuardianVerificationResponse,
12
+ NoticeConsentStatusResponse,
13
+ PurposeSelection,
10
14
  RedactoJwtPayload,
11
15
  SubmitConsentEventParams,
12
16
  TTSAudioUrlsResponse,
@@ -127,7 +131,7 @@ export const fetchConsentContent = async ({
127
131
 
128
132
  export const submitConsentEvent = async ({
129
133
  accessToken,
130
- baseUrl,
134
+ ledgerBaseUrl,
131
135
  noticeUuid,
132
136
  purposes,
133
137
  declined,
@@ -151,7 +155,10 @@ export const submitConsentEvent = async ({
151
155
  throw new Error("Invalid token: missing organization or workspace UUID");
152
156
  }
153
157
 
154
- const apiBaseUrl = baseUrl || BASE_URL;
158
+ if (!ledgerBaseUrl) {
159
+ throw new Error("ledgerBaseUrl is required to submit consent");
160
+ }
161
+ const apiBaseUrl = ledgerBaseUrl;
155
162
 
156
163
  const validatedPurposes = purposes.map((purpose) => {
157
164
  if (!purpose.uuid) {
@@ -209,6 +216,128 @@ export const submitConsentEvent = async ({
209
216
  apiCache.clear();
210
217
  };
211
218
 
219
+ /**
220
+ * Fetch the principal's current consent status for a notice from the Go
221
+ * ledger (`check-consent`). The principal resolves from the JWT, so the body
222
+ * is empty. Returns null on any failure so the caller can fall back to the
223
+ * Python-provided overlay (graceful degradation). This is how the banner
224
+ * learns prior-consent / reconsent state post-cutover, since Python's notice
225
+ * validation reads its own (now-empty) consent DB.
226
+ */
227
+ export const fetchNoticeConsentStatus = async ({
228
+ accessToken,
229
+ ledgerBaseUrl,
230
+ noticeUuid,
231
+ signal,
232
+ }: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
233
+ if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
234
+ return null;
235
+ }
236
+
237
+ const decodedToken = decodeTokenSafely(accessToken);
238
+ if (!decodedToken) {
239
+ return null;
240
+ }
241
+
242
+ const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
243
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
244
+ return null;
245
+ }
246
+
247
+ try {
248
+ const response = await fetch(
249
+ `${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
250
+ {
251
+ method: "POST",
252
+ headers: {
253
+ Authorization: `Bearer ${accessToken}`,
254
+ "Content-Type": "application/json",
255
+ },
256
+ body: "{}",
257
+ signal,
258
+ }
259
+ );
260
+
261
+ if (!response.ok) {
262
+ return null;
263
+ }
264
+
265
+ const data = (await response.json()) as
266
+ | NoticeConsentStatusResponse
267
+ | { detail?: NoticeConsentStatusResponse };
268
+ // The ledger returns the response directly; tolerate a {detail} envelope too.
269
+ const body =
270
+ data && typeof data === "object" && "detail" in data && data.detail
271
+ ? data.detail
272
+ : (data as NoticeConsentStatusResponse);
273
+
274
+ if (!body || !Array.isArray(body.purposes)) {
275
+ return null;
276
+ }
277
+ return body;
278
+ } catch {
279
+ // Network / abort / parse failure — degrade to the Python overlay.
280
+ return null;
281
+ }
282
+ };
283
+
284
+ /**
285
+ * Translate the ledger's notice consent status into the
286
+ * `{ purpose_selections, reconsent_required }` shape the banner already
287
+ * consumes, so prior-consent pre-fill and reconsent categorization work off
288
+ * the ledger. Returns null for a first-time principal (no consent record —
289
+ * every purpose INACTIVE), preserving the normal first-time consent flow.
290
+ *
291
+ * `needs_reconsent` is derived from `status` (anything not ACTIVE needs
292
+ * reconsent: expired / withdrawn / declined / inactive). NOTE: notice-version
293
+ * reconsent is NOT covered — the ledger's check-consent has no version
294
+ * awareness yet, so a republished-notice version bump won't re-prompt until
295
+ * that lands on the ledger (tracked fast-follow).
296
+ */
297
+ export const buildLedgerConsentOverlay = (
298
+ status: NoticeConsentStatusResponse,
299
+ configPurposes: ConsentContent["detail"]["active_config"]["purposes"]
300
+ ): {
301
+ reconsent_required: boolean;
302
+ purpose_selections: Record<string, PurposeSelection>;
303
+ } | null => {
304
+ const byUuid = new Map(status.purposes.map((p) => [p.uuid, p]));
305
+ const hasHistory = status.purposes.some(
306
+ (p) => (p.status || "INACTIVE") !== "INACTIVE"
307
+ );
308
+ if (!hasHistory) {
309
+ return null;
310
+ }
311
+
312
+ const purpose_selections: Record<string, PurposeSelection> = {};
313
+ for (const cp of configPurposes) {
314
+ const lp = byUuid.get(cp.uuid);
315
+ const purposeStatus = lp?.status || "INACTIVE";
316
+ const selectedByUuid = new Map(
317
+ (lp?.data_elements || []).map((de) => [de.uuid, de.selected])
318
+ );
319
+ const data_elements: Record<string, DataElementSelection> = {};
320
+ for (const de of cp.data_elements) {
321
+ data_elements[de.uuid] = {
322
+ selected: selectedByUuid.get(de.uuid) ?? false,
323
+ enabled: de.enabled,
324
+ required: de.required,
325
+ };
326
+ }
327
+ purpose_selections[cp.uuid] = {
328
+ selected: lp?.selected ?? false,
329
+ status: purposeStatus,
330
+ needs_reconsent: purposeStatus !== "ACTIVE",
331
+ data_elements,
332
+ };
333
+ }
334
+
335
+ const reconsent_required = configPurposes.some(
336
+ (cp) => (purpose_selections[cp.uuid]?.status || "INACTIVE") !== "ACTIVE"
337
+ );
338
+ return { reconsent_required, purpose_selections };
339
+ };
340
+
212
341
  export const fetchTTSAudioUrls = async ({
213
342
  accessToken,
214
343
  baseUrl,