@redacto.io/consent-sdk-react 10.0.0-beta.7 → 10.0.0-beta.9
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/CHANGELOG.md +16 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +162 -56
- package/dist/index.mjs +161 -57
- package/dist/privacy-center.d.mts +54 -12
- package/dist/privacy-center.d.ts +54 -12
- package/dist/privacy-center.js +265 -77
- package/dist/privacy-center.mjs +264 -78
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
- package/src/RedactoNoticeConsent/api/index.ts +78 -48
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +57 -11
- package/src/RedactoNoticeConsent/types.ts +28 -2
- 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/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
- 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 -5
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +142 -5
- package/tests/consentClient.test.ts +46 -0
|
@@ -17,8 +17,31 @@ interface RedactoPrivacyCenterProps {
|
|
|
17
17
|
baseUrl: string; // Consent server base URL
|
|
18
18
|
ledgerBaseUrl?: string; // Go ledger base URL — serves user-consents; omit to use the legacy Python privacy-center consent endpoints
|
|
19
19
|
slug: string;
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** JWT access token. Optional so sandbox mode (see `token`) can omit it. */
|
|
21
|
+
accessToken?: string;
|
|
22
|
+
/** JWT refresh token. Optional; used by the OTP-refresh loop in the JWT flow. */
|
|
23
|
+
refreshToken?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Sandbox PoC: a pasted static token. When set, the Privacy Center runs
|
|
26
|
+
* against the sandbox with no JWT — it sends the single `X-Consent-Token`
|
|
27
|
+
* header, carries the acting identity (`email` || `mobile`) in each request's
|
|
28
|
+
* own payload, takes org/workspace from `organisationUuid`/`workspaceUuid`,
|
|
29
|
+
* and never calls `/otp/refresh`.
|
|
30
|
+
*/
|
|
31
|
+
token?: string;
|
|
32
|
+
/** Sandbox test subject email (carried in the request payload when set). */
|
|
33
|
+
email?: string;
|
|
34
|
+
/** Sandbox test subject mobile (fallback subject when `email` is absent). */
|
|
35
|
+
mobile?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Sandbox UCIC: the client's own user id / org_user_id, e.g. a UUID or
|
|
38
|
+
* customer code; optional third identifier alongside email/mobile.
|
|
39
|
+
*/
|
|
40
|
+
ucic?: string;
|
|
41
|
+
/** Sandbox org UUID (replaces the JWT-decoded org in sandbox mode). */
|
|
42
|
+
organisationUuid?: string;
|
|
43
|
+
/** Sandbox workspace UUID (replaces the JWT-decoded workspace in sandbox mode). */
|
|
44
|
+
workspaceUuid?: string;
|
|
22
45
|
onError: (error: Error) => boolean;
|
|
23
46
|
initialPage?: InternalPage;
|
|
24
47
|
onBack?: 'back' | 'signout';
|
|
@@ -30,6 +53,12 @@ export function RedactoPrivacyCenter({
|
|
|
30
53
|
slug,
|
|
31
54
|
accessToken,
|
|
32
55
|
refreshToken,
|
|
56
|
+
token,
|
|
57
|
+
email,
|
|
58
|
+
mobile,
|
|
59
|
+
ucic,
|
|
60
|
+
organisationUuid,
|
|
61
|
+
workspaceUuid,
|
|
33
62
|
onError,
|
|
34
63
|
initialPage = 'consent-manager',
|
|
35
64
|
onBack,
|
|
@@ -41,6 +70,12 @@ export function RedactoPrivacyCenter({
|
|
|
41
70
|
<AuthProvider
|
|
42
71
|
accessToken={accessToken}
|
|
43
72
|
refreshToken={refreshToken}
|
|
73
|
+
token={token}
|
|
74
|
+
sandboxUcic={ucic}
|
|
75
|
+
sandboxSubject={email || mobile}
|
|
76
|
+
organisationUuid={organisationUuid}
|
|
77
|
+
workspaceUuid={workspaceUuid}
|
|
78
|
+
slug={slug}
|
|
44
79
|
onError={onError}
|
|
45
80
|
>
|
|
46
81
|
<NavigationProvider initialPage={initialPage}>
|
|
@@ -357,6 +357,7 @@ export async function manageConsentServer(params: {
|
|
|
357
357
|
productUuid?: string;
|
|
358
358
|
noticeUuid?: string;
|
|
359
359
|
dataElements?: Array<{ uuid: string; selected: boolean }>;
|
|
360
|
+
language?: string;
|
|
360
361
|
}): Promise<boolean | null> {
|
|
361
362
|
try {
|
|
362
363
|
if (!params.consentClient) {
|
|
@@ -381,7 +382,8 @@ export async function manageConsentServer(params: {
|
|
|
381
382
|
params.dataElementUuids,
|
|
382
383
|
params.productUuid,
|
|
383
384
|
params.noticeUuid,
|
|
384
|
-
params.dataElements
|
|
385
|
+
params.dataElements,
|
|
386
|
+
params.language
|
|
385
387
|
);
|
|
386
388
|
if (result && typeof result === 'object' && 'success' in result) {
|
|
387
389
|
return Boolean((result as LegacyManageConsentResponse).success);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { apiFetch, ApiError } from "./fetcher";
|
|
2
2
|
import type { ApiFetchOptions, ApiResponse } from "./fetcher";
|
|
3
|
+
import {
|
|
4
|
+
isSandboxMode,
|
|
5
|
+
sandboxAuthHeaders,
|
|
6
|
+
sandboxContact,
|
|
7
|
+
sandboxQueryIdentity,
|
|
8
|
+
} from "../lib/sandbox";
|
|
3
9
|
import type {
|
|
4
10
|
CaseMessage,
|
|
5
11
|
IdentityListResponse,
|
|
@@ -33,7 +39,11 @@ export type BaseRequest = {
|
|
|
33
39
|
* integrations without a deployed ledger keep working.
|
|
34
40
|
*/
|
|
35
41
|
export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
36
|
-
const
|
|
42
|
+
const ledgerConfigured = Boolean(ledgerBaseUrl);
|
|
43
|
+
// Sandbox mode never routes to the Go ledger (it has no sandbox / test::
|
|
44
|
+
// support). A sandbox token forces the legacy consent-server path regardless
|
|
45
|
+
// of ledgerBaseUrl, so isolation can't be bypassed by also configuring a ledger.
|
|
46
|
+
const useLedger = () => ledgerConfigured && !isSandboxMode();
|
|
37
47
|
const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
|
|
38
48
|
/**
|
|
39
49
|
* Privacy Center API fetch wrapper that uses Authorization: Bearer header.
|
|
@@ -72,15 +82,26 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// Sandbox PoC: send only the X-Consent-Token credential (no JWT bearer). Set
|
|
86
|
+
// last so it wins over any caller-supplied Authorization header. The acting
|
|
87
|
+
// identity travels in the request payload -- query params on reads, the body
|
|
88
|
+
// on writes -- never a header.
|
|
89
|
+
const sandboxHeaders = sandboxAuthHeaders();
|
|
90
|
+
if (sandboxHeaders) {
|
|
91
|
+
Object.entries(sandboxHeaders).forEach(([key, value]) => {
|
|
92
|
+
requestHeaders.set(key, value);
|
|
93
|
+
});
|
|
94
|
+
} else {
|
|
95
|
+
// Add Authorization Bearer token for privacy center APIs
|
|
96
|
+
const token = authToken || (getAuthToken ? await getAuthToken() : null);
|
|
97
|
+
if (token) {
|
|
98
|
+
const trimmedToken = token.trim();
|
|
99
|
+
// Format token with Bearer prefix if not already present
|
|
100
|
+
const bearerToken = trimmedToken.startsWith('Bearer ')
|
|
101
|
+
? trimmedToken
|
|
102
|
+
: `Bearer ${trimmedToken}`;
|
|
103
|
+
requestHeaders.set("Authorization", bearerToken);
|
|
104
|
+
}
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
// Build fetch options
|
|
@@ -180,7 +201,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
180
201
|
}
|
|
181
202
|
|
|
182
203
|
function ledgerUrl(baseRequest: BaseRequest, path: string): string {
|
|
183
|
-
if (!useLedger) {
|
|
204
|
+
if (!useLedger()) {
|
|
184
205
|
throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
|
|
185
206
|
}
|
|
186
207
|
return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
|
|
@@ -191,13 +212,47 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
191
212
|
}
|
|
192
213
|
|
|
193
214
|
function notificationUrl(baseRequest: BaseRequest, path = ''): string {
|
|
194
|
-
|
|
215
|
+
const identitySuffix = sandboxIdentitySuffix();
|
|
216
|
+
if (!identitySuffix) {
|
|
217
|
+
return privacyCenterUrl(baseRequest, `/notifications${path}`);
|
|
218
|
+
}
|
|
219
|
+
const separator = path.includes('?') ? `&${identitySuffix.slice(1)}` : identitySuffix;
|
|
220
|
+
return privacyCenterUrl(baseRequest, `/notifications${path}${separator}`);
|
|
195
221
|
}
|
|
196
222
|
|
|
197
223
|
function resolveAuthToken(authToken: string | undefined, baseRequest: BaseRequest): string {
|
|
198
224
|
return authToken || baseRequest.authToken;
|
|
199
225
|
}
|
|
200
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Append the sandbox acting identity (`primary_email` / `primary_mobile`) to a
|
|
229
|
+
* query-param bag when sandbox mode is active; a no-op otherwise. The server
|
|
230
|
+
* resolves the acting test identity from these query params on reads (there is
|
|
231
|
+
* no subject header). The credential is the X-Consent-Token header set in
|
|
232
|
+
* privacyCenterApiFetch.
|
|
233
|
+
*/
|
|
234
|
+
function appendSandboxIdentity(queryParams: URLSearchParams): void {
|
|
235
|
+
const identity = sandboxQueryIdentity();
|
|
236
|
+
if (identity) {
|
|
237
|
+
Object.entries(identity).forEach(([key, value]) => {
|
|
238
|
+
queryParams.append(key, value);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The sandbox acting-identity query suffix (`?primary_email=...` /
|
|
245
|
+
* `?primary_mobile=...`) for endpoints that build a bare URL with no other
|
|
246
|
+
* query params (the case-communication reads/POSTs). Empty string outside
|
|
247
|
+
* sandbox mode, so the JWT path is unchanged.
|
|
248
|
+
*/
|
|
249
|
+
function sandboxIdentitySuffix(): string {
|
|
250
|
+
const queryParams = new URLSearchParams();
|
|
251
|
+
appendSandboxIdentity(queryParams);
|
|
252
|
+
const queryString = queryParams.toString();
|
|
253
|
+
return queryString ? `?${queryString}` : "";
|
|
254
|
+
}
|
|
255
|
+
|
|
201
256
|
return {
|
|
202
257
|
/**
|
|
203
258
|
* Get form data (privacy center)
|
|
@@ -247,13 +302,23 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
247
302
|
const url = privacyCenterUrl(baseRequest, '/case');
|
|
248
303
|
const { useSessionAuth = false, getAuthToken, ...fetchOptions } = options || {};
|
|
249
304
|
|
|
305
|
+
// Sandbox mode: the acting test identity is the single `contact` field
|
|
306
|
+
// (the server has no subject header; it namespaces this identity `test::`).
|
|
307
|
+
// Merge it into the body only when sandbox mode is active and the payload
|
|
308
|
+
// is a plain object; the JWT path is left byte-for-byte unchanged.
|
|
309
|
+
const contact = sandboxContact();
|
|
310
|
+
const requestPayload =
|
|
311
|
+
contact && payload && typeof payload === "object"
|
|
312
|
+
? { ...(payload as Record<string, unknown>), contact }
|
|
313
|
+
: payload;
|
|
314
|
+
|
|
250
315
|
// Case management uses session-based auth (X-Auth-Token)
|
|
251
316
|
// Privacy center uses JWT Bearer tokens (Authorization: Bearer)
|
|
252
317
|
if (useSessionAuth) {
|
|
253
318
|
return apiFetch(url, {
|
|
254
319
|
...fetchOptions,
|
|
255
320
|
method: "POST",
|
|
256
|
-
body: JSON.stringify(
|
|
321
|
+
body: JSON.stringify(requestPayload),
|
|
257
322
|
getAuthToken,
|
|
258
323
|
});
|
|
259
324
|
}
|
|
@@ -262,7 +327,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
262
327
|
return privacyCenterApiFetch(url, {
|
|
263
328
|
...fetchOptions,
|
|
264
329
|
method: "POST",
|
|
265
|
-
body: JSON.stringify(
|
|
330
|
+
body: JSON.stringify(requestPayload),
|
|
266
331
|
authToken: resolveAuthToken(authToken, baseRequest),
|
|
267
332
|
});
|
|
268
333
|
},
|
|
@@ -388,9 +453,12 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
388
453
|
if (params.status) queryParams.append("status", params.status);
|
|
389
454
|
if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
|
|
390
455
|
if (params.language) queryParams.append("language", params.language);
|
|
456
|
+
// Sandbox mode: the acting identity travels as query params
|
|
457
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
458
|
+
appendSandboxIdentity(queryParams);
|
|
391
459
|
const queryString = queryParams.toString();
|
|
392
460
|
const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
|
|
393
|
-
const url = useLedger
|
|
461
|
+
const url = useLedger()
|
|
394
462
|
? ledgerUrl(baseRequest, suffix)
|
|
395
463
|
: privacyCenterUrl(baseRequest, suffix);
|
|
396
464
|
return privacyCenterApiFetch(url, {
|
|
@@ -426,9 +494,12 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
426
494
|
if (params.search) queryParams.append("search", params.search);
|
|
427
495
|
if (params.language) queryParams.append("language", params.language);
|
|
428
496
|
if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
|
|
497
|
+
// Sandbox mode: the acting identity travels as query params
|
|
498
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
499
|
+
appendSandboxIdentity(queryParams);
|
|
429
500
|
const queryString = queryParams.toString();
|
|
430
501
|
const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
|
|
431
|
-
const url = useLedger
|
|
502
|
+
const url = useLedger()
|
|
432
503
|
? ledgerUrl(baseRequest, suffix)
|
|
433
504
|
: privacyCenterUrl(baseRequest, suffix);
|
|
434
505
|
return privacyCenterApiFetch(url, {
|
|
@@ -467,9 +538,10 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
467
538
|
dataElementUuids?: string[],
|
|
468
539
|
productUuid?: string,
|
|
469
540
|
noticeUuid?: string,
|
|
470
|
-
dataElements?: Array<{ uuid: string; selected: boolean }
|
|
541
|
+
dataElements?: Array<{ uuid: string; selected: boolean }>,
|
|
542
|
+
language?: string
|
|
471
543
|
): Promise<unknown> {
|
|
472
|
-
if (!useLedger) {
|
|
544
|
+
if (!useLedger()) {
|
|
473
545
|
const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
|
|
474
546
|
const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
|
|
475
547
|
if (nominatorContact) {
|
|
@@ -481,6 +553,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
481
553
|
if (productUuid) {
|
|
482
554
|
body.product_uuid = productUuid;
|
|
483
555
|
}
|
|
556
|
+
if (language) {
|
|
557
|
+
body.language = language;
|
|
558
|
+
}
|
|
484
559
|
return privacyCenterApiFetch(url, {
|
|
485
560
|
...options,
|
|
486
561
|
method: "POST",
|
|
@@ -507,7 +582,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
507
582
|
}
|
|
508
583
|
return { uuid: de.uuid, selected };
|
|
509
584
|
});
|
|
510
|
-
const body = {
|
|
585
|
+
const body: Record<string, unknown> = {
|
|
511
586
|
notice_uuid: noticeUuid,
|
|
512
587
|
partial: true,
|
|
513
588
|
declined: false,
|
|
@@ -520,6 +595,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
520
595
|
data_elements: elements,
|
|
521
596
|
},
|
|
522
597
|
],
|
|
598
|
+
...(language ? { language } : {}),
|
|
523
599
|
};
|
|
524
600
|
const url = ledgerUrl(baseRequest, "/submit-consent");
|
|
525
601
|
return privacyCenterApiFetch(url, {
|
|
@@ -545,6 +621,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
545
621
|
if (offset !== undefined) queryParams.append("offset", offset.toString());
|
|
546
622
|
if (limit !== undefined) queryParams.append("limit", limit.toString());
|
|
547
623
|
if (language) queryParams.append("language", language);
|
|
624
|
+
// Sandbox mode: the acting identity travels as query params
|
|
625
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
626
|
+
appendSandboxIdentity(queryParams);
|
|
548
627
|
const queryString = queryParams.toString();
|
|
549
628
|
const url = privacyCenterUrl(baseRequest, `/activities${queryString ? `?${queryString}` : ""}`);
|
|
550
629
|
return privacyCenterApiFetch<unknown>(url, {
|
|
@@ -623,7 +702,7 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
623
702
|
// (paginated list under /public/...); otherwise the legacy Python
|
|
624
703
|
// privacy-center list is used. normalizeReceipts tolerates both envelopes.
|
|
625
704
|
const receiptsSuffix = `/receipts${queryString ? `?${queryString}` : ""}`;
|
|
626
|
-
const url = useLedger
|
|
705
|
+
const url = useLedger()
|
|
627
706
|
? ledgerUrl(baseRequest, receiptsSuffix)
|
|
628
707
|
: privacyCenterUrl(baseRequest, receiptsSuffix);
|
|
629
708
|
return privacyCenterApiFetch<unknown>(url, {
|
|
@@ -648,13 +727,22 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
648
727
|
// The ledger serves the receipt PDF at /receipts/{uuid}/download, the
|
|
649
728
|
// legacy Python service at /receipts/{uuid}/pdf. The receiptUuid always
|
|
650
729
|
// comes from the same-mode list above, so list and download stay consistent.
|
|
651
|
-
const url = useLedger
|
|
730
|
+
const url = useLedger()
|
|
652
731
|
? ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`)
|
|
653
732
|
: privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
|
|
654
733
|
const token = resolveAuthToken(authToken, baseRequest);
|
|
655
734
|
const requestHeaders = new Headers();
|
|
656
735
|
requestHeaders.set("Accept", "application/pdf");
|
|
657
|
-
|
|
736
|
+
// Sandbox PoC: send the single X-Consent-Token header instead of the JWT
|
|
737
|
+
// bearer. The receipt PDF download identifies the principal by receiptUuid
|
|
738
|
+
// (already scoped to the sandbox subject via the same-mode list), so no
|
|
739
|
+
// extra identity payload is needed here.
|
|
740
|
+
const sandboxHeaders = sandboxAuthHeaders();
|
|
741
|
+
if (sandboxHeaders) {
|
|
742
|
+
Object.entries(sandboxHeaders).forEach(([key, value]) => {
|
|
743
|
+
requestHeaders.set(key, value);
|
|
744
|
+
});
|
|
745
|
+
} else if (token) {
|
|
658
746
|
const trimmedToken = token.trim();
|
|
659
747
|
requestHeaders.set(
|
|
660
748
|
"Authorization",
|
|
@@ -697,6 +785,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
697
785
|
if (offset !== undefined) queryParams.append("offset", offset.toString());
|
|
698
786
|
if (limit !== undefined) queryParams.append("limit", limit.toString());
|
|
699
787
|
if (language) queryParams.append("language", language);
|
|
788
|
+
// Sandbox mode: the acting identity travels as query params
|
|
789
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
790
|
+
appendSandboxIdentity(queryParams);
|
|
700
791
|
const queryString = queryParams.toString();
|
|
701
792
|
const url = privacyCenterUrl(baseRequest, `/cases?${queryString}`);
|
|
702
793
|
return privacyCenterApiFetch<unknown>(url, {
|
|
@@ -715,7 +806,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
715
806
|
caseUuid: string,
|
|
716
807
|
authToken?: string
|
|
717
808
|
): Promise<CaseMessage[]> {
|
|
718
|
-
|
|
809
|
+
// Sandbox mode: the acting identity travels as query params
|
|
810
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
811
|
+
const url = caseUrl(baseRequest, caseUuid, `/messages${sandboxIdentitySuffix()}`);
|
|
719
812
|
return privacyCenterApiFetch<CaseMessage[]>(url, {
|
|
720
813
|
authToken: resolveAuthToken(authToken, baseRequest),
|
|
721
814
|
});
|
|
@@ -731,7 +824,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
731
824
|
documentUuids: string[],
|
|
732
825
|
authToken?: string
|
|
733
826
|
): Promise<CaseMessage> {
|
|
734
|
-
|
|
827
|
+
// Sandbox mode: the acting identity travels as query params
|
|
828
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
829
|
+
const url = caseUrl(baseRequest, caseUuid, `/messages${sandboxIdentitySuffix()}`);
|
|
735
830
|
return privacyCenterApiFetch<CaseMessage>(url, {
|
|
736
831
|
method: 'POST',
|
|
737
832
|
body: JSON.stringify({ body, document_uuids: documentUuids }),
|
|
@@ -748,7 +843,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
|
748
843
|
file: File,
|
|
749
844
|
authToken?: string
|
|
750
845
|
): Promise<UploadDocumentResponse> {
|
|
751
|
-
|
|
846
|
+
// Sandbox mode: the acting identity travels as query params
|
|
847
|
+
// (primary_email / primary_mobile); the credential is the X-Consent-Token header.
|
|
848
|
+
const url = caseUrl(baseRequest, caseUuid, `/upload-document${sandboxIdentitySuffix()}`);
|
|
752
849
|
const formData = new FormData();
|
|
753
850
|
formData.append('file', file, file.name); // field name must be exactly "file"
|
|
754
851
|
// "payload" is required by the privacy-center endpoint (omitting it causes 422)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sandboxAuthHeaders } from "../lib/sandbox";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Options for apiFetch function
|
|
3
5
|
*/
|
|
@@ -125,16 +127,29 @@ export async function apiFetch<T>(
|
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
// Add auth
|
|
130
|
+
// Add auth AFTER custom headers so it's never overridden. Only for calls that
|
|
131
|
+
// opt into auth (a `getAuthToken` is supplied) — public calls like org
|
|
132
|
+
// branding (which hit the user server, not consent-server) stay unauthenticated
|
|
133
|
+
// and must NOT receive the sandbox headers.
|
|
129
134
|
if (getAuthToken) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// Sandbox PoC: send the single X-Consent-Token header instead of the session
|
|
136
|
+
// token. The acting identity travels in the request payload (injected by the
|
|
137
|
+
// client call sites), not a header.
|
|
138
|
+
const sandboxHeaders = sandboxAuthHeaders();
|
|
139
|
+
if (sandboxHeaders) {
|
|
140
|
+
Object.entries(sandboxHeaders).forEach(([key, value]) => {
|
|
141
|
+
requestHeaders.set(key, value);
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
const token = await getAuthToken();
|
|
145
|
+
if (token) {
|
|
146
|
+
// Use the exact header format that works in other parts of the codebase
|
|
147
|
+
// Other working API calls use 'X-Auth-Token' (capital A and T, rest lowercase)
|
|
148
|
+
const trimmedToken = token.trim();
|
|
149
|
+
requestHeaders.set("X-Auth-Token", trimmedToken);
|
|
150
|
+
// Also set lowercase for compatibility (some servers are case-sensitive)
|
|
151
|
+
requestHeaders.set("x-auth-token", trimmedToken);
|
|
152
|
+
}
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -776,6 +776,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
776
776
|
productUuid,
|
|
777
777
|
noticeUuid,
|
|
778
778
|
dataElements,
|
|
779
|
+
language: langParam,
|
|
779
780
|
});
|
|
780
781
|
|
|
781
782
|
if (response) {
|
|
@@ -805,7 +806,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
805
806
|
handleCloseModal();
|
|
806
807
|
}
|
|
807
808
|
},
|
|
808
|
-
[organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord]
|
|
809
|
+
[organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord, langParam]
|
|
809
810
|
);
|
|
810
811
|
|
|
811
812
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
@@ -10,10 +10,10 @@ import { PiUploadBold } from 'react-icons/pi';
|
|
|
10
10
|
import { toast } from '../utils/toast';
|
|
11
11
|
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
|
|
12
12
|
import { PCModal } from '../ui/PCModal';
|
|
13
|
-
import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup } from './groupHelpers';
|
|
13
|
+
import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup, legacyFlatGroup } from './groupHelpers';
|
|
14
14
|
import { pcStyles } from '../styles/pcStyles';
|
|
15
15
|
import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
|
|
16
|
-
import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
|
|
16
|
+
import { FORM_PICKER_GROUP_LIMIT, LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
|
|
17
17
|
import { ensureValidToken } from '../lib/utils';
|
|
18
18
|
import { useAuth } from '../context/AuthContext';
|
|
19
19
|
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
@@ -143,7 +143,7 @@ export default function Form({
|
|
|
143
143
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
144
144
|
const { t } = useTranslation();
|
|
145
145
|
const { accessToken, refreshToken, onError, updateTokens } = useAuth();
|
|
146
|
-
const { consentClient, baseUrl
|
|
146
|
+
const { consentClient, baseUrl } = useBaseUrl();
|
|
147
147
|
const organizationId = organization?.uuid;
|
|
148
148
|
const workspaceId = organization?.workspaces[0]?.uuid;
|
|
149
149
|
|
|
@@ -235,12 +235,31 @@ export default function Form({
|
|
|
235
235
|
|
|
236
236
|
// Union of direct + nominated product groups exposed by the backend.
|
|
237
237
|
// Drives both the product-cards step and the per-product picker beneath.
|
|
238
|
+
// When the backend exposes no groups at all but did return the legacy flat
|
|
239
|
+
// `purposes` list (pre-grouping Python deployments, and the popup /
|
|
240
|
+
// case-management contexts that cannot read user-consents), fall back to a
|
|
241
|
+
// single synthetic group so the purposes stay reachable instead of the
|
|
242
|
+
// picker claiming the principal has no consents.
|
|
238
243
|
const allGroups = useMemo<FormProductGroup[]>(() => {
|
|
239
244
|
if (!data) {
|
|
240
245
|
return [];
|
|
241
246
|
}
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
const groups = [
|
|
248
|
+
...(data.direct_groups ?? []),
|
|
249
|
+
...(data.nominated_groups ?? []),
|
|
250
|
+
];
|
|
251
|
+
if (groups.length > 0) {
|
|
252
|
+
return groups;
|
|
253
|
+
}
|
|
254
|
+
const flatGroup = legacyFlatGroup(data, t('yourData'));
|
|
255
|
+
// `buildGroupPurposes` is what the panel renders from — it drops purposes
|
|
256
|
+
// with no data elements, so a flat list that survives nothing must keep
|
|
257
|
+
// showing the empty state rather than an unusable panel.
|
|
258
|
+
if (buildGroupPurposes(flatGroup).length === 0) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
return [flatGroup];
|
|
262
|
+
}, [data, t]);
|
|
244
263
|
|
|
245
264
|
// TECH-3712 UX: when the DP has exactly one consent group total
|
|
246
265
|
// (direct + nominated combined), the product-selection step is friction —
|
|
@@ -275,21 +294,22 @@ export default function Form({
|
|
|
275
294
|
[],
|
|
276
295
|
);
|
|
277
296
|
|
|
278
|
-
// Approach A: source the DSAR request-form product picker from
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
297
|
+
// Approach A: source the DSAR request-form product picker from user-consents
|
|
298
|
+
// rather than from Python's form/data, which stopped emitting product groups
|
|
299
|
+
// after the consent cutover. `consentClient.getUserConsents` serves the same
|
|
300
|
+
// grouped payload from either backend — the Go ledger's
|
|
301
|
+
// `/public/.../user-consents` when a ledgerBaseUrl is configured, Python's
|
|
302
|
+
// `/dsar/privacy-center/user-consents` otherwise (the same source the
|
|
303
|
+
// Manage Consent view reads in both modes) — so the overlay runs regardless
|
|
304
|
+
// of which backend the SDK points at. Only the end-user privacy-center path
|
|
305
|
+
// is eligible: popup / case-management resolve an arbitrary contact, which
|
|
306
|
+
// the JWT-scoped user-consents endpoint cannot serve, so those keep the
|
|
307
|
+
// form/data groups. Returns the form data unchanged when ineligible, when
|
|
308
|
+
// the read fails, or when it comes back with no groups at all — an empty
|
|
309
|
+
// result must never overwrite groups form/data did supply.
|
|
310
|
+
const withUserConsentPickerGroups = useCallback(
|
|
291
311
|
async (form: FormData, language?: string): Promise<FormData> => {
|
|
292
|
-
if (
|
|
312
|
+
if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
|
|
293
313
|
return form;
|
|
294
314
|
}
|
|
295
315
|
const consents = await fetchUserConsents({
|
|
@@ -304,19 +324,18 @@ export default function Form({
|
|
|
304
324
|
nominatedLimit: FORM_PICKER_GROUP_LIMIT,
|
|
305
325
|
language,
|
|
306
326
|
});
|
|
307
|
-
|
|
327
|
+
const direct = consents?.direct ?? [];
|
|
328
|
+
const nominated = consents?.nominated ?? [];
|
|
329
|
+
if (direct.length === 0 && nominated.length === 0) {
|
|
308
330
|
return form;
|
|
309
331
|
}
|
|
310
332
|
return {
|
|
311
333
|
...form,
|
|
312
|
-
direct_groups:
|
|
313
|
-
nominated_groups:
|
|
314
|
-
consentGroupToFormProductGroup,
|
|
315
|
-
),
|
|
334
|
+
direct_groups: direct.map(consentGroupToFormProductGroup),
|
|
335
|
+
nominated_groups: nominated.map(consentGroupToFormProductGroup),
|
|
316
336
|
};
|
|
317
337
|
},
|
|
318
338
|
[
|
|
319
|
-
ledgerBaseUrl,
|
|
320
339
|
isPopup,
|
|
321
340
|
isCaseManagementContext,
|
|
322
341
|
organizationId,
|
|
@@ -366,7 +385,7 @@ export default function Form({
|
|
|
366
385
|
return;
|
|
367
386
|
}
|
|
368
387
|
|
|
369
|
-
setData(await
|
|
388
|
+
setData(await withUserConsentPickerGroups(translatedFormData, langValue));
|
|
370
389
|
|
|
371
390
|
if (translatedFormData.grievance_options) {
|
|
372
391
|
const currentGrievanceSelections = { ...selectedGrievances };
|
|
@@ -385,7 +404,7 @@ export default function Form({
|
|
|
385
404
|
} finally {
|
|
386
405
|
setIsTranslating(false);
|
|
387
406
|
}
|
|
388
|
-
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t,
|
|
407
|
+
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withUserConsentPickerGroups]);
|
|
389
408
|
|
|
390
409
|
// Clear product / org-level selections when request type changes.
|
|
391
410
|
// Per-product state (selectedPurposes, selectedCorrectionElements,
|
|
@@ -538,7 +557,7 @@ export default function Form({
|
|
|
538
557
|
}
|
|
539
558
|
}
|
|
540
559
|
|
|
541
|
-
const pickerData = await
|
|
560
|
+
const pickerData = await withUserConsentPickerGroups(
|
|
542
561
|
displayData,
|
|
543
562
|
langParam || undefined,
|
|
544
563
|
);
|
|
@@ -565,7 +584,7 @@ export default function Form({
|
|
|
565
584
|
};
|
|
566
585
|
|
|
567
586
|
void initializePrivacyCenterForm();
|
|
568
|
-
}, [organizationId, workspaceId, isPopup, authContext, consentClient,
|
|
587
|
+
}, [organizationId, workspaceId, isPopup, authContext, consentClient, withUserConsentPickerGroups]);
|
|
569
588
|
|
|
570
589
|
/**
|
|
571
590
|
* Maps translated correction element names back to English names using UUID lookup.
|
|
@@ -671,7 +690,13 @@ export default function Form({
|
|
|
671
690
|
}
|
|
672
691
|
|
|
673
692
|
const isProductScoped = isProductScopedRequestType(requestType);
|
|
674
|
-
|
|
693
|
+
// The synthetic legacy-flat group has no server-side product, so it must
|
|
694
|
+
// not anchor the case — those submissions keep the pre-grouping
|
|
695
|
+
// org-level shape (`product_uuid` is optional on the backend).
|
|
696
|
+
const isLegacyFlatSelection =
|
|
697
|
+
selectedGroup?.product_uuid === LEGACY_PRODUCT_GROUP_UUID;
|
|
698
|
+
const anchorGroup =
|
|
699
|
+
isProductScoped && !isLegacyFlatSelection ? selectedGroup : null;
|
|
675
700
|
|
|
676
701
|
const requestDetails: {
|
|
677
702
|
purposes: UpdatePurpose[];
|
|
@@ -12,6 +12,7 @@ import { useAuth } from '../context/AuthContext';
|
|
|
12
12
|
import { ProfileSwitchProvider } from '../context/ProfileSwitchContext';
|
|
13
13
|
import { PCLoader } from '../ui';
|
|
14
14
|
import { decodeJwtToken } from '../lib/utils';
|
|
15
|
+
import { isSandboxMode } from '../lib/sandbox';
|
|
15
16
|
import type {
|
|
16
17
|
GatePhase,
|
|
17
18
|
IdentityCandidate,
|
|
@@ -54,6 +55,13 @@ export const SelectUserGate = ({ children }: SelectUserGateProps) => {
|
|
|
54
55
|
useEffect(() => {
|
|
55
56
|
let cancelled = false;
|
|
56
57
|
|
|
58
|
+
// Sandbox PoC: there is no JWT and no shared-contact concept — the subject is
|
|
59
|
+
// fixed by props. Skip the identity lookup and render children directly.
|
|
60
|
+
if (isSandboxMode()) {
|
|
61
|
+
setPhase('resolved');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
if (!organizationId || !workspaceId || !accessToken) {
|
|
58
66
|
setPhase('resolved');
|
|
59
67
|
return;
|