@redacto.io/consent-sdk-react 10.0.0-beta.4 → 10.0.0-beta.6

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.
@@ -7,16 +7,16 @@
7
7
  CLI Cleaning output folder
8
8
  ESM Build start
9
9
  CJS Build start
10
- CJS dist/index.js 514.08 KB
10
+ CJS dist/index.js 516.41 KB
11
11
  CJS dist/privacy-center.js 2.21 MB
12
- CJS ⚡️ Build success in 219ms
13
- ESM dist/index.mjs 499.63 KB
12
+ CJS ⚡️ Build success in 179ms
14
13
  ESM dist/chunk-6A665OPR.mjs 841.00 B
14
+ ESM dist/index.mjs 501.90 KB
15
15
  ESM dist/privacy-center.mjs 2.17 MB
16
- ESM ⚡️ Build success in 232ms
16
+ ESM ⚡️ Build success in 184ms
17
17
  DTS Build start
18
- DTS ⚡️ Build success in 7326ms
19
- DTS dist/index.d.mts 8.27 KB
18
+ DTS ⚡️ Build success in 3448ms
19
+ DTS dist/index.d.mts 8.42 KB
20
20
  DTS dist/privacy-center.d.mts 33.31 KB
21
- DTS dist/index.d.ts 8.27 KB
21
+ DTS dist/index.d.ts 8.42 KB
22
22
  DTS dist/privacy-center.d.ts 33.31 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @redacto.io/consent-sdk-react
2
2
 
3
+ ## 10.0.0-beta.6
4
+
5
+ ### Patch Changes
6
+
7
+ - minor bug fix in beta version
8
+
9
+ ## 10.0.0-beta.5
10
+
11
+ ### Major Changes
12
+
13
+ - M3 Version
14
+
15
+ ### Minor Changes
16
+
17
+ - 1a11423: RedactoNoticeAssisted: add `ledgerBaseUrl` support with the standard fallback, for parity with RedactoNoticeConsent and RedactoNoticeConsentInline. When `ledgerBaseUrl` is set, the consent write is routed through `ledgerBaseUrl || baseUrl || BASE_URL` (only `submit-consent` moves to the ledger; the public notice / OTP / TTS endpoints stay on `baseUrl`). After OTP verification the widget also runs a ledger `check-consent` read and, if the principal has already fully consented, hands off via `onComplete` without writing a duplicate consent event — the ledger equivalent of the legacy Python 409 `CONSENT_ALREADY_PROVIDED`, which no longer fires once consent records live in the ledger. Without `ledgerBaseUrl` the behavior is unchanged (write to the legacy consent server, reactive 409-as-success). The ledger `check-consent` network read is now shared across all three notice components.
18
+
19
+ ### Patch Changes
20
+
21
+ - 1a11423: RedactoNoticeConsentInline: detect already-consented principals via the Go ledger after the consent cutover. Previously the embedded widget only recognized an already-consented user through Python's 409 `CONSENT_ALREADY_PROVIDED` on `get-notice`; once consent records move to the ledger, Python stops returning that 409, so the widget would re-render the notice and could auto-submit a duplicate consent. When `ledgerBaseUrl` is configured (and outside the magic-link `specific_uuid` flow), the widget now runs the same ledger `check-consent` read the banner uses and short-circuits to the already-consented state, gating auto-submit until that read settles. Without `ledgerBaseUrl` the legacy Python-409 path is unchanged. The ledger `check-consent` read is now shared between `RedactoNoticeConsent` and `RedactoNoticeConsentInline`.
22
+
3
23
  ## 10.0.0-beta.4
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -249,6 +249,7 @@ A modal popup component for consent collection. Displays a full-screen modal wit
249
249
  | `accessToken` | `string` | Yes | - | Authentication token for API calls |
250
250
  | `refreshToken` | `string` | Yes | - | Refresh token for token renewal |
251
251
  | `baseUrl` | `string` | No | - | Base URL for the consent API |
252
+ | `ledgerBaseUrl` | `string` | No | - | Go ledger base URL. When set, consent writes and prior-consent checks route through the ledger (falls back: ledger → baseUrl → default). Omit to keep the legacy consent-server path. |
252
253
  | `blockUI` | `boolean` | No | `true` | Whether to block UI interaction |
253
254
  | `language` | `string` | No | `"en"` | Language code |
254
255
  | `settings` | `object` | No | - | Custom styling options |
@@ -335,6 +336,7 @@ The component automatically submits consent when all of the following conditions
335
336
  | `accessToken` | `string` | No | - | Authentication token |
336
337
  | `refreshToken` | `string` | No | - | Refresh token |
337
338
  | `baseUrl` | `string` | No | - | Base URL for the consent API |
339
+ | `ledgerBaseUrl` | `string` | No | - | Go ledger base URL. When set, consent writes and already-consented checks route through the ledger. Omit for legacy behavior. |
338
340
  | `language` | `string` | No | `"en"` | Language code |
339
341
  | `settings` | `object` | No | - | Custom styling options |
340
342
  | `applicationId` | `string` | No | - | Application-specific UUID |
@@ -365,6 +367,7 @@ A modal, agent-assisted consent widget for collecting consent from an end-user w
365
367
  | `workspaceUuid` | `string` | Yes | - | Workspace UUID (path segment of the public notice endpoint) |
366
368
  | `noticeUuid` | `string` | Yes | - | Notice UUID to fetch and render |
367
369
  | `baseUrl` | `string` | No | Production consent API | Base URL for the consent API |
370
+ | `ledgerBaseUrl` | `string` | No | - | Go ledger base URL. When set, the consent write routes through the ledger (falls back: ledger → baseUrl → default). Notice fetch, OTP, and TTS always stay on `baseUrl`. Omit for legacy behavior. |
368
371
  | `onDecline` | `() => void` | No | Logs and resets the flow | Called when the agent/customer declines (host should unmount the modal) |
369
372
  | `onComplete` | `() => void` | No | Logs and resets the flow | Called when the agent clicks "Proceed" on the confirmed screen |
370
373
 
@@ -761,6 +764,7 @@ import { RedactoPrivacyCenter } from "@redacto.io/consent-sdk-react/privacy-cent
761
764
  | Prop | Type | Required | Default | Description |
762
765
  | -------------- | -------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------- | -------------------------------------------------------- |
763
766
  | `baseUrl` | `string` | Yes | - | Consent server base URL |
767
+ | `ledgerBaseUrl` | `string` | No | - | Go ledger base URL. When set, user consents, group purposes, receipts, and manage-consent route through the ledger, and the DSAR form's product picker is overlaid from ledger-served consents. Omit to use the legacy `/dsar/privacy-center/*` routes. |
764
768
  | `slug` | `string` | Yes | - | Workspace slug |
765
769
  | `accessToken` | `string` | Yes | - | JWT access token |
766
770
  | `refreshToken` | `string` | Yes | - | JWT refresh token |
package/dist/index.d.mts CHANGED
@@ -139,6 +139,8 @@ type RedactoNoticeAssistedProps = {
139
139
  noticeUuid: string;
140
140
  /** API base URL (defaults to the production consent API). */
141
141
  baseUrl?: string;
142
+ /** Go ledger base URL — routes the consent write to the ledger; omit to use the legacy consent server. */
143
+ ledgerBaseUrl?: string;
142
144
  /** Called when the agent/customer declines (host should unmount the modal). */
143
145
  onDecline?: () => void;
144
146
  /** Called when the agent clicks "Proceed" on the confirmed screen. */
@@ -159,7 +161,7 @@ type RedactoNoticeAssistedProps = {
159
161
  * submit, `onComplete` fires immediately with no confirmation screen (mirroring
160
162
  * RedactoNoticeConsent's `onAccept`). Steps: `notice` → `verify` (with `otpSent`).
161
163
  */
162
- declare const RedactoNoticeAssisted: ({ organisationUuid, workspaceUuid, noticeUuid, baseUrl, onDecline, onComplete, }: RedactoNoticeAssistedProps) => react.JSX.Element;
164
+ declare const RedactoNoticeAssisted: ({ organisationUuid, workspaceUuid, noticeUuid, baseUrl, ledgerBaseUrl, onDecline, onComplete, }: RedactoNoticeAssistedProps) => react.JSX.Element;
163
165
 
164
166
  /**
165
167
  * DigiLockerCallback Component
package/dist/index.d.ts CHANGED
@@ -139,6 +139,8 @@ type RedactoNoticeAssistedProps = {
139
139
  noticeUuid: string;
140
140
  /** API base URL (defaults to the production consent API). */
141
141
  baseUrl?: string;
142
+ /** Go ledger base URL — routes the consent write to the ledger; omit to use the legacy consent server. */
143
+ ledgerBaseUrl?: string;
142
144
  /** Called when the agent/customer declines (host should unmount the modal). */
143
145
  onDecline?: () => void;
144
146
  /** Called when the agent clicks "Proceed" on the confirmed screen. */
@@ -159,7 +161,7 @@ type RedactoNoticeAssistedProps = {
159
161
  * submit, `onComplete` fires immediately with no confirmation screen (mirroring
160
162
  * RedactoNoticeConsent's `onAccept`). Steps: `notice` → `verify` (with `otpSent`).
161
163
  */
162
- declare const RedactoNoticeAssisted: ({ organisationUuid, workspaceUuid, noticeUuid, baseUrl, onDecline, onComplete, }: RedactoNoticeAssistedProps) => react.JSX.Element;
164
+ declare const RedactoNoticeAssisted: ({ organisationUuid, workspaceUuid, noticeUuid, baseUrl, ledgerBaseUrl, onDecline, onComplete, }: RedactoNoticeAssistedProps) => react.JSX.Element;
163
165
 
164
166
  /**
165
167
  * DigiLockerCallback Component
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@ module.exports = __toCommonJS(src_exports);
32
32
  var import_react2 = require("react");
33
33
 
34
34
  // src/RedactoNoticeConsent/api/index.ts
35
- var import_jwt_decode = require("jwt-decode");
35
+ var import_jwt_decode2 = require("jwt-decode");
36
36
 
37
37
  // src/shared/api-errors.ts
38
38
  var ApiError = class extends Error {
@@ -92,6 +92,83 @@ async function parseApiError(response, fallbackMessage) {
92
92
  });
93
93
  }
94
94
 
95
+ // src/shared/notice-consent-status.ts
96
+ var import_jwt_decode = require("jwt-decode");
97
+ var decodeNoticeConsentToken = (token) => {
98
+ try {
99
+ return (0, import_jwt_decode.jwtDecode)(token);
100
+ } catch {
101
+ return null;
102
+ }
103
+ };
104
+ var fetchNoticeConsentStatusForScope = async ({
105
+ accessToken,
106
+ ledgerBaseUrl,
107
+ organisationUuid,
108
+ workspaceUuid,
109
+ noticeUuid,
110
+ signal
111
+ }) => {
112
+ if (!accessToken || !ledgerBaseUrl || !organisationUuid || !workspaceUuid || !noticeUuid) {
113
+ return null;
114
+ }
115
+ try {
116
+ const response = await fetch(
117
+ `${ledgerBaseUrl}/public/organisations/${organisationUuid}/workspaces/${workspaceUuid}/notices/${noticeUuid}/check-consent`,
118
+ {
119
+ method: "POST",
120
+ headers: {
121
+ Authorization: `Bearer ${accessToken}`,
122
+ "Content-Type": "application/json"
123
+ },
124
+ body: "{}",
125
+ signal
126
+ }
127
+ );
128
+ if (!response.ok) {
129
+ return null;
130
+ }
131
+ const data = await response.json();
132
+ const body = data && typeof data === "object" && "detail" in data && data.detail ? data.detail : data;
133
+ if (!body || !Array.isArray(body.purposes)) {
134
+ return null;
135
+ }
136
+ return body;
137
+ } catch {
138
+ return null;
139
+ }
140
+ };
141
+ var fetchNoticeConsentStatus = async ({
142
+ accessToken,
143
+ ledgerBaseUrl,
144
+ noticeUuid,
145
+ signal
146
+ }) => {
147
+ if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
148
+ return null;
149
+ }
150
+ const decodedToken = decodeNoticeConsentToken(accessToken);
151
+ if (!decodedToken) {
152
+ return null;
153
+ }
154
+ const {
155
+ organisation_uuid: ORGANISATION_UUID,
156
+ workspace_uuid: WORKSPACE_UUID
157
+ } = decodedToken;
158
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
159
+ return null;
160
+ }
161
+ return fetchNoticeConsentStatusForScope({
162
+ accessToken,
163
+ ledgerBaseUrl,
164
+ organisationUuid: ORGANISATION_UUID,
165
+ workspaceUuid: WORKSPACE_UUID,
166
+ noticeUuid,
167
+ signal
168
+ });
169
+ };
170
+ var isNoticeFullyConsented = (status) => status.consented && status.all_mandatory_active;
171
+
95
172
  // src/RedactoNoticeConsent/api/index.ts
96
173
  var BASE_URL = "https://api.redacto.io/consent";
97
174
  var apiCache = /* @__PURE__ */ new Map();
@@ -111,7 +188,7 @@ function setCachedData(key, data, ttl = CACHE_TTL) {
111
188
  }
112
189
  function decodeTokenSafely(token) {
113
190
  try {
114
- return (0, import_jwt_decode.jwtDecode)(token);
191
+ return (0, import_jwt_decode2.jwtDecode)(token);
115
192
  } catch (error) {
116
193
  console.warn("Failed to decode JWT token:", error);
117
194
  return null;
@@ -249,49 +326,6 @@ var submitConsentEvent = async ({
249
326
  }
250
327
  apiCache.clear();
251
328
  };
252
- var fetchNoticeConsentStatus = async ({
253
- accessToken,
254
- ledgerBaseUrl,
255
- noticeUuid,
256
- signal
257
- }) => {
258
- if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
259
- return null;
260
- }
261
- const decodedToken = decodeTokenSafely(accessToken);
262
- if (!decodedToken) {
263
- return null;
264
- }
265
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
266
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
267
- return null;
268
- }
269
- try {
270
- const response = await fetch(
271
- `${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
272
- {
273
- method: "POST",
274
- headers: {
275
- Authorization: `Bearer ${accessToken}`,
276
- "Content-Type": "application/json"
277
- },
278
- body: "{}",
279
- signal
280
- }
281
- );
282
- if (!response.ok) {
283
- return null;
284
- }
285
- const data = await response.json();
286
- const body = data && typeof data === "object" && "detail" in data && data.detail ? data.detail : data;
287
- if (!body || !Array.isArray(body.purposes)) {
288
- return null;
289
- }
290
- return body;
291
- } catch {
292
- return null;
293
- }
294
- };
295
329
  var buildLedgerConsentOverlay = (status, configPurposes) => {
296
330
  var _a, _b;
297
331
  const byUuid = new Map(status.purposes.map((p) => [p.uuid, p]));
@@ -5250,6 +5284,9 @@ var RedactoConsentInlineComponent = ({
5250
5284
  const [selectedPurposes, setSelectedPurposes] = (0, import_react4.useState)({});
5251
5285
  const [selectedDataElements, setSelectedDataElements] = (0, import_react4.useState)({});
5252
5286
  const [hasAlreadyConsented, setHasAlreadyConsented] = (0, import_react4.useState)(false);
5287
+ const [isCheckingConsent, setIsCheckingConsent] = (0, import_react4.useState)(
5288
+ Boolean(ledgerBaseUrl) && !applicationId
5289
+ );
5253
5290
  const [fetchError, setFetchError] = (0, import_react4.useState)(null);
5254
5291
  const [errorMessage, setErrorMessage] = (0, import_react4.useState)(null);
5255
5292
  const hasSubmittedRef = (0, import_react4.useRef)(false);
@@ -5392,6 +5429,38 @@ var RedactoConsentInlineComponent = ({
5392
5429
  (0, import_react4.useEffect)(() => {
5393
5430
  fetchNotice();
5394
5431
  }, [org_uuid, workspace_uuid, notice_uuid, language, applicationId]);
5432
+ (0, import_react4.useEffect)(() => {
5433
+ if (!ledgerBaseUrl || applicationId) {
5434
+ return;
5435
+ }
5436
+ if (!accessToken || !content) {
5437
+ return;
5438
+ }
5439
+ let active = true;
5440
+ const controller = new AbortController();
5441
+ const checkLedgerConsent = async () => {
5442
+ try {
5443
+ const ledgerStatus = await fetchNoticeConsentStatus({
5444
+ accessToken,
5445
+ ledgerBaseUrl,
5446
+ noticeUuid: notice_uuid,
5447
+ signal: controller.signal
5448
+ });
5449
+ if (active && ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
5450
+ setHasAlreadyConsented(true);
5451
+ }
5452
+ } finally {
5453
+ if (active) {
5454
+ setIsCheckingConsent(false);
5455
+ }
5456
+ }
5457
+ };
5458
+ checkLedgerConsent();
5459
+ return () => {
5460
+ active = false;
5461
+ controller.abort();
5462
+ };
5463
+ }, [ledgerBaseUrl, applicationId, accessToken, content, notice_uuid]);
5395
5464
  const togglePurposeCollapse = (purposeUuid) => {
5396
5465
  setCollapsedPurposes((prev) => ({
5397
5466
  ...prev,
@@ -5515,7 +5584,7 @@ var RedactoConsentInlineComponent = ({
5515
5584
  ]
5516
5585
  );
5517
5586
  (0, import_react4.useEffect)(() => {
5518
- if (accessToken && content && areAllRequiredElementsChecked && !isSubmitting && !hasSubmittedRef.current) {
5587
+ if (accessToken && content && areAllRequiredElementsChecked && !isSubmitting && !isCheckingConsent && !hasAlreadyConsented && !hasSubmittedRef.current) {
5519
5588
  hasSubmittedRef.current = true;
5520
5589
  const autoSubmit = async () => {
5521
5590
  try {
@@ -5532,6 +5601,8 @@ var RedactoConsentInlineComponent = ({
5532
5601
  content,
5533
5602
  areAllRequiredElementsChecked,
5534
5603
  isSubmitting,
5604
+ isCheckingConsent,
5605
+ hasAlreadyConsented,
5535
5606
  handleAcceptWithToken
5536
5607
  ]);
5537
5608
  (0, import_react4.useEffect)(() => {
@@ -6792,6 +6863,7 @@ var submitAssistedConsent = async ({
6792
6863
  organisationUuid,
6793
6864
  workspaceUuid,
6794
6865
  baseUrl,
6866
+ ledgerBaseUrl,
6795
6867
  noticeUuid,
6796
6868
  purposes,
6797
6869
  language,
@@ -6822,7 +6894,7 @@ var submitAssistedConsent = async ({
6822
6894
  declined: false,
6823
6895
  language: language || void 0
6824
6896
  };
6825
- const url = `${publicScope(baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
6897
+ const url = `${publicScope(ledgerBaseUrl || baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
6826
6898
  const response = await fetch(url, {
6827
6899
  method: "POST",
6828
6900
  headers: {
@@ -8257,6 +8329,7 @@ var RedactoNoticeAssisted = ({
8257
8329
  workspaceUuid,
8258
8330
  noticeUuid,
8259
8331
  baseUrl,
8332
+ ledgerBaseUrl,
8260
8333
  onDecline,
8261
8334
  onComplete
8262
8335
  }) => {
@@ -8992,6 +9065,19 @@ var RedactoNoticeAssisted = ({
8992
9065
  return;
8993
9066
  }
8994
9067
  const token = verifyDetail.access_token;
9068
+ if (ledgerBaseUrl) {
9069
+ const ledgerStatus = await fetchNoticeConsentStatusForScope({
9070
+ accessToken: token,
9071
+ ledgerBaseUrl,
9072
+ organisationUuid,
9073
+ workspaceUuid,
9074
+ noticeUuid: content.notice_uuid
9075
+ });
9076
+ if (ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
9077
+ handleSuccess();
9078
+ return;
9079
+ }
9080
+ }
8995
9081
  const submitPurposes = content.purposes.map((purpose) => ({
8996
9082
  ...purpose,
8997
9083
  selected: selectedPurposes[purpose.uuid] || false,
@@ -9005,6 +9091,7 @@ var RedactoNoticeAssisted = ({
9005
9091
  organisationUuid,
9006
9092
  workspaceUuid,
9007
9093
  baseUrl,
9094
+ ledgerBaseUrl,
9008
9095
  noticeUuid: content.notice_uuid,
9009
9096
  purposes: submitPurposes,
9010
9097
  language: toBcp47Code(selectedLanguage)
@@ -9028,6 +9115,7 @@ var RedactoNoticeAssisted = ({
9028
9115
  otpUuid,
9029
9116
  otpValue,
9030
9117
  baseUrl,
9118
+ ledgerBaseUrl,
9031
9119
  selectedPurposes,
9032
9120
  selectedDataElements,
9033
9121
  selectedLanguage,
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
 
8
8
  // src/RedactoNoticeConsent/api/index.ts
9
- import { jwtDecode } from "jwt-decode";
9
+ import { jwtDecode as jwtDecode2 } from "jwt-decode";
10
10
 
11
11
  // src/shared/api-errors.ts
12
12
  var ApiError = class extends Error {
@@ -66,6 +66,83 @@ async function parseApiError(response, fallbackMessage) {
66
66
  });
67
67
  }
68
68
 
69
+ // src/shared/notice-consent-status.ts
70
+ import { jwtDecode } from "jwt-decode";
71
+ var decodeNoticeConsentToken = (token) => {
72
+ try {
73
+ return jwtDecode(token);
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+ var fetchNoticeConsentStatusForScope = async ({
79
+ accessToken,
80
+ ledgerBaseUrl,
81
+ organisationUuid,
82
+ workspaceUuid,
83
+ noticeUuid,
84
+ signal
85
+ }) => {
86
+ if (!accessToken || !ledgerBaseUrl || !organisationUuid || !workspaceUuid || !noticeUuid) {
87
+ return null;
88
+ }
89
+ try {
90
+ const response = await fetch(
91
+ `${ledgerBaseUrl}/public/organisations/${organisationUuid}/workspaces/${workspaceUuid}/notices/${noticeUuid}/check-consent`,
92
+ {
93
+ method: "POST",
94
+ headers: {
95
+ Authorization: `Bearer ${accessToken}`,
96
+ "Content-Type": "application/json"
97
+ },
98
+ body: "{}",
99
+ signal
100
+ }
101
+ );
102
+ if (!response.ok) {
103
+ return null;
104
+ }
105
+ const data = await response.json();
106
+ const body = data && typeof data === "object" && "detail" in data && data.detail ? data.detail : data;
107
+ if (!body || !Array.isArray(body.purposes)) {
108
+ return null;
109
+ }
110
+ return body;
111
+ } catch {
112
+ return null;
113
+ }
114
+ };
115
+ var fetchNoticeConsentStatus = async ({
116
+ accessToken,
117
+ ledgerBaseUrl,
118
+ noticeUuid,
119
+ signal
120
+ }) => {
121
+ if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
122
+ return null;
123
+ }
124
+ const decodedToken = decodeNoticeConsentToken(accessToken);
125
+ if (!decodedToken) {
126
+ return null;
127
+ }
128
+ const {
129
+ organisation_uuid: ORGANISATION_UUID,
130
+ workspace_uuid: WORKSPACE_UUID
131
+ } = decodedToken;
132
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
133
+ return null;
134
+ }
135
+ return fetchNoticeConsentStatusForScope({
136
+ accessToken,
137
+ ledgerBaseUrl,
138
+ organisationUuid: ORGANISATION_UUID,
139
+ workspaceUuid: WORKSPACE_UUID,
140
+ noticeUuid,
141
+ signal
142
+ });
143
+ };
144
+ var isNoticeFullyConsented = (status) => status.consented && status.all_mandatory_active;
145
+
69
146
  // src/RedactoNoticeConsent/api/index.ts
70
147
  var BASE_URL = "https://api.redacto.io/consent";
71
148
  var apiCache = /* @__PURE__ */ new Map();
@@ -85,7 +162,7 @@ function setCachedData(key, data, ttl = CACHE_TTL) {
85
162
  }
86
163
  function decodeTokenSafely(token) {
87
164
  try {
88
- return jwtDecode(token);
165
+ return jwtDecode2(token);
89
166
  } catch (error) {
90
167
  console.warn("Failed to decode JWT token:", error);
91
168
  return null;
@@ -223,49 +300,6 @@ var submitConsentEvent = async ({
223
300
  }
224
301
  apiCache.clear();
225
302
  };
226
- var fetchNoticeConsentStatus = async ({
227
- accessToken,
228
- ledgerBaseUrl,
229
- noticeUuid,
230
- signal
231
- }) => {
232
- if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
233
- return null;
234
- }
235
- const decodedToken = decodeTokenSafely(accessToken);
236
- if (!decodedToken) {
237
- return null;
238
- }
239
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
240
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
241
- return null;
242
- }
243
- try {
244
- const response = await fetch(
245
- `${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
246
- {
247
- method: "POST",
248
- headers: {
249
- Authorization: `Bearer ${accessToken}`,
250
- "Content-Type": "application/json"
251
- },
252
- body: "{}",
253
- signal
254
- }
255
- );
256
- if (!response.ok) {
257
- return null;
258
- }
259
- const data = await response.json();
260
- const body = data && typeof data === "object" && "detail" in data && data.detail ? data.detail : data;
261
- if (!body || !Array.isArray(body.purposes)) {
262
- return null;
263
- }
264
- return body;
265
- } catch {
266
- return null;
267
- }
268
- };
269
303
  var buildLedgerConsentOverlay = (status, configPurposes) => {
270
304
  var _a, _b;
271
305
  const byUuid = new Map(status.purposes.map((p) => [p.uuid, p]));
@@ -5197,6 +5231,9 @@ var RedactoConsentInlineComponent = ({
5197
5231
  const [selectedPurposes, setSelectedPurposes] = useState3({});
5198
5232
  const [selectedDataElements, setSelectedDataElements] = useState3({});
5199
5233
  const [hasAlreadyConsented, setHasAlreadyConsented] = useState3(false);
5234
+ const [isCheckingConsent, setIsCheckingConsent] = useState3(
5235
+ Boolean(ledgerBaseUrl) && !applicationId
5236
+ );
5200
5237
  const [fetchError, setFetchError] = useState3(null);
5201
5238
  const [errorMessage, setErrorMessage] = useState3(null);
5202
5239
  const hasSubmittedRef = useRef2(false);
@@ -5339,6 +5376,38 @@ var RedactoConsentInlineComponent = ({
5339
5376
  useEffect3(() => {
5340
5377
  fetchNotice();
5341
5378
  }, [org_uuid, workspace_uuid, notice_uuid, language, applicationId]);
5379
+ useEffect3(() => {
5380
+ if (!ledgerBaseUrl || applicationId) {
5381
+ return;
5382
+ }
5383
+ if (!accessToken || !content) {
5384
+ return;
5385
+ }
5386
+ let active = true;
5387
+ const controller = new AbortController();
5388
+ const checkLedgerConsent = async () => {
5389
+ try {
5390
+ const ledgerStatus = await fetchNoticeConsentStatus({
5391
+ accessToken,
5392
+ ledgerBaseUrl,
5393
+ noticeUuid: notice_uuid,
5394
+ signal: controller.signal
5395
+ });
5396
+ if (active && ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
5397
+ setHasAlreadyConsented(true);
5398
+ }
5399
+ } finally {
5400
+ if (active) {
5401
+ setIsCheckingConsent(false);
5402
+ }
5403
+ }
5404
+ };
5405
+ checkLedgerConsent();
5406
+ return () => {
5407
+ active = false;
5408
+ controller.abort();
5409
+ };
5410
+ }, [ledgerBaseUrl, applicationId, accessToken, content, notice_uuid]);
5342
5411
  const togglePurposeCollapse = (purposeUuid) => {
5343
5412
  setCollapsedPurposes((prev) => ({
5344
5413
  ...prev,
@@ -5462,7 +5531,7 @@ var RedactoConsentInlineComponent = ({
5462
5531
  ]
5463
5532
  );
5464
5533
  useEffect3(() => {
5465
- if (accessToken && content && areAllRequiredElementsChecked && !isSubmitting && !hasSubmittedRef.current) {
5534
+ if (accessToken && content && areAllRequiredElementsChecked && !isSubmitting && !isCheckingConsent && !hasAlreadyConsented && !hasSubmittedRef.current) {
5466
5535
  hasSubmittedRef.current = true;
5467
5536
  const autoSubmit = async () => {
5468
5537
  try {
@@ -5479,6 +5548,8 @@ var RedactoConsentInlineComponent = ({
5479
5548
  content,
5480
5549
  areAllRequiredElementsChecked,
5481
5550
  isSubmitting,
5551
+ isCheckingConsent,
5552
+ hasAlreadyConsented,
5482
5553
  handleAcceptWithToken
5483
5554
  ]);
5484
5555
  useEffect3(() => {
@@ -6739,6 +6810,7 @@ var submitAssistedConsent = async ({
6739
6810
  organisationUuid,
6740
6811
  workspaceUuid,
6741
6812
  baseUrl,
6813
+ ledgerBaseUrl,
6742
6814
  noticeUuid,
6743
6815
  purposes,
6744
6816
  language,
@@ -6769,7 +6841,7 @@ var submitAssistedConsent = async ({
6769
6841
  declined: false,
6770
6842
  language: language || void 0
6771
6843
  };
6772
- const url = `${publicScope(baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
6844
+ const url = `${publicScope(ledgerBaseUrl || baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
6773
6845
  const response = await fetch(url, {
6774
6846
  method: "POST",
6775
6847
  headers: {
@@ -8204,6 +8276,7 @@ var RedactoNoticeAssisted = ({
8204
8276
  workspaceUuid,
8205
8277
  noticeUuid,
8206
8278
  baseUrl,
8279
+ ledgerBaseUrl,
8207
8280
  onDecline,
8208
8281
  onComplete
8209
8282
  }) => {
@@ -8939,6 +9012,19 @@ var RedactoNoticeAssisted = ({
8939
9012
  return;
8940
9013
  }
8941
9014
  const token = verifyDetail.access_token;
9015
+ if (ledgerBaseUrl) {
9016
+ const ledgerStatus = await fetchNoticeConsentStatusForScope({
9017
+ accessToken: token,
9018
+ ledgerBaseUrl,
9019
+ organisationUuid,
9020
+ workspaceUuid,
9021
+ noticeUuid: content.notice_uuid
9022
+ });
9023
+ if (ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
9024
+ handleSuccess();
9025
+ return;
9026
+ }
9027
+ }
8942
9028
  const submitPurposes = content.purposes.map((purpose) => ({
8943
9029
  ...purpose,
8944
9030
  selected: selectedPurposes[purpose.uuid] || false,
@@ -8952,6 +9038,7 @@ var RedactoNoticeAssisted = ({
8952
9038
  organisationUuid,
8953
9039
  workspaceUuid,
8954
9040
  baseUrl,
9041
+ ledgerBaseUrl,
8955
9042
  noticeUuid: content.notice_uuid,
8956
9043
  purposes: submitPurposes,
8957
9044
  language: toBcp47Code(selectedLanguage)
@@ -8975,6 +9062,7 @@ var RedactoNoticeAssisted = ({
8975
9062
  otpUuid,
8976
9063
  otpValue,
8977
9064
  baseUrl,
9065
+ ledgerBaseUrl,
8978
9066
  selectedPurposes,
8979
9067
  selectedDataElements,
8980
9068
  selectedLanguage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "10.0.0-beta.4",
3
+ "version": "10.0.0-beta.6",
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. */
@@ -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. */
@@ -5,7 +5,6 @@ import type {
5
5
  ConsentEventPayload,
6
6
  DataElementSelection,
7
7
  FetchConsentContentParams,
8
- FetchNoticeConsentStatusParams,
9
8
  FetchTTSAudioUrlsParams,
10
9
  InitiateGuardianVerificationParams,
11
10
  InitiateGuardianVerificationResponse,
@@ -18,6 +17,11 @@ import type {
18
17
  VerifyGuardianStatusResponse,
19
18
  } from "./types";
20
19
 
20
+ // The ledger `check-consent` read is shared with RedactoNoticeConsentInline.
21
+ // Re-exported through this barrel so `./api` importers (and the test mock)
22
+ // resolve it here unchanged.
23
+ export { fetchNoticeConsentStatus } from "../../shared/notice-consent-status";
24
+
21
25
  const BASE_URL = "https://api.redacto.io/consent";
22
26
 
23
27
  // Simple in-memory cache for API responses to reduce calls
@@ -216,71 +220,6 @@ export const submitConsentEvent = async ({
216
220
  apiCache.clear();
217
221
  };
218
222
 
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
223
  /**
285
224
  * Translate the ledger's notice consent status into the
286
225
  * `{ purpose_selections, reconsent_required }` shape the banner already
@@ -20,36 +20,15 @@ export type DataElementSelection = {
20
20
  };
21
21
 
22
22
  // ─── Ledger notice check-consent ────────────────────────────────────────────
23
- // Mirrors the Go ledger's `NoticeConsentStatusResponse`. Used to overlay the
24
- // banner's prior-consent / reconsent state from the ledger, since Python's
25
- // notice-validation overlay is empty after the consent cutover.
26
- export type NoticeConsentStatusDataElement = {
27
- uuid: string;
28
- name: string;
29
- selected: boolean;
30
- };
31
-
32
- export type NoticeConsentStatusPurpose = {
33
- uuid: string;
34
- name: string;
35
- selected: boolean;
36
- status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED | INACTIVE
37
- expiry_datetime?: string | null;
38
- data_elements: NoticeConsentStatusDataElement[];
39
- };
40
-
41
- export type NoticeConsentStatusResponse = {
42
- consented: boolean;
43
- all_mandatory_active: boolean;
44
- purposes: NoticeConsentStatusPurpose[];
45
- };
46
-
47
- export type FetchNoticeConsentStatusParams = {
48
- accessToken: string;
49
- ledgerBaseUrl?: string;
50
- noticeUuid: string;
51
- signal?: AbortSignal;
52
- };
23
+ // These types moved to the shared module so the banner and the inline widget
24
+ // consume one definition; re-exported here so existing `./types` importers
25
+ // keep working unchanged.
26
+ export type {
27
+ FetchNoticeConsentStatusParams,
28
+ NoticeConsentStatusDataElement,
29
+ NoticeConsentStatusPurpose,
30
+ NoticeConsentStatusResponse,
31
+ } from "../../shared/notice-consent-status";
53
32
 
54
33
  export type ConsentContent = {
55
34
  code: number;
@@ -1,5 +1,9 @@
1
1
  import { useEffect, useMemo, useState, useRef, useCallback, memo } from "react";
2
2
  import { fetchConsentContent, submitConsentEvent } from "./api";
3
+ import {
4
+ fetchNoticeConsentStatus,
5
+ isNoticeFullyConsented,
6
+ } from "../shared/notice-consent-status";
3
7
  import type { ConsentContent } from "./api/types";
4
8
  import type { Props, TranslationObject } from "./types";
5
9
  import { styles } from "./styles";
@@ -36,6 +40,14 @@ const RedactoConsentInlineComponent = ({
36
40
  Record<string, boolean>
37
41
  >({});
38
42
  const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
43
+ // When a ledger is configured, block the auto-submit effect until the ledger
44
+ // check-consent read settles, so an already-consented principal is not
45
+ // re-submitted. Starts true only when a ledger check will actually run
46
+ // (skipped for the magic-link specific_uuid flow); stays false otherwise so
47
+ // the legacy Python-409 path is unaffected.
48
+ const [isCheckingConsent, setIsCheckingConsent] = useState<boolean>(
49
+ Boolean(ledgerBaseUrl) && !applicationId
50
+ );
39
51
  const [fetchError, setFetchError] = useState<Error | null>(null);
40
52
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
41
53
 
@@ -220,6 +232,45 @@ const RedactoConsentInlineComponent = ({
220
232
  // eslint-disable-next-line react-hooks/exhaustive-deps
221
233
  }, [org_uuid, workspace_uuid, notice_uuid, language, applicationId]);
222
234
 
235
+ // Post-cutover, consent records live in the Go ledger, so Python's
236
+ // get-notice no longer returns 409 CONSENT_ALREADY_PROVIDED. When a ledger
237
+ // is configured, ask it directly whether this principal has already fully
238
+ // consented — the same check-consent read the banner uses. Skipped for the
239
+ // magic-link specific_uuid flow (which always re-submits against a specific
240
+ // application) and when no ledger is set (the legacy 409 path governs).
241
+ useEffect(() => {
242
+ if (!ledgerBaseUrl || applicationId) {
243
+ return;
244
+ }
245
+ if (!accessToken || !content) {
246
+ return;
247
+ }
248
+ let active = true;
249
+ const controller = new AbortController();
250
+ const checkLedgerConsent = async () => {
251
+ try {
252
+ const ledgerStatus = await fetchNoticeConsentStatus({
253
+ accessToken,
254
+ ledgerBaseUrl,
255
+ noticeUuid: notice_uuid,
256
+ signal: controller.signal,
257
+ });
258
+ if (active && ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
259
+ setHasAlreadyConsented(true);
260
+ }
261
+ } finally {
262
+ if (active) {
263
+ setIsCheckingConsent(false);
264
+ }
265
+ }
266
+ };
267
+ checkLedgerConsent();
268
+ return () => {
269
+ active = false;
270
+ controller.abort();
271
+ };
272
+ }, [ledgerBaseUrl, applicationId, accessToken, content, notice_uuid]);
273
+
223
274
  const togglePurposeCollapse = (purposeUuid: string) => {
224
275
  setCollapsedPurposes((prev) => ({
225
276
  ...prev,
@@ -392,13 +443,18 @@ const RedactoConsentInlineComponent = ({
392
443
  ]
393
444
  );
394
445
 
395
- // Auto-submit consent when all conditions are met
446
+ // Auto-submit consent when all conditions are met. Waits for the ledger
447
+ // check-consent read to settle (isCheckingConsent) and never fires for an
448
+ // already-consented principal, so the cutover cannot trigger a duplicate
449
+ // submission.
396
450
  useEffect(() => {
397
451
  if (
398
452
  accessToken &&
399
453
  content &&
400
454
  areAllRequiredElementsChecked &&
401
455
  !isSubmitting &&
456
+ !isCheckingConsent &&
457
+ !hasAlreadyConsented &&
402
458
  !hasSubmittedRef.current
403
459
  ) {
404
460
  hasSubmittedRef.current = true;
@@ -417,6 +473,8 @@ const RedactoConsentInlineComponent = ({
417
473
  content,
418
474
  areAllRequiredElementsChecked,
419
475
  isSubmitting,
476
+ isCheckingConsent,
477
+ hasAlreadyConsented,
420
478
  handleAcceptWithToken,
421
479
  ]);
422
480
 
@@ -0,0 +1,190 @@
1
+ import { jwtDecode } from "jwt-decode";
2
+
3
+ /**
4
+ * Shared Go-ledger notice `check-consent` read for the consent-sdk-react
5
+ * package.
6
+ *
7
+ * Post-cutover, consent records live in the Go ledger, so the Python
8
+ * `get-notice` endpoint no longer knows whether a principal has already
9
+ * consented (it used to answer that with a 409 `CONSENT_ALREADY_PROVIDED`).
10
+ * Both the banner (`RedactoNoticeConsent`) and the embedded widget
11
+ * (`RedactoNoticeConsentInline`) ask the ledger directly instead — this module
12
+ * is the single implementation they share.
13
+ *
14
+ * Every failure mode (no ledger configured, undecodable token, non-OK
15
+ * response, network/abort/parse error) degrades to `null` so callers can fall
16
+ * back to the legacy Python-driven flow.
17
+ */
18
+
19
+ type NoticeConsentJwtPayload = {
20
+ organisation_uuid: string;
21
+ workspace_uuid: string;
22
+ };
23
+
24
+ // Mirrors the Go ledger's `NoticeConsentStatusResponse`. Used to overlay the
25
+ // prior-consent / reconsent state from the ledger, since Python's
26
+ // notice-validation overlay is empty after the consent cutover.
27
+ export type NoticeConsentStatusDataElement = {
28
+ uuid: string;
29
+ name: string;
30
+ selected: boolean;
31
+ };
32
+
33
+ export type NoticeConsentStatusPurpose = {
34
+ uuid: string;
35
+ name: string;
36
+ selected: boolean;
37
+ status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED | INACTIVE
38
+ expiry_datetime?: string | null;
39
+ data_elements: NoticeConsentStatusDataElement[];
40
+ };
41
+
42
+ export type NoticeConsentStatusResponse = {
43
+ consented: boolean;
44
+ all_mandatory_active: boolean;
45
+ purposes: NoticeConsentStatusPurpose[];
46
+ };
47
+
48
+ export type FetchNoticeConsentStatusParams = {
49
+ accessToken: string;
50
+ ledgerBaseUrl?: string;
51
+ noticeUuid: string;
52
+ signal?: AbortSignal;
53
+ };
54
+
55
+ // Same read, but with org/workspace supplied explicitly instead of decoded from
56
+ // the JWT. Used by flows that already hold authoritative org/workspace (props)
57
+ // and whose token may be opaque — e.g. RedactoNoticeAssisted, whose OTP access
58
+ // token carries no org/workspace claims.
59
+ export type FetchNoticeConsentStatusForScopeParams = {
60
+ accessToken: string;
61
+ ledgerBaseUrl?: string;
62
+ organisationUuid: string;
63
+ workspaceUuid: string;
64
+ noticeUuid: string;
65
+ signal?: AbortSignal;
66
+ };
67
+
68
+ // Decode the JWT to resolve the org/workspace the ledger path is scoped to.
69
+ // Returns null on any decode failure so the caller degrades gracefully.
70
+ const decodeNoticeConsentToken = (
71
+ token: string
72
+ ): NoticeConsentJwtPayload | null => {
73
+ try {
74
+ return jwtDecode(token) as NoticeConsentJwtPayload;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Fetch the principal's current consent status for a notice from the Go
82
+ * ledger (`check-consent`), with org/workspace supplied explicitly. The
83
+ * principal resolves from the bearer token server-side, so the body is empty.
84
+ * Returns null on any failure so the caller can fall back to the Python-driven
85
+ * flow (graceful degradation). This holds the single network implementation;
86
+ * {@link fetchNoticeConsentStatus} decodes the JWT and delegates here.
87
+ */
88
+ export const fetchNoticeConsentStatusForScope = async ({
89
+ accessToken,
90
+ ledgerBaseUrl,
91
+ organisationUuid,
92
+ workspaceUuid,
93
+ noticeUuid,
94
+ signal,
95
+ }: FetchNoticeConsentStatusForScopeParams): Promise<NoticeConsentStatusResponse | null> => {
96
+ if (
97
+ !accessToken ||
98
+ !ledgerBaseUrl ||
99
+ !organisationUuid ||
100
+ !workspaceUuid ||
101
+ !noticeUuid
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ try {
107
+ const response = await fetch(
108
+ `${ledgerBaseUrl}/public/organisations/${organisationUuid}/workspaces/${workspaceUuid}/notices/${noticeUuid}/check-consent`,
109
+ {
110
+ method: "POST",
111
+ headers: {
112
+ Authorization: `Bearer ${accessToken}`,
113
+ "Content-Type": "application/json",
114
+ },
115
+ body: "{}",
116
+ signal,
117
+ }
118
+ );
119
+
120
+ if (!response.ok) {
121
+ return null;
122
+ }
123
+
124
+ const data = (await response.json()) as
125
+ | NoticeConsentStatusResponse
126
+ | { detail?: NoticeConsentStatusResponse };
127
+ // The ledger returns the response directly; tolerate a {detail} envelope too.
128
+ const body =
129
+ data && typeof data === "object" && "detail" in data && data.detail
130
+ ? data.detail
131
+ : (data as NoticeConsentStatusResponse);
132
+
133
+ if (!body || !Array.isArray(body.purposes)) {
134
+ return null;
135
+ }
136
+ return body;
137
+ } catch {
138
+ // Network / abort / parse failure — degrade to the Python-driven flow.
139
+ return null;
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Fetch the principal's current consent status for a notice from the Go
145
+ * ledger (`check-consent`). The org/workspace are resolved by decoding the
146
+ * JWT. Returns null on any failure so the caller can fall back to the
147
+ * Python-provided overlay (graceful degradation).
148
+ */
149
+ export const fetchNoticeConsentStatus = async ({
150
+ accessToken,
151
+ ledgerBaseUrl,
152
+ noticeUuid,
153
+ signal,
154
+ }: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
155
+ if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
156
+ return null;
157
+ }
158
+
159
+ const decodedToken = decodeNoticeConsentToken(accessToken);
160
+ if (!decodedToken) {
161
+ return null;
162
+ }
163
+
164
+ const {
165
+ organisation_uuid: ORGANISATION_UUID,
166
+ workspace_uuid: WORKSPACE_UUID,
167
+ } = decodedToken;
168
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
169
+ return null;
170
+ }
171
+
172
+ return fetchNoticeConsentStatusForScope({
173
+ accessToken,
174
+ ledgerBaseUrl,
175
+ organisationUuid: ORGANISATION_UUID,
176
+ workspaceUuid: WORKSPACE_UUID,
177
+ noticeUuid,
178
+ signal,
179
+ });
180
+ };
181
+
182
+ /**
183
+ * Whether the principal has already fully consented to this notice with no
184
+ * reconsent needed — the ledger equivalent of Python's 409
185
+ * `CONSENT_ALREADY_PROVIDED`. Requires an active consent record with every
186
+ * mandatory purpose still ACTIVE.
187
+ */
188
+ export const isNoticeFullyConsented = (
189
+ status: NoticeConsentStatusResponse
190
+ ): boolean => status.consented && status.all_mandatory_active;