@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.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +20 -0
- package/README.md +4 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +135 -47
- package/dist/index.mjs +135 -47
- package/package.json +1 -1
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
- package/src/RedactoNoticeAssisted/api/index.ts +7 -1
- package/src/RedactoNoticeAssisted/api/types.ts +2 -0
- package/src/RedactoNoticeAssisted/types.ts +2 -0
- package/src/RedactoNoticeConsent/api/index.ts +5 -66
- package/src/RedactoNoticeConsent/api/types.ts +9 -30
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +59 -1
- package/src/shared/notice-consent-status.ts +190 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
[34mCLI[39m Cleaning output folder
|
|
8
8
|
[34mESM[39m Build start
|
|
9
9
|
[34mCJS[39m Build start
|
|
10
|
-
[32mCJS[39m [1mdist/index.js [22m[
|
|
10
|
+
[32mCJS[39m [1mdist/index.js [22m[32m516.41 KB[39m
|
|
11
11
|
[32mCJS[39m [1mdist/privacy-center.js [22m[32m2.21 MB[39m
|
|
12
|
-
[32mCJS[39m ⚡️ Build success in
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m499.63 KB[39m
|
|
12
|
+
[32mCJS[39m ⚡️ Build success in 179ms
|
|
14
13
|
[32mESM[39m [1mdist/chunk-6A665OPR.mjs [22m[32m841.00 B[39m
|
|
14
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m501.90 KB[39m
|
|
15
15
|
[32mESM[39m [1mdist/privacy-center.mjs [22m[32m2.17 MB[39m
|
|
16
|
-
[32mESM[39m ⚡️ Build success in
|
|
16
|
+
[32mESM[39m ⚡️ Build success in 184ms
|
|
17
17
|
DTS Build start
|
|
18
|
-
DTS ⚡️ Build success in
|
|
19
|
-
DTS dist/index.d.mts 8.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
export type
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|