@redacto.io/consent-sdk-react 10.0.0-beta.1 → 10.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +109 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +32 -4
- package/dist/index.d.ts +32 -4
- package/dist/index.js +340 -230
- package/dist/index.mjs +339 -231
- package/dist/privacy-center.d.mts +55 -12
- package/dist/privacy-center.d.ts +55 -12
- package/dist/privacy-center.js +848 -950
- package/dist/privacy-center.mjs +698 -802
- 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/i18n.ts +0 -55
- package/src/RedactoNoticeAssisted/types.ts +2 -0
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +74 -33
- package/src/RedactoNoticeConsent/api/index.ts +88 -175
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +58 -43
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +28 -2
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +85 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +10 -1
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +2 -0
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
- package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
- package/src/RedactoPrivacyCenter/api/client.ts +122 -25
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +47 -45
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +25 -13
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -6
- package/src/RedactoPrivacyCenter/components/constant.ts +3 -3
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/types.ts +38 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +25 -1
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/en/translation.json +4 -3
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -3
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
- package/src/shared/notice-consent-status.ts +190 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +142 -5
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +46 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -340
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { jwtDecode } from "jwt-decode";
|
|
2
2
|
import { parseApiError } from "../../shared/api-errors";
|
|
3
|
+
import {
|
|
4
|
+
buildAuthHeaders,
|
|
5
|
+
resolveRequestAuth,
|
|
6
|
+
sandboxBodyIdentity,
|
|
7
|
+
sandboxQueryIdentity,
|
|
8
|
+
} from "./sandbox";
|
|
3
9
|
import type {
|
|
4
10
|
ConsentContent,
|
|
5
11
|
ConsentEventPayload,
|
|
6
|
-
DataElementSelection,
|
|
7
12
|
FetchConsentContentParams,
|
|
8
|
-
FetchNoticeConsentStatusParams,
|
|
9
13
|
FetchTTSAudioUrlsParams,
|
|
10
14
|
InitiateGuardianVerificationParams,
|
|
11
15
|
InitiateGuardianVerificationResponse,
|
|
12
|
-
NoticeConsentStatusResponse,
|
|
13
|
-
PurposeSelection,
|
|
14
16
|
RedactoJwtPayload,
|
|
15
17
|
SubmitConsentEventParams,
|
|
16
18
|
TTSAudioUrlsResponse,
|
|
@@ -18,6 +20,11 @@ import type {
|
|
|
18
20
|
VerifyGuardianStatusResponse,
|
|
19
21
|
} from "./types";
|
|
20
22
|
|
|
23
|
+
// The ledger `check-consent` read is shared with RedactoNoticeConsentInline.
|
|
24
|
+
// Re-exported through this barrel so `./api` importers (and the test mock)
|
|
25
|
+
// resolve it here unchanged.
|
|
26
|
+
export { fetchNoticeConsentStatus } from "../../shared/notice-consent-status";
|
|
27
|
+
|
|
21
28
|
const BASE_URL = "https://api.redacto.io/consent";
|
|
22
29
|
|
|
23
30
|
// Simple in-memory cache for API responses to reduce calls
|
|
@@ -65,25 +72,28 @@ export const fetchConsentContent = async ({
|
|
|
65
72
|
validate_against = "all",
|
|
66
73
|
include_fully_consented_data = false,
|
|
67
74
|
signal,
|
|
75
|
+
...sandboxParams
|
|
68
76
|
}: FetchConsentContentParams & { signal?: AbortSignal }): Promise<ConsentContent> => {
|
|
69
|
-
if (!noticeId || !
|
|
70
|
-
throw new Error("noticeId
|
|
77
|
+
if (!noticeId || !validate_against) {
|
|
78
|
+
throw new Error("noticeId is required");
|
|
71
79
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!decodedToken) {
|
|
75
|
-
throw new Error("Invalid access token");
|
|
80
|
+
if (!accessToken && !sandboxParams.token) {
|
|
81
|
+
throw new Error("accessToken or token is required");
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
const {
|
|
79
|
-
|
|
80
|
-
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
81
|
-
}
|
|
84
|
+
const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
|
|
85
|
+
resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
|
|
82
86
|
|
|
83
87
|
const apiBaseUrl = baseUrl || BASE_URL;
|
|
84
88
|
|
|
85
|
-
// Create cache key for this request - include
|
|
86
|
-
|
|
89
|
+
// Create cache key for this request - include the auth identity (token or
|
|
90
|
+
// sandbox org/workspace/subject) so different users get different cache
|
|
91
|
+
// entries. Org + workspace are part of the sandbox identity so two sessions
|
|
92
|
+
// sharing a subject but scoped to different orgs never collide.
|
|
93
|
+
const cacheIdentity = sandbox
|
|
94
|
+
? `sandbox:${sandbox.organisationUuid}:${sandbox.workspaceUuid}:${sandbox.ucic || sandbox.subject}`
|
|
95
|
+
: accessToken;
|
|
96
|
+
const cacheKey = `${cacheIdentity}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}-${include_fully_consented_data}`;
|
|
87
97
|
|
|
88
98
|
if (validate_against === "all" && !specific_uuid) {
|
|
89
99
|
const cachedData = getCachedData(cacheKey);
|
|
@@ -103,11 +113,18 @@ export const fetchConsentContent = async ({
|
|
|
103
113
|
if (include_fully_consented_data) {
|
|
104
114
|
url.searchParams.append("include_fully_consented_data", "true");
|
|
105
115
|
}
|
|
116
|
+
// Sandbox mode: the acting identity travels as query params
|
|
117
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
118
|
+
if (sandbox) {
|
|
119
|
+
for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
|
|
120
|
+
url.searchParams.append(key, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
106
123
|
|
|
107
124
|
const response = await fetch(url.toString(), {
|
|
108
125
|
method: "GET",
|
|
109
126
|
headers: {
|
|
110
|
-
|
|
127
|
+
...buildAuthHeaders(accessToken, sandbox),
|
|
111
128
|
"Accept-Language": language,
|
|
112
129
|
},
|
|
113
130
|
signal,
|
|
@@ -141,24 +158,23 @@ export const submitConsentEvent = async ({
|
|
|
141
158
|
guardianVerificationReference,
|
|
142
159
|
selfDeclaredAdult,
|
|
143
160
|
signal,
|
|
161
|
+
...sandboxParams
|
|
144
162
|
}: SubmitConsentEventParams & { signal?: AbortSignal }): Promise<void> => {
|
|
145
|
-
if (!noticeUuid || !
|
|
146
|
-
throw new Error("noticeUuid
|
|
163
|
+
if (!noticeUuid || !Array.isArray(purposes)) {
|
|
164
|
+
throw new Error("noticeUuid and purposes array are required");
|
|
147
165
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!decodedToken) {
|
|
151
|
-
throw new Error("Invalid access token");
|
|
166
|
+
if (!accessToken && !sandboxParams.token) {
|
|
167
|
+
throw new Error("accessToken or token is required");
|
|
152
168
|
}
|
|
153
169
|
|
|
154
|
-
const {
|
|
155
|
-
|
|
156
|
-
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
157
|
-
}
|
|
170
|
+
const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
|
|
171
|
+
resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
|
|
158
172
|
|
|
159
173
|
// Fall back to the consent server when no ledger is configured, so existing
|
|
160
|
-
// integrations that only pass baseUrl remain backwards compatible.
|
|
161
|
-
|
|
174
|
+
// integrations that only pass baseUrl remain backwards compatible. Sandbox
|
|
175
|
+
// never routes to the Go ledger (no X-Consent-Token support), so a sandbox
|
|
176
|
+
// session forces the consent-server path even if a ledgerBaseUrl is set.
|
|
177
|
+
const apiBaseUrl = (sandbox ? null : ledgerBaseUrl) || baseUrl || BASE_URL;
|
|
162
178
|
|
|
163
179
|
const validatedPurposes = purposes.map((purpose) => {
|
|
164
180
|
if (!purpose.uuid) {
|
|
@@ -192,15 +208,19 @@ export const submitConsentEvent = async ({
|
|
|
192
208
|
self_declared_adult: selfDeclaredAdult,
|
|
193
209
|
};
|
|
194
210
|
|
|
211
|
+
// Sandbox mode: merge the acting test identity into the body (the server has
|
|
212
|
+
// no subject header; it namespaces this identity `test::`).
|
|
213
|
+
const requestBody = sandbox ? { ...payload, ...sandboxBodyIdentity(sandbox) } : payload;
|
|
214
|
+
|
|
195
215
|
const response = await fetch(
|
|
196
216
|
`${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/submit-consent`,
|
|
197
217
|
{
|
|
198
218
|
method: "POST",
|
|
199
219
|
headers: {
|
|
200
|
-
|
|
220
|
+
...buildAuthHeaders(accessToken, sandbox),
|
|
201
221
|
"Content-Type": "application/json",
|
|
202
222
|
},
|
|
203
|
-
body: JSON.stringify(
|
|
223
|
+
body: JSON.stringify(requestBody),
|
|
204
224
|
signal,
|
|
205
225
|
}
|
|
206
226
|
);
|
|
@@ -216,157 +236,42 @@ export const submitConsentEvent = async ({
|
|
|
216
236
|
apiCache.clear();
|
|
217
237
|
};
|
|
218
238
|
|
|
219
|
-
/**
|
|
220
|
-
* Fetch the principal's current consent status for a notice from the Go
|
|
221
|
-
* ledger (`check-consent`). The principal resolves from the JWT, so the body
|
|
222
|
-
* is empty. Returns null on any failure so the caller can fall back to the
|
|
223
|
-
* Python-provided overlay (graceful degradation). This is how the banner
|
|
224
|
-
* learns prior-consent / reconsent state post-cutover, since Python's notice
|
|
225
|
-
* validation reads its own (now-empty) consent DB.
|
|
226
|
-
*/
|
|
227
|
-
export const fetchNoticeConsentStatus = async ({
|
|
228
|
-
accessToken,
|
|
229
|
-
ledgerBaseUrl,
|
|
230
|
-
noticeUuid,
|
|
231
|
-
signal,
|
|
232
|
-
}: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
|
|
233
|
-
if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const decodedToken = decodeTokenSafely(accessToken);
|
|
238
|
-
if (!decodedToken) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
|
|
243
|
-
if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
const response = await fetch(
|
|
249
|
-
`${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
|
|
250
|
-
{
|
|
251
|
-
method: "POST",
|
|
252
|
-
headers: {
|
|
253
|
-
Authorization: `Bearer ${accessToken}`,
|
|
254
|
-
"Content-Type": "application/json",
|
|
255
|
-
},
|
|
256
|
-
body: "{}",
|
|
257
|
-
signal,
|
|
258
|
-
}
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
if (!response.ok) {
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const data = (await response.json()) as
|
|
266
|
-
| NoticeConsentStatusResponse
|
|
267
|
-
| { detail?: NoticeConsentStatusResponse };
|
|
268
|
-
// The ledger returns the response directly; tolerate a {detail} envelope too.
|
|
269
|
-
const body =
|
|
270
|
-
data && typeof data === "object" && "detail" in data && data.detail
|
|
271
|
-
? data.detail
|
|
272
|
-
: (data as NoticeConsentStatusResponse);
|
|
273
|
-
|
|
274
|
-
if (!body || !Array.isArray(body.purposes)) {
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
return body;
|
|
278
|
-
} catch {
|
|
279
|
-
// Network / abort / parse failure — degrade to the Python overlay.
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Translate the ledger's notice consent status into the
|
|
286
|
-
* `{ purpose_selections, reconsent_required }` shape the banner already
|
|
287
|
-
* consumes, so prior-consent pre-fill and reconsent categorization work off
|
|
288
|
-
* the ledger. Returns null for a first-time principal (no consent record —
|
|
289
|
-
* every purpose INACTIVE), preserving the normal first-time consent flow.
|
|
290
|
-
*
|
|
291
|
-
* `needs_reconsent` is derived from `status` (anything not ACTIVE needs
|
|
292
|
-
* reconsent: expired / withdrawn / declined / inactive). NOTE: notice-version
|
|
293
|
-
* reconsent is NOT covered — the ledger's check-consent has no version
|
|
294
|
-
* awareness yet, so a republished-notice version bump won't re-prompt until
|
|
295
|
-
* that lands on the ledger (tracked fast-follow).
|
|
296
|
-
*/
|
|
297
|
-
export const buildLedgerConsentOverlay = (
|
|
298
|
-
status: NoticeConsentStatusResponse,
|
|
299
|
-
configPurposes: ConsentContent["detail"]["active_config"]["purposes"]
|
|
300
|
-
): {
|
|
301
|
-
reconsent_required: boolean;
|
|
302
|
-
purpose_selections: Record<string, PurposeSelection>;
|
|
303
|
-
} | null => {
|
|
304
|
-
const byUuid = new Map(status.purposes.map((p) => [p.uuid, p]));
|
|
305
|
-
const hasHistory = status.purposes.some(
|
|
306
|
-
(p) => (p.status || "INACTIVE") !== "INACTIVE"
|
|
307
|
-
);
|
|
308
|
-
if (!hasHistory) {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const purpose_selections: Record<string, PurposeSelection> = {};
|
|
313
|
-
for (const cp of configPurposes) {
|
|
314
|
-
const lp = byUuid.get(cp.uuid);
|
|
315
|
-
const purposeStatus = lp?.status || "INACTIVE";
|
|
316
|
-
const selectedByUuid = new Map(
|
|
317
|
-
(lp?.data_elements || []).map((de) => [de.uuid, de.selected])
|
|
318
|
-
);
|
|
319
|
-
const data_elements: Record<string, DataElementSelection> = {};
|
|
320
|
-
for (const de of cp.data_elements) {
|
|
321
|
-
data_elements[de.uuid] = {
|
|
322
|
-
selected: selectedByUuid.get(de.uuid) ?? false,
|
|
323
|
-
enabled: de.enabled,
|
|
324
|
-
required: de.required,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
purpose_selections[cp.uuid] = {
|
|
328
|
-
selected: lp?.selected ?? false,
|
|
329
|
-
status: purposeStatus,
|
|
330
|
-
needs_reconsent: purposeStatus !== "ACTIVE",
|
|
331
|
-
data_elements,
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const reconsent_required = configPurposes.some(
|
|
336
|
-
(cp) => (purpose_selections[cp.uuid]?.status || "INACTIVE") !== "ACTIVE"
|
|
337
|
-
);
|
|
338
|
-
return { reconsent_required, purpose_selections };
|
|
339
|
-
};
|
|
340
|
-
|
|
341
239
|
export const fetchTTSAudioUrls = async ({
|
|
342
240
|
accessToken,
|
|
343
241
|
baseUrl,
|
|
242
|
+
ledgerBaseUrl,
|
|
344
243
|
noticeUuid,
|
|
345
244
|
language,
|
|
346
245
|
signal,
|
|
246
|
+
...sandboxParams
|
|
347
247
|
}: FetchTTSAudioUrlsParams & { signal?: AbortSignal }): Promise<TTSAudioUrlsResponse> => {
|
|
348
|
-
if (!accessToken || !noticeUuid || !language) {
|
|
349
|
-
throw new Error("accessToken, noticeUuid, and language are required");
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const decodedToken = decodeTokenSafely(accessToken);
|
|
353
|
-
if (!decodedToken) {
|
|
354
|
-
throw new Error("Invalid access token");
|
|
248
|
+
if ((!accessToken && !sandboxParams.token) || !noticeUuid || !language) {
|
|
249
|
+
throw new Error("accessToken (or token), noticeUuid, and language are required");
|
|
355
250
|
}
|
|
356
251
|
|
|
357
|
-
const {
|
|
358
|
-
|
|
359
|
-
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
360
|
-
}
|
|
252
|
+
const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
|
|
253
|
+
resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
|
|
361
254
|
|
|
362
|
-
|
|
255
|
+
// Prefer the Go ledger for audio, but a sandbox session never routes there:
|
|
256
|
+
// the ledger has no X-Consent-Token support, so it stays on the consent
|
|
257
|
+
// server. Mirrors submitConsentEvent's base-url selection.
|
|
258
|
+
const apiBaseUrl = (sandbox ? null : ledgerBaseUrl) || baseUrl || BASE_URL;
|
|
363
259
|
|
|
364
|
-
const url =
|
|
260
|
+
const url = new URL(
|
|
261
|
+
`${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`
|
|
262
|
+
);
|
|
263
|
+
// Sandbox mode: the acting identity travels as query params
|
|
264
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
265
|
+
if (sandbox) {
|
|
266
|
+
for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
|
|
267
|
+
url.searchParams.append(key, value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
365
270
|
|
|
366
|
-
const response = await fetch(url, {
|
|
271
|
+
const response = await fetch(url.toString(), {
|
|
367
272
|
method: "GET",
|
|
368
273
|
headers: {
|
|
369
|
-
|
|
274
|
+
...buildAuthHeaders(accessToken, sandbox),
|
|
370
275
|
Accept: "application/json",
|
|
371
276
|
},
|
|
372
277
|
signal,
|
|
@@ -404,6 +309,9 @@ export const initiateGuardianVerification = async ({
|
|
|
404
309
|
signal,
|
|
405
310
|
}: InitiateGuardianVerificationParams): Promise<InitiateGuardianVerificationResponse> => {
|
|
406
311
|
try {
|
|
312
|
+
// Guardian verification is JWT-only: sandbox sessions have no minor-notice /
|
|
313
|
+
// guardian support (the server 403s them, and C1 blocks minor notices at
|
|
314
|
+
// render), so no sandbox identity is wired through here.
|
|
407
315
|
if (!accessToken) {
|
|
408
316
|
throw new Error("accessToken is required");
|
|
409
317
|
}
|
|
@@ -424,8 +332,10 @@ export const initiateGuardianVerification = async ({
|
|
|
424
332
|
if (!decodedToken) {
|
|
425
333
|
throw new Error("Invalid access token");
|
|
426
334
|
}
|
|
427
|
-
|
|
428
|
-
|
|
335
|
+
const {
|
|
336
|
+
organisation_uuid: ORGANISATION_UUID,
|
|
337
|
+
workspace_uuid: WORKSPACE_UUID,
|
|
338
|
+
} = decodedToken;
|
|
429
339
|
if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
|
|
430
340
|
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
431
341
|
}
|
|
@@ -519,6 +429,9 @@ export const verifyGuardianStatus = async ({
|
|
|
519
429
|
sessionToken,
|
|
520
430
|
signal,
|
|
521
431
|
}: VerifyGuardianStatusParams): Promise<VerifyGuardianStatusResponse> => {
|
|
432
|
+
// Guardian verification is JWT-only: sandbox sessions have no minor-notice /
|
|
433
|
+
// guardian support (the server 403s them, and C1 blocks minor notices at
|
|
434
|
+
// render), so no sandbox identity is wired through here.
|
|
522
435
|
if (!accessToken || !sessionToken) {
|
|
523
436
|
throw new Error("accessToken and sessionToken are required");
|
|
524
437
|
}
|
|
@@ -527,8 +440,10 @@ export const verifyGuardianStatus = async ({
|
|
|
527
440
|
if (!decodedToken) {
|
|
528
441
|
throw new Error("Invalid access token");
|
|
529
442
|
}
|
|
530
|
-
|
|
531
|
-
|
|
443
|
+
const {
|
|
444
|
+
organisation_uuid: ORGANISATION_UUID,
|
|
445
|
+
workspace_uuid: WORKSPACE_UUID,
|
|
446
|
+
} = decodedToken;
|
|
532
447
|
if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
|
|
533
448
|
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
534
449
|
}
|
|
@@ -543,9 +458,7 @@ export const verifyGuardianStatus = async ({
|
|
|
543
458
|
Authorization: `Bearer ${accessToken}`,
|
|
544
459
|
"Content-Type": "application/json",
|
|
545
460
|
},
|
|
546
|
-
body: JSON.stringify({
|
|
547
|
-
session_token: sessionToken,
|
|
548
|
-
}),
|
|
461
|
+
body: JSON.stringify({ session_token: sessionToken }),
|
|
549
462
|
signal,
|
|
550
463
|
}
|
|
551
464
|
);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox-mode auth primitives for the consent PoC.
|
|
3
|
+
*
|
|
4
|
+
* In sandbox mode the SDK talks to the consent-server with a pasted static
|
|
5
|
+
* token and an explicit test subject, bypassing the JWT entirely. Org/workspace
|
|
6
|
+
* UUIDs come from props (they still build the `/public/organisations/{org}/
|
|
7
|
+
* workspaces/{ws}/...` URL paths) instead of being decoded from a token.
|
|
8
|
+
*
|
|
9
|
+
* Auth is the single `X-Consent-Token` header carrying the static token; its
|
|
10
|
+
* presence alone marks the request as `environment=test`. There is no subject
|
|
11
|
+
* header. The acting identity (the test subject) rides the request's own
|
|
12
|
+
* payload: query params (`primary_email` / `primary_mobile`) on GET/query reads,
|
|
13
|
+
* and the body on POSTs. The server normalizes and namespaces it `test::`
|
|
14
|
+
* server-side. Call sites resolve the credential via {@link buildAuthHeaders}
|
|
15
|
+
* and the identity via {@link sandboxQueryIdentity} / {@link sandboxBodyIdentity}.
|
|
16
|
+
*
|
|
17
|
+
* This file exists so every API call site resolves its org/workspace UUIDs and
|
|
18
|
+
* builds its auth header through one place, keeping sandbox vs. JWT branching
|
|
19
|
+
* from drifting across the ~6 fetch sites.
|
|
20
|
+
*/
|
|
21
|
+
import { SANDBOX_TOKEN_HEADER } from "../../shared/sandbox";
|
|
22
|
+
import type {
|
|
23
|
+
RedactoJwtPayload,
|
|
24
|
+
ResolvedRequestAuth,
|
|
25
|
+
SandboxAuth,
|
|
26
|
+
SandboxAuthParams,
|
|
27
|
+
SandboxIdentityPayload,
|
|
28
|
+
} from "./types";
|
|
29
|
+
|
|
30
|
+
export { SANDBOX_TOKEN_HEADER };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a {@link SandboxAuth} from the raw sandbox props, or return null when
|
|
34
|
+
* sandbox mode is not active (no token).
|
|
35
|
+
*
|
|
36
|
+
* @throws Error when a sandbox token is provided but org/workspace or the acting
|
|
37
|
+
* identity are incomplete — sandbox mode cannot build URLs or identify the
|
|
38
|
+
* acting principal without them, so failing loudly beats a silent malformed
|
|
39
|
+
* request.
|
|
40
|
+
*/
|
|
41
|
+
export const resolveSandboxAuth = (
|
|
42
|
+
params: SandboxAuthParams
|
|
43
|
+
): SandboxAuth | null => {
|
|
44
|
+
const token = params.token?.trim();
|
|
45
|
+
if (!token) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const organisationUuid = params.organisationUuid?.trim();
|
|
50
|
+
const workspaceUuid = params.workspaceUuid?.trim();
|
|
51
|
+
const ucic = params.sandboxUcic?.trim();
|
|
52
|
+
const subject = params.sandboxSubject?.trim();
|
|
53
|
+
|
|
54
|
+
if (!organisationUuid || !workspaceUuid) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Sandbox mode requires organisationUuid and workspaceUuid props"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!ucic && !subject) {
|
|
60
|
+
throw new Error("Sandbox mode requires a UCIC, email, or mobile");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { token, ucic, subject: subject ?? "", organisationUuid, workspaceUuid };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the org/workspace UUIDs and sandbox auth for a request. In sandbox
|
|
68
|
+
* mode these come straight from props; otherwise they are decoded from the JWT
|
|
69
|
+
* (preserving the pre-sandbox behavior and error messages exactly). The JWT
|
|
70
|
+
* decoder is injected so this module stays free of a direct jwt-decode import.
|
|
71
|
+
*
|
|
72
|
+
* @throws Error when neither a valid sandbox config nor a decodable token with
|
|
73
|
+
* org/workspace UUIDs is available.
|
|
74
|
+
*/
|
|
75
|
+
export const resolveRequestAuth = (
|
|
76
|
+
accessToken: string | undefined,
|
|
77
|
+
sandboxParams: SandboxAuthParams,
|
|
78
|
+
decodeTokenSafely: (token: string) => RedactoJwtPayload | null
|
|
79
|
+
): ResolvedRequestAuth => {
|
|
80
|
+
const sandbox = resolveSandboxAuth(sandboxParams);
|
|
81
|
+
if (sandbox) {
|
|
82
|
+
return {
|
|
83
|
+
organisationUuid: sandbox.organisationUuid,
|
|
84
|
+
workspaceUuid: sandbox.workspaceUuid,
|
|
85
|
+
sandbox,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const decodedToken = accessToken ? decodeTokenSafely(accessToken) : null;
|
|
90
|
+
if (!decodedToken) {
|
|
91
|
+
throw new Error("Invalid access token");
|
|
92
|
+
}
|
|
93
|
+
const { organisation_uuid, workspace_uuid } = decodedToken;
|
|
94
|
+
if (!organisation_uuid || !workspace_uuid) {
|
|
95
|
+
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
organisationUuid: organisation_uuid,
|
|
99
|
+
workspaceUuid: workspace_uuid,
|
|
100
|
+
sandbox: null,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the request headers for an authenticated call. In sandbox mode this is
|
|
106
|
+
* the single `X-Consent-Token` credential (its presence marks the request as
|
|
107
|
+
* `environment=test`); otherwise the standard `Authorization: Bearer`. There is
|
|
108
|
+
* no subject header — the acting identity travels in the request's own payload
|
|
109
|
+
* (see {@link sandboxQueryIdentity} for reads, {@link sandboxBodyIdentity} for
|
|
110
|
+
* POSTs).
|
|
111
|
+
*/
|
|
112
|
+
export const buildAuthHeaders = (
|
|
113
|
+
accessToken: string | undefined,
|
|
114
|
+
sandbox: SandboxAuth | null
|
|
115
|
+
): Record<string, string> => {
|
|
116
|
+
if (sandbox) {
|
|
117
|
+
return { [SANDBOX_TOKEN_HEADER]: sandbox.token };
|
|
118
|
+
}
|
|
119
|
+
return { Authorization: `Bearer ${accessToken}` };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The identity payload fragment for the sandbox acting principal:
|
|
124
|
+
* `{ org_user_id }` when a UCIC is set (it takes precedence), else
|
|
125
|
+
* `{ primary_email }` when the subject looks like an email (contains "@"),
|
|
126
|
+
* otherwise `{ primary_mobile }`. Merge this into a JSON request body (POST) or
|
|
127
|
+
* into query params (GET) so the server can resolve the acting test identity.
|
|
128
|
+
*/
|
|
129
|
+
export const sandboxBodyIdentity = (
|
|
130
|
+
sandbox: SandboxAuth
|
|
131
|
+
): SandboxIdentityPayload => {
|
|
132
|
+
if (sandbox.ucic) {
|
|
133
|
+
return { org_user_id: sandbox.ucic };
|
|
134
|
+
}
|
|
135
|
+
return sandbox.subject.includes("@")
|
|
136
|
+
? { primary_email: sandbox.subject }
|
|
137
|
+
: { primary_mobile: sandbox.subject };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The identity query fragment for GET/query reads: `{ org_user_id }` when a UCIC
|
|
142
|
+
* is set (it takes precedence), else `{ primary_email }` when the subject looks
|
|
143
|
+
* like an email (contains "@"), otherwise `{ primary_mobile }`. Append these to
|
|
144
|
+
* the request's query string so the server can resolve the acting test identity
|
|
145
|
+
* (the server reads `org_user_id` / `primary_email` / `primary_mobile` query
|
|
146
|
+
* params on sandbox reads).
|
|
147
|
+
*/
|
|
148
|
+
export const sandboxQueryIdentity = (
|
|
149
|
+
sandbox: SandboxAuth
|
|
150
|
+
): SandboxIdentityPayload => sandboxBodyIdentity(sandbox);
|
|
@@ -6,6 +6,52 @@ export type RedactoJwtPayload = {
|
|
|
6
6
|
iat?: number;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Optional sandbox-mode auth fields shared by every API param type. When
|
|
11
|
+
* `token` is set the call sends the single `X-Consent-Token` header instead of
|
|
12
|
+
* `Authorization: Bearer`, injects the subject into each request's own payload
|
|
13
|
+
* (body for POSTs, query param for GETs), and takes org/workspace from these
|
|
14
|
+
* props instead of decoding the JWT. All optional so existing JWT callers are
|
|
15
|
+
* unaffected.
|
|
16
|
+
*/
|
|
17
|
+
export type SandboxAuthParams = {
|
|
18
|
+
token?: string;
|
|
19
|
+
/** UCIC — the client's own user id (`org_user_id`); takes precedence over the subject. */
|
|
20
|
+
sandboxUcic?: string;
|
|
21
|
+
sandboxSubject?: string;
|
|
22
|
+
organisationUuid?: string;
|
|
23
|
+
workspaceUuid?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sandbox authentication context. Present only when the host opts into sandbox
|
|
28
|
+
* mode by passing a sandbox token; the token/org/workspace are always required
|
|
29
|
+
* at that point because there is no JWT to fall back on. The acting identity is
|
|
30
|
+
* a UCIC (the client's own `org_user_id`) or a `subject` (email/mobile) — at
|
|
31
|
+
* least one is present (UCIC takes precedence when both are set).
|
|
32
|
+
*/
|
|
33
|
+
export type SandboxAuth = {
|
|
34
|
+
token: string;
|
|
35
|
+
/** UCIC — the client's own user id (`org_user_id`). Takes precedence over `subject`. */
|
|
36
|
+
ucic?: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
organisationUuid: string;
|
|
39
|
+
workspaceUuid: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The identity payload fragment for the sandbox acting principal. */
|
|
43
|
+
export type SandboxIdentityPayload =
|
|
44
|
+
| { org_user_id: string }
|
|
45
|
+
| { primary_email: string }
|
|
46
|
+
| { primary_mobile: string };
|
|
47
|
+
|
|
48
|
+
/** Org/workspace UUIDs plus the resolved sandbox context for a request. */
|
|
49
|
+
export type ResolvedRequestAuth = {
|
|
50
|
+
organisationUuid: string;
|
|
51
|
+
workspaceUuid: string;
|
|
52
|
+
sandbox: SandboxAuth | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
9
55
|
export type PurposeSelection = {
|
|
10
56
|
selected: boolean;
|
|
11
57
|
status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED
|
|
@@ -19,38 +65,6 @@ export type DataElementSelection = {
|
|
|
19
65
|
required: boolean;
|
|
20
66
|
};
|
|
21
67
|
|
|
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
|
-
};
|
|
53
|
-
|
|
54
68
|
export type ConsentContent = {
|
|
55
69
|
code: number;
|
|
56
70
|
status: string;
|
|
@@ -178,10 +192,10 @@ export type Settings = {
|
|
|
178
192
|
font?: string;
|
|
179
193
|
};
|
|
180
194
|
|
|
181
|
-
export type FetchConsentContentParams = {
|
|
195
|
+
export type FetchConsentContentParams = SandboxAuthParams & {
|
|
182
196
|
noticeId: string;
|
|
183
|
-
accessToken
|
|
184
|
-
refreshToken
|
|
197
|
+
accessToken?: string;
|
|
198
|
+
refreshToken?: string;
|
|
185
199
|
baseUrl?: string;
|
|
186
200
|
language?: string;
|
|
187
201
|
specific_uuid?: string;
|
|
@@ -228,8 +242,8 @@ export type Purpose = {
|
|
|
228
242
|
data_elements: Array<DataElement>;
|
|
229
243
|
};
|
|
230
244
|
|
|
231
|
-
export type SubmitConsentEventParams = {
|
|
232
|
-
accessToken
|
|
245
|
+
export type SubmitConsentEventParams = SandboxAuthParams & {
|
|
246
|
+
accessToken?: string;
|
|
233
247
|
baseUrl?: string;
|
|
234
248
|
ledgerBaseUrl?: string;
|
|
235
249
|
noticeUuid: string;
|
|
@@ -352,9 +366,10 @@ export type DpoInfoAudio = {
|
|
|
352
366
|
grievance_email_audio_url?: string;
|
|
353
367
|
};
|
|
354
368
|
|
|
355
|
-
export type FetchTTSAudioUrlsParams = {
|
|
356
|
-
accessToken
|
|
369
|
+
export type FetchTTSAudioUrlsParams = SandboxAuthParams & {
|
|
370
|
+
accessToken?: string;
|
|
357
371
|
baseUrl?: string;
|
|
372
|
+
ledgerBaseUrl?: string;
|
|
358
373
|
noticeUuid: string;
|
|
359
374
|
language: string;
|
|
360
375
|
};
|
|
@@ -367,8 +382,8 @@ export type FetchTTSAudioUrlsParams = {
|
|
|
367
382
|
* Parameters for initiating guardian verification
|
|
368
383
|
* POST /guardian/initiate-verification
|
|
369
384
|
*/
|
|
370
|
-
export type InitiateGuardianVerificationParams = {
|
|
371
|
-
accessToken
|
|
385
|
+
export type InitiateGuardianVerificationParams = SandboxAuthParams & {
|
|
386
|
+
accessToken?: string;
|
|
372
387
|
baseUrl?: string;
|
|
373
388
|
guardianName: string;
|
|
374
389
|
guardianContact: string;
|
|
@@ -403,8 +418,8 @@ export type InitiateGuardianVerificationResponse = {
|
|
|
403
418
|
* Parameters for checking verification status
|
|
404
419
|
* POST /guardian/verify-status
|
|
405
420
|
*/
|
|
406
|
-
export type VerifyGuardianStatusParams = {
|
|
407
|
-
accessToken
|
|
421
|
+
export type VerifyGuardianStatusParams = SandboxAuthParams & {
|
|
422
|
+
accessToken?: string;
|
|
408
423
|
baseUrl?: string;
|
|
409
424
|
sessionToken: string;
|
|
410
425
|
signal?: AbortSignal;
|