@redacto.io/consent-sdk-react 10.0.0-beta.0 → 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.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +123 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +43 -8
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.js +345 -237
  8. package/dist/index.mjs +344 -238
  9. package/dist/privacy-center.d.mts +113 -37
  10. package/dist/privacy-center.d.ts +113 -37
  11. package/dist/privacy-center.js +2317 -1910
  12. package/dist/privacy-center.mjs +2245 -1835
  13. package/package.json +1 -1
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
  15. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
  16. package/src/RedactoNoticeAssisted/api/index.ts +7 -1
  17. package/src/RedactoNoticeAssisted/api/types.ts +2 -0
  18. package/src/RedactoNoticeAssisted/i18n.ts +0 -55
  19. package/src/RedactoNoticeAssisted/types.ts +2 -0
  20. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
  21. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +75 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +90 -177
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +60 -44
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +29 -3
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
  31. package/src/RedactoNoticeConsentInline/types.ts +1 -1
  32. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  33. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
  34. package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
  35. package/src/RedactoPrivacyCenter/api/client.ts +216 -39
  36. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  37. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  38. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
  39. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  40. package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
  41. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  42. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
  43. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
  44. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  45. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  46. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  47. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
  48. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  49. package/src/RedactoPrivacyCenter/context/types.ts +39 -1
  50. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  51. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  52. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  53. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  54. package/src/RedactoPrivacyCenter/lib/types.ts +78 -2
  55. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  56. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  58. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  59. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  60. package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
  61. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  62. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  63. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  64. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  65. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  66. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  67. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  68. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  69. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  70. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  71. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  72. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  73. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  74. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  75. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  76. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  77. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  78. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  79. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -2
  80. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  81. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  82. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  83. package/src/shared/notice-consent-status.ts +190 -0
  84. package/src/shared/sandbox.ts +6 -0
  85. package/tests/Form.test.tsx +190 -7
  86. package/tests/RevokeWarningMessage.test.tsx +151 -0
  87. package/tests/consentClient.test.ts +217 -0
  88. package/tests/manageConsentServer.test.ts +38 -0
  89. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
@@ -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: string;
184
- refreshToken: string;
197
+ accessToken?: string;
198
+ refreshToken?: string;
185
199
  baseUrl?: string;
186
200
  language?: string;
187
201
  specific_uuid?: string;
@@ -228,9 +242,10 @@ export type Purpose = {
228
242
  data_elements: Array<DataElement>;
229
243
  };
230
244
 
231
- export type SubmitConsentEventParams = {
232
- accessToken: string;
233
- ledgerBaseUrl: string;
245
+ export type SubmitConsentEventParams = SandboxAuthParams & {
246
+ accessToken?: string;
247
+ baseUrl?: string;
248
+ ledgerBaseUrl?: string;
234
249
  noticeUuid: string;
235
250
  purposes: Array<Purpose>;
236
251
  declined: boolean;
@@ -351,9 +366,10 @@ export type DpoInfoAudio = {
351
366
  grievance_email_audio_url?: string;
352
367
  };
353
368
 
354
- export type FetchTTSAudioUrlsParams = {
355
- accessToken: string;
369
+ export type FetchTTSAudioUrlsParams = SandboxAuthParams & {
370
+ accessToken?: string;
356
371
  baseUrl?: string;
372
+ ledgerBaseUrl?: string;
357
373
  noticeUuid: string;
358
374
  language: string;
359
375
  };
@@ -366,8 +382,8 @@ export type FetchTTSAudioUrlsParams = {
366
382
  * Parameters for initiating guardian verification
367
383
  * POST /guardian/initiate-verification
368
384
  */
369
- export type InitiateGuardianVerificationParams = {
370
- accessToken: string;
385
+ export type InitiateGuardianVerificationParams = SandboxAuthParams & {
386
+ accessToken?: string;
371
387
  baseUrl?: string;
372
388
  guardianName: string;
373
389
  guardianContact: string;
@@ -402,8 +418,8 @@ export type InitiateGuardianVerificationResponse = {
402
418
  * Parameters for checking verification status
403
419
  * POST /guardian/verify-status
404
420
  */
405
- export type VerifyGuardianStatusParams = {
406
- accessToken: string;
421
+ export type VerifyGuardianStatusParams = SandboxAuthParams & {
422
+ accessToken?: string;
407
423
  baseUrl?: string;
408
424
  sessionToken: string;
409
425
  signal?: AbortSignal;
@@ -6,7 +6,6 @@ export const LANGUAGE_NATIVE_LABELS: Record<string, string> = {
6
6
  English: "English",
7
7
  Assamese: "অসমীয়া (Assamese)",
8
8
  Bengali: "বাংলা (Bengali)",
9
- Bhojpuri: "भोजपुरी (Bhojpuri)",
10
9
  Bodo: "बड़ो (Bodo)",
11
10
  Dogri: "डोगरी (Dogri)",
12
11
  Gujarati: "ગુજરાતી (Gujarati)",
@@ -44,7 +43,6 @@ const LANGUAGE_NAME_TO_BCP47: Record<string, string> = {
44
43
  English: "en",
45
44
  Assamese: "as",
46
45
  Bengali: "bn",
47
- Bhojpuri: "bho",
48
46
  Bodo: "brx",
49
47
  Dogri: "doi",
50
48
  Gujarati: "gu",
@@ -2,10 +2,36 @@ import type { Settings, DpoInfoTranslation } from "./api/types";
2
2
 
3
3
  export type Props = Readonly<{
4
4
  noticeId: string;
5
- accessToken: string;
6
- refreshToken: string;
5
+ /**
6
+ * JWT access token. Optional so sandbox integrations (see `token`) can
7
+ * omit it. Required in the normal JWT flow.
8
+ */
9
+ accessToken?: string;
10
+ /** JWT refresh token. Optional; accepted for source-compat, currently unused. */
11
+ refreshToken?: string;
12
+ /**
13
+ * Sandbox PoC: a pasted static token. When set, the SDK runs against the
14
+ * sandbox with no JWT — it sends the single `X-Consent-Token` header, carries
15
+ * the acting identity (`email` || `mobile`) in each request's own payload,
16
+ * takes org/workspace from `organisationUuid`/`workspaceUuid`, and does no
17
+ * token refresh.
18
+ */
19
+ token?: string;
20
+ /** Sandbox test subject email (carried in the request payload when set). */
21
+ email?: string;
22
+ /** Sandbox test subject mobile (fallback subject when `email` is absent). */
23
+ mobile?: string;
24
+ /**
25
+ * Sandbox UCIC: the client's own user id / org_user_id, e.g. a UUID or
26
+ * customer code; optional third identifier alongside email/mobile.
27
+ */
28
+ ucic?: string;
29
+ /** Sandbox org UUID (replaces the JWT-decoded org in sandbox mode). */
30
+ organisationUuid?: string;
31
+ /** Sandbox workspace UUID (replaces the JWT-decoded workspace in sandbox mode). */
32
+ workspaceUuid?: string;
7
33
  baseUrl?: string;
8
- ledgerBaseUrl: string;
34
+ ledgerBaseUrl?: string;
9
35
  settings?: Partial<Settings>;
10
36
  language?: string;
11
37
  blockUI?: boolean;
@@ -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
 
@@ -149,18 +161,26 @@ const RedactoConsentInlineComponent = ({
149
161
  return defaultText;
150
162
  };
151
163
 
152
- const fetchNotice = async () => {
164
+ const fetchNotice = async (signal?: AbortSignal) => {
153
165
  setIsLoading(true);
154
166
  setFetchError(null);
167
+ setHasAlreadyConsented(false);
155
168
  try {
156
169
  const consentContentData = await fetchConsentContent({
157
170
  org_uuid,
158
171
  workspace_uuid,
159
172
  notice_uuid,
173
+ accessToken,
160
174
  baseUrl,
161
175
  language,
162
176
  specific_uuid: applicationId,
177
+ signal,
163
178
  });
179
+
180
+ if (signal?.aborted) {
181
+ return;
182
+ }
183
+
164
184
  const activeConfig = consentContentData.detail.active_config;
165
185
  setContent(activeConfig);
166
186
  if (activeConfig.default_language) {
@@ -203,6 +223,13 @@ const RedactoConsentInlineComponent = ({
203
223
  );
204
224
  setSelectedPurposes(initialPurposeState);
205
225
  } catch (err: unknown) {
226
+ if (
227
+ signal?.aborted ||
228
+ (err instanceof Error && err.name === "AbortError")
229
+ ) {
230
+ return;
231
+ }
232
+
206
233
  const error = err as Error & { status?: number };
207
234
  setFetchError(error);
208
235
  if (error.status === 409) {
@@ -211,14 +238,60 @@ const RedactoConsentInlineComponent = ({
211
238
  onError?.(error);
212
239
  }
213
240
  } finally {
214
- setIsLoading(false);
241
+ if (!signal?.aborted) {
242
+ setIsLoading(false);
243
+ }
215
244
  }
216
245
  };
217
246
 
218
247
  useEffect(() => {
219
- fetchNotice();
248
+ const controller = new AbortController();
249
+ void fetchNotice(controller.signal);
250
+
251
+ return () => {
252
+ controller.abort();
253
+ };
220
254
  // eslint-disable-next-line react-hooks/exhaustive-deps
221
- }, [org_uuid, workspace_uuid, notice_uuid, language, applicationId]);
255
+ }, [org_uuid, workspace_uuid, notice_uuid, language, applicationId, accessToken]);
256
+
257
+ // Post-cutover, consent records live in the Go ledger, so Python's
258
+ // get-notice no longer returns 409 CONSENT_ALREADY_PROVIDED. When a ledger
259
+ // is configured, ask it directly whether this principal has already fully
260
+ // consented — the same check-consent read the banner uses. Skipped for the
261
+ // magic-link specific_uuid flow (which always re-submits against a specific
262
+ // application) and when no ledger is set (the legacy 409 path governs).
263
+ useEffect(() => {
264
+ if (!ledgerBaseUrl || applicationId) {
265
+ return;
266
+ }
267
+ if (!accessToken || !content) {
268
+ return;
269
+ }
270
+ let active = true;
271
+ const controller = new AbortController();
272
+ const checkLedgerConsent = async () => {
273
+ try {
274
+ const ledgerStatus = await fetchNoticeConsentStatus({
275
+ accessToken,
276
+ ledgerBaseUrl,
277
+ noticeUuid: notice_uuid,
278
+ signal: controller.signal,
279
+ });
280
+ if (active && ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
281
+ setHasAlreadyConsented(true);
282
+ }
283
+ } finally {
284
+ if (active) {
285
+ setIsCheckingConsent(false);
286
+ }
287
+ }
288
+ };
289
+ checkLedgerConsent();
290
+ return () => {
291
+ active = false;
292
+ controller.abort();
293
+ };
294
+ }, [ledgerBaseUrl, applicationId, accessToken, content, notice_uuid]);
222
295
 
223
296
  const togglePurposeCollapse = (purposeUuid: string) => {
224
297
  setCollapsedPurposes((prev) => ({
@@ -320,6 +393,7 @@ const RedactoConsentInlineComponent = ({
320
393
  org_uuid,
321
394
  workspace_uuid,
322
395
  accessToken: tokenToUse,
396
+ baseUrl,
323
397
  ledgerBaseUrl,
324
398
  noticeUuid: content.notice_uuid,
325
399
  purposes: content.purposes.map((purpose) => {
@@ -391,13 +465,18 @@ const RedactoConsentInlineComponent = ({
391
465
  ]
392
466
  );
393
467
 
394
- // Auto-submit consent when all conditions are met
468
+ // Auto-submit consent when all conditions are met. Waits for the ledger
469
+ // check-consent read to settle (isCheckingConsent) and never fires for an
470
+ // already-consented principal, so the cutover cannot trigger a duplicate
471
+ // submission.
395
472
  useEffect(() => {
396
473
  if (
397
474
  accessToken &&
398
475
  content &&
399
476
  areAllRequiredElementsChecked &&
400
477
  !isSubmitting &&
478
+ !isCheckingConsent &&
479
+ !hasAlreadyConsented &&
401
480
  !hasSubmittedRef.current
402
481
  ) {
403
482
  hasSubmittedRef.current = true;
@@ -416,6 +495,8 @@ const RedactoConsentInlineComponent = ({
416
495
  content,
417
496
  areAllRequiredElementsChecked,
418
497
  isSubmitting,
498
+ isCheckingConsent,
499
+ hasAlreadyConsented,
419
500
  handleAcceptWithToken,
420
501
  ]);
421
502
 
@@ -12,13 +12,19 @@ export const fetchConsentContent = async ({
12
12
  org_uuid,
13
13
  workspace_uuid,
14
14
  notice_uuid,
15
+ accessToken,
15
16
  baseUrl,
17
+ language,
16
18
  specific_uuid,
19
+ signal,
17
20
  }: FetchConsentContentParams): Promise<ConsentContent> => {
18
21
  const apiBaseUrl = baseUrl || BASE_URL;
22
+ const noticesUrl = `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/notices`;
19
23
 
20
24
  const url = new URL(
21
- `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/notices/get-notice/${notice_uuid}`
25
+ accessToken
26
+ ? `${noticesUrl}/${notice_uuid}`
27
+ : `${noticesUrl}/get-notice/${notice_uuid}`
22
28
  );
23
29
 
24
30
  if (specific_uuid) {
@@ -29,7 +35,10 @@ export const fetchConsentContent = async ({
29
35
  method: "GET",
30
36
  headers: {
31
37
  "Content-Type": "application/json",
38
+ "Accept-Language": language || "en",
39
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
32
40
  },
41
+ signal,
33
42
  });
34
43
 
35
44
  if (!response.ok) {
@@ -47,16 +56,16 @@ export const submitConsentEvent = async ({
47
56
  org_uuid,
48
57
  workspace_uuid,
49
58
  accessToken,
59
+ baseUrl,
50
60
  ledgerBaseUrl,
51
61
  noticeUuid,
52
62
  purposes,
53
63
  declined,
54
64
  meta_data,
55
65
  }: SubmitConsentEventParams): Promise<void> => {
56
- if (!ledgerBaseUrl) {
57
- throw new Error("ledgerBaseUrl is required to submit consent");
58
- }
59
- const apiBaseUrl = ledgerBaseUrl;
66
+ // Fall back to the consent server when no ledger is configured, so existing
67
+ // integrations that only pass baseUrl remain backwards compatible.
68
+ const apiBaseUrl = ledgerBaseUrl || baseUrl || BASE_URL;
60
69
 
61
70
  const payload: ConsentEventPayload = {
62
71
  notice_uuid: noticeUuid,
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { fetchConsentContent } from "./index";
3
+
4
+ const LEDGER = "https://ledger.example.test/consent-ledger";
5
+ const PYTHON = "https://api.example.test/consent";
6
+ const TOKEN = "opaque-token";
7
+
8
+ const scope = (base: string) =>
9
+ `${base}/public/organisations/org-1/workspaces/ws-1/notices`;
10
+
11
+ const requestedUrl = (fetchMock: ReturnType<typeof vi.fn>): string =>
12
+ String(fetchMock.mock.calls[0][0]);
13
+
14
+ const requestedHeaders = (
15
+ fetchMock: ReturnType<typeof vi.fn>
16
+ ): Record<string, string> =>
17
+ (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
18
+
19
+ describe("inline notice read routing", () => {
20
+ const fetchMock = vi.fn();
21
+ const base = { org_uuid: "org-1", workspace_uuid: "ws-1", notice_uuid: "ntc-1" };
22
+
23
+ beforeEach(() => {
24
+ fetchMock.mockReset();
25
+ fetchMock.mockResolvedValue({
26
+ ok: true,
27
+ status: 200,
28
+ json: async () => ({}),
29
+ } as Response);
30
+ vi.stubGlobal("fetch", fetchMock);
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ it("reads the authenticated notice endpoint when a token is available", async () => {
38
+ await fetchConsentContent({ ...base, accessToken: TOKEN, baseUrl: LEDGER });
39
+ expect(requestedUrl(fetchMock)).toBe(`${scope(LEDGER)}/ntc-1`);
40
+ expect(requestedHeaders(fetchMock).Authorization).toBe(`Bearer ${TOKEN}`);
41
+ });
42
+
43
+ it("falls back to the tokenless get-notice endpoint without a token", async () => {
44
+ await fetchConsentContent({ ...base, baseUrl: PYTHON });
45
+ expect(requestedUrl(fetchMock)).toBe(`${scope(PYTHON)}/get-notice/ntc-1`);
46
+ expect(requestedHeaders(fetchMock).Authorization).toBeUndefined();
47
+ });
48
+
49
+ it("keeps specific_uuid on the authenticated read", async () => {
50
+ await fetchConsentContent({
51
+ ...base,
52
+ accessToken: TOKEN,
53
+ baseUrl: LEDGER,
54
+ specific_uuid: "app-1",
55
+ });
56
+ expect(requestedUrl(fetchMock)).toBe(`${scope(LEDGER)}/ntc-1?specific_uuid=app-1`);
57
+ });
58
+
59
+ it("sends the requested language on both paths", async () => {
60
+ await fetchConsentContent({ ...base, baseUrl: PYTHON, language: "hi" });
61
+ expect(requestedHeaders(fetchMock)["Accept-Language"]).toBe("hi");
62
+
63
+ fetchMock.mockClear();
64
+ await fetchConsentContent({
65
+ ...base,
66
+ accessToken: TOKEN,
67
+ baseUrl: LEDGER,
68
+ language: "hi",
69
+ });
70
+ expect(requestedHeaders(fetchMock)["Accept-Language"]).toBe("hi");
71
+ });
72
+ });
@@ -128,9 +128,11 @@ export type FetchConsentContentParams = {
128
128
  org_uuid: string;
129
129
  workspace_uuid: string;
130
130
  notice_uuid: string;
131
+ accessToken?: string;
131
132
  baseUrl?: string;
132
133
  language?: string;
133
134
  specific_uuid?: string;
135
+ signal?: AbortSignal;
134
136
  };
135
137
 
136
138
  export type ConsentEventPayload = {
@@ -172,7 +174,8 @@ export type SubmitConsentEventParams = {
172
174
  org_uuid: string;
173
175
  workspace_uuid: string;
174
176
  accessToken: string;
175
- ledgerBaseUrl: string;
177
+ baseUrl?: string;
178
+ ledgerBaseUrl?: string;
176
179
  noticeUuid: string;
177
180
  purposes: Array<Purpose>;
178
181
  declined: boolean;
@@ -8,7 +8,7 @@ export type Props = Readonly<{
8
8
  accessToken?: string;
9
9
  refreshToken?: string;
10
10
  baseUrl?: string;
11
- ledgerBaseUrl: string;
11
+ ledgerBaseUrl?: string;
12
12
  settings?: Partial<Settings>;
13
13
  language?: string;
14
14
  onAccept?: () => void;
@@ -12,6 +12,7 @@ import { useAuth } from './context/AuthContext';
12
12
  import { PCLoader } from './ui';
13
13
  import { pcStyles } from './styles/pcStyles';
14
14
  import AppShell from './components/AppShell/AppShell';
15
+ import { SelectUserGate } from './components/SelectUserGate';
15
16
 
16
17
  interface PrivacyCenterLayoutProps {
17
18
  children: ReactNode;
@@ -164,11 +165,13 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
164
165
 
165
166
  return (
166
167
  <OrganizationProvider organization={organization}>
167
- <DataAvailabilityProvider>
168
- <AppShell onBack={onBack}>
169
- {children}
170
- </AppShell>
171
- </DataAvailabilityProvider>
168
+ <SelectUserGate>
169
+ <DataAvailabilityProvider>
170
+ <AppShell onBack={onBack}>
171
+ {children}
172
+ </AppShell>
173
+ </DataAvailabilityProvider>
174
+ </SelectUserGate>
172
175
  </OrganizationProvider>
173
176
  );
174
177
  }
@@ -15,10 +15,33 @@ injectPCStyles();
15
15
 
16
16
  interface RedactoPrivacyCenterProps {
17
17
  baseUrl: string; // Consent server base URL
18
- ledgerBaseUrl: string; // Go ledger base URL — serves user-consents
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
- accessToken: string;
21
- refreshToken: string;
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}>
@@ -24,6 +24,7 @@ import type {
24
24
  GetCaseMessagesParams,
25
25
  MessageDocument,
26
26
  GetNotificationsServerParams,
27
+ LegacyManageConsentResponse,
27
28
  NotificationActionBaseParams,
28
29
  NotificationActionResponse,
29
30
  NotificationByUuidServerParams,
@@ -353,6 +354,7 @@ export async function manageConsentServer(params: {
353
354
  productUuid?: string;
354
355
  noticeUuid?: string;
355
356
  dataElements?: Array<{ uuid: string; selected: boolean }>;
357
+ language?: string;
356
358
  }): Promise<boolean | null> {
357
359
  try {
358
360
  if (!params.consentClient) {
@@ -361,10 +363,12 @@ export async function manageConsentServer(params: {
361
363
  // Privacy center endpoints use JWT Bearer tokens, not session-based auth
362
364
  const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
363
365
 
364
- // Routes to the Go ledger's submit-consent (partial); resolves on 2xx and
365
- // throws otherwise, so a resolved call is success. The ledger returns a
366
- // ConsentEventResponse (no `success` flag), unlike the old Python endpoint.
367
- await params.consentClient.manageConsent(
366
+ // Ledger mode routes to the Go ledger's submit-consent (partial), which
367
+ // resolves on 2xx and throws otherwise — a resolved call is success (the
368
+ // ledger returns a ConsentEventResponse, no `success` flag). Legacy mode
369
+ // hits the Python manage-consent endpoint, whose unwrapped response
370
+ // carries a `success` flag that must be honored even on 2xx.
371
+ const result = await params.consentClient.manageConsent(
368
372
  baseRequest,
369
373
  params.purposeId,
370
374
  params.contact,
@@ -375,8 +379,12 @@ export async function manageConsentServer(params: {
375
379
  params.dataElementUuids,
376
380
  params.productUuid,
377
381
  params.noticeUuid,
378
- params.dataElements
382
+ params.dataElements,
383
+ params.language
379
384
  );
385
+ if (result && typeof result === 'object' && 'success' in result) {
386
+ return Boolean((result as LegacyManageConsentResponse).success);
387
+ }
380
388
  return true;
381
389
  } catch (error: any) {
382
390
  // Normalize nominator-specific errors into stable error codes