@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.
- package/.turbo/turbo-build.log +22 -25
- package/CHANGELOG.md +123 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +43 -8
- package/dist/index.d.ts +43 -8
- package/dist/index.js +345 -237
- package/dist/index.mjs +344 -238
- package/dist/privacy-center.d.mts +113 -37
- package/dist/privacy-center.d.ts +113 -37
- package/dist/privacy-center.js +2317 -1910
- package/dist/privacy-center.mjs +2245 -1835
- 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 +75 -33
- package/src/RedactoNoticeConsent/api/index.ts +90 -177
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +60 -44
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +29 -3
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
- package/src/RedactoNoticeConsentInline/types.ts +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
- package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
- package/src/RedactoPrivacyCenter/api/client.ts +216 -39
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
- package/src/RedactoPrivacyCenter/context/types.ts +39 -1
- 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 +78 -2
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -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 +190 -7
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +217 -0
- package/tests/manageConsentServer.test.ts +38 -0
- 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
|
|
184
|
-
refreshToken
|
|
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
|
|
233
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
241
|
+
if (!signal?.aborted) {
|
|
242
|
+
setIsLoading(false);
|
|
243
|
+
}
|
|
215
244
|
}
|
|
216
245
|
};
|
|
217
246
|
|
|
218
247
|
useEffect(() => {
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
177
|
+
baseUrl?: string;
|
|
178
|
+
ledgerBaseUrl?: string;
|
|
176
179
|
noticeUuid: string;
|
|
177
180
|
purposes: Array<Purpose>;
|
|
178
181
|
declined: boolean;
|
|
@@ -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
|
-
<
|
|
168
|
-
<
|
|
169
|
-
{
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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}>
|
|
@@ -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
|
-
//
|
|
365
|
-
// throws otherwise
|
|
366
|
-
// ConsentEventResponse
|
|
367
|
-
|
|
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
|