@redacto.io/consent-sdk-react 10.0.0-beta.3 → 10.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +39 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +135 -101
- package/dist/index.mjs +135 -101
- package/dist/privacy-center.js +461 -778
- package/dist/privacy-center.mjs +311 -628
- 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/api/index.ts +5 -66
- package/src/RedactoNoticeConsent/api/types.ts +9 -30
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +59 -1
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +22 -27
- package/src/RedactoPrivacyCenter/lib/constants.ts +0 -1
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
- package/src/RedactoPrivacyCenter/locales/en/translation.json +2 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
- package/src/shared/notice-consent-status.ts +190 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -343
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./api";
|
|
19
19
|
import type { ConsentContent, TTSAudioUrlsResponse } from "./api/types";
|
|
20
20
|
import { ApiError } from "../shared/api-errors";
|
|
21
|
+
import { fetchNoticeConsentStatusForScope } from "../shared/notice-consent-status";
|
|
21
22
|
import { makeStaticT } from "./i18n";
|
|
22
23
|
|
|
23
24
|
vi.mock("./api", () => ({
|
|
@@ -28,12 +29,26 @@ vi.mock("./api", () => ({
|
|
|
28
29
|
submitAssistedConsent: vi.fn(),
|
|
29
30
|
}));
|
|
30
31
|
|
|
32
|
+
// Mock only the ledger check-consent network read; keep the real
|
|
33
|
+
// isNoticeFullyConsented predicate so the already-consented decision is exercised.
|
|
34
|
+
vi.mock("../shared/notice-consent-status", async (importActual) => {
|
|
35
|
+
const actual =
|
|
36
|
+
await importActual<typeof import("../shared/notice-consent-status")>();
|
|
37
|
+
return {
|
|
38
|
+
...actual,
|
|
39
|
+
fetchNoticeConsentStatusForScope: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const LEDGER_BASE_URL = "https://ledger.example.test/consent-ledger";
|
|
44
|
+
|
|
31
45
|
const otpLength = ASSISTED_CONFIG.otpLength;
|
|
32
46
|
const mockedFetch = vi.mocked(fetchAssistedNotice);
|
|
33
47
|
const mockedFetchTTS = vi.mocked(fetchAssistedTTSAudioUrls);
|
|
34
48
|
const mockedCreateOtp = vi.mocked(createOtp);
|
|
35
49
|
const mockedVerifyOtp = vi.mocked(verifyOtp);
|
|
36
50
|
const mockedSubmitConsent = vi.mocked(submitAssistedConsent);
|
|
51
|
+
const mockedFetchScope = vi.mocked(fetchNoticeConsentStatusForScope);
|
|
37
52
|
|
|
38
53
|
const OTP_DETAIL = {
|
|
39
54
|
uuid: "otp-1",
|
|
@@ -226,6 +241,9 @@ describe("RedactoNoticeAssisted", () => {
|
|
|
226
241
|
mockedCreateOtp.mockResolvedValue(OTP_DETAIL);
|
|
227
242
|
mockedVerifyOtp.mockResolvedValue(VERIFY_DETAIL);
|
|
228
243
|
mockedSubmitConsent.mockResolvedValue(null);
|
|
244
|
+
// Default: ledger reports no prior full consent, so flows proceed to submit.
|
|
245
|
+
mockedFetchScope.mockReset();
|
|
246
|
+
mockedFetchScope.mockResolvedValue(null);
|
|
229
247
|
});
|
|
230
248
|
|
|
231
249
|
it("fetches with the public-notice props (no auth)", async () => {
|
|
@@ -604,6 +622,75 @@ describe("RedactoNoticeAssisted", () => {
|
|
|
604
622
|
).not.toBeInTheDocument();
|
|
605
623
|
});
|
|
606
624
|
|
|
625
|
+
// ── Go ledger (ledgerBaseUrl) ─────────────────────────────────────────────
|
|
626
|
+
// Drives the full notice → OTP → Confirm flow with a notice that carries a
|
|
627
|
+
// notice_uuid (so the check-consent read runs), and returns the onComplete spy.
|
|
628
|
+
const runConfirmFlow = async (extraProps: Record<string, unknown>) => {
|
|
629
|
+
mockedFetch.mockResolvedValue(NOTICE_WITH_AUDIO);
|
|
630
|
+
const onComplete = vi.fn();
|
|
631
|
+
render(
|
|
632
|
+
<RedactoNoticeAssisted {...PROPS} onComplete={onComplete} {...extraProps} />,
|
|
633
|
+
);
|
|
634
|
+
await screen.findByRole("heading", { name: "EA Sports" });
|
|
635
|
+
selectRequired();
|
|
636
|
+
fireEvent.click(screen.getByRole("button", { name: "Agree" }));
|
|
637
|
+
fireEvent.change(screen.getByLabelText("Mobile number"), {
|
|
638
|
+
target: { value: "9876543210" },
|
|
639
|
+
});
|
|
640
|
+
fireEvent.click(screen.getByRole("button", { name: "Send OTP" }));
|
|
641
|
+
await screen.findByText(/OTP sent to/);
|
|
642
|
+
typeOtp("1".repeat(otpLength));
|
|
643
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm OTP" }));
|
|
644
|
+
return onComplete;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
it("routes the write through ledgerBaseUrl and submits when not already consented", async () => {
|
|
648
|
+
const onComplete = await runConfirmFlow({ ledgerBaseUrl: LEDGER_BASE_URL });
|
|
649
|
+
|
|
650
|
+
await waitFor(() => expect(mockedSubmitConsent).toHaveBeenCalledTimes(1));
|
|
651
|
+
// The check-consent read runs with props org/workspace + the OTP token.
|
|
652
|
+
expect(mockedFetchScope).toHaveBeenCalledWith(
|
|
653
|
+
expect.objectContaining({
|
|
654
|
+
accessToken: "access-token-1",
|
|
655
|
+
ledgerBaseUrl: LEDGER_BASE_URL,
|
|
656
|
+
organisationUuid: "org-1",
|
|
657
|
+
workspaceUuid: "ws-1",
|
|
658
|
+
noticeUuid: "ntc-1",
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
// The submit carries ledgerBaseUrl so the api layer routes it to the ledger.
|
|
662
|
+
expect(mockedSubmitConsent).toHaveBeenCalledWith(
|
|
663
|
+
expect.objectContaining({ ledgerBaseUrl: LEDGER_BASE_URL }),
|
|
664
|
+
);
|
|
665
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("short-circuits an already fully-consented principal via the ledger (no submit)", async () => {
|
|
669
|
+
mockedFetchScope.mockResolvedValue({
|
|
670
|
+
consented: true,
|
|
671
|
+
all_mandatory_active: true,
|
|
672
|
+
purposes: [],
|
|
673
|
+
});
|
|
674
|
+
const onComplete = await runConfirmFlow({ ledgerBaseUrl: LEDGER_BASE_URL });
|
|
675
|
+
|
|
676
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
|
|
677
|
+
expect(mockedSubmitConsent).not.toHaveBeenCalled();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("skips the ledger check-consent and stays on baseUrl when ledgerBaseUrl is absent", async () => {
|
|
681
|
+
const onComplete = await runConfirmFlow({});
|
|
682
|
+
|
|
683
|
+
await waitFor(() => expect(mockedSubmitConsent).toHaveBeenCalledTimes(1));
|
|
684
|
+
expect(mockedFetchScope).not.toHaveBeenCalled();
|
|
685
|
+
expect(mockedSubmitConsent).toHaveBeenCalledWith(
|
|
686
|
+
expect.objectContaining({
|
|
687
|
+
ledgerBaseUrl: undefined,
|
|
688
|
+
baseUrl: "https://api.example.test/consent",
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
|
|
692
|
+
});
|
|
693
|
+
|
|
607
694
|
// The verify endpoint returns English copy with no error_code, so the SDK
|
|
608
695
|
// localizes by HTTP status + the `should_request_new` flag instead of echoing
|
|
609
696
|
// the server message.
|
|
@@ -23,6 +23,10 @@ import type {
|
|
|
23
23
|
TTSAudioUrlsResponse,
|
|
24
24
|
} from "./api/types";
|
|
25
25
|
import { ApiError } from "../shared/api-errors";
|
|
26
|
+
import {
|
|
27
|
+
fetchNoticeConsentStatusForScope,
|
|
28
|
+
isNoticeFullyConsented,
|
|
29
|
+
} from "../shared/notice-consent-status";
|
|
26
30
|
import {
|
|
27
31
|
toNativeLabel,
|
|
28
32
|
toBcp47Code,
|
|
@@ -251,6 +255,7 @@ export const RedactoNoticeAssisted = ({
|
|
|
251
255
|
workspaceUuid,
|
|
252
256
|
noticeUuid,
|
|
253
257
|
baseUrl,
|
|
258
|
+
ledgerBaseUrl,
|
|
254
259
|
onDecline,
|
|
255
260
|
onComplete,
|
|
256
261
|
}: RedactoNoticeAssistedProps) => {
|
|
@@ -1207,6 +1212,26 @@ export const RedactoNoticeAssisted = ({
|
|
|
1207
1212
|
}
|
|
1208
1213
|
const token = verifyDetail.access_token;
|
|
1209
1214
|
|
|
1215
|
+
// Post-cutover, consent records live in the Go ledger, so Python's 409
|
|
1216
|
+
// CONSENT_ALREADY_PROVIDED can no longer be relied on. When a ledger is
|
|
1217
|
+
// configured, ask it directly (org/workspace from props, OTP token as
|
|
1218
|
+
// bearer) whether this principal has already fully consented and, if so,
|
|
1219
|
+
// hand off without writing a duplicate consent event. Any failure returns
|
|
1220
|
+
// null and falls through to the normal submit (graceful degradation).
|
|
1221
|
+
if (ledgerBaseUrl) {
|
|
1222
|
+
const ledgerStatus = await fetchNoticeConsentStatusForScope({
|
|
1223
|
+
accessToken: token,
|
|
1224
|
+
ledgerBaseUrl,
|
|
1225
|
+
organisationUuid,
|
|
1226
|
+
workspaceUuid,
|
|
1227
|
+
noticeUuid: content.notice_uuid,
|
|
1228
|
+
});
|
|
1229
|
+
if (ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
|
|
1230
|
+
handleSuccess();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1210
1235
|
// Map current selection onto the submit payload shape (required → selected).
|
|
1211
1236
|
const submitPurposes = content.purposes.map((purpose) => ({
|
|
1212
1237
|
...purpose,
|
|
@@ -1224,6 +1249,7 @@ export const RedactoNoticeAssisted = ({
|
|
|
1224
1249
|
organisationUuid,
|
|
1225
1250
|
workspaceUuid,
|
|
1226
1251
|
baseUrl,
|
|
1252
|
+
ledgerBaseUrl,
|
|
1227
1253
|
noticeUuid: content.notice_uuid,
|
|
1228
1254
|
purposes: submitPurposes,
|
|
1229
1255
|
language: toBcp47Code(selectedLanguage),
|
|
@@ -1255,6 +1281,7 @@ export const RedactoNoticeAssisted = ({
|
|
|
1255
1281
|
otpUuid,
|
|
1256
1282
|
otpValue,
|
|
1257
1283
|
baseUrl,
|
|
1284
|
+
ledgerBaseUrl,
|
|
1258
1285
|
selectedPurposes,
|
|
1259
1286
|
selectedDataElements,
|
|
1260
1287
|
selectedLanguage,
|
|
@@ -160,6 +160,7 @@ export const submitAssistedConsent = async ({
|
|
|
160
160
|
organisationUuid,
|
|
161
161
|
workspaceUuid,
|
|
162
162
|
baseUrl,
|
|
163
|
+
ledgerBaseUrl,
|
|
163
164
|
noticeUuid,
|
|
164
165
|
purposes,
|
|
165
166
|
language,
|
|
@@ -196,7 +197,12 @@ export const submitAssistedConsent = async ({
|
|
|
196
197
|
language: language || undefined,
|
|
197
198
|
};
|
|
198
199
|
|
|
199
|
-
|
|
200
|
+
// Route the write to the Go ledger when configured, else the legacy consent
|
|
201
|
+
// server. publicScope already applies `|| BASE_URL`, so this yields the
|
|
202
|
+
// standard `ledgerBaseUrl || baseUrl || BASE_URL` fallback used by the other
|
|
203
|
+
// notice components. Only submit-consent routes to the ledger; notice/OTP/TTS
|
|
204
|
+
// stay on baseUrl.
|
|
205
|
+
const url = `${publicScope(ledgerBaseUrl || baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
|
|
200
206
|
|
|
201
207
|
const response = await fetch(url, {
|
|
202
208
|
method: "POST",
|
|
@@ -94,6 +94,8 @@ export type SubmitAssistedConsentParams = {
|
|
|
94
94
|
organisationUuid: string;
|
|
95
95
|
workspaceUuid: string;
|
|
96
96
|
baseUrl?: string;
|
|
97
|
+
/** Go ledger base URL — routes the write to the ledger; omit to use the legacy consent server. */
|
|
98
|
+
ledgerBaseUrl?: string;
|
|
97
99
|
/** `notice_uuid` from the fetched active_config. */
|
|
98
100
|
noticeUuid: string;
|
|
99
101
|
/** Purposes carrying their current `selected` state + data elements. */
|
|
@@ -1248,60 +1248,6 @@ const as: StaticStrings = {
|
|
|
1248
1248
|
close: "বন্ধ কৰক",
|
|
1249
1249
|
};
|
|
1250
1250
|
|
|
1251
|
-
const bho: StaticStrings = {
|
|
1252
|
-
useEmailInstead: "ईमेल इस्तेमाल करीं",
|
|
1253
|
-
useMobileInstead: "मोबाइल इस्तेमाल करीं",
|
|
1254
|
-
emailAddress: "ईमेल पता",
|
|
1255
|
-
otpHelperEmail: "हमनी एह ईमेल पर एक बेर इस्तेमाल होखे वाला पासवर्ड भेजब।",
|
|
1256
|
-
changeEmail: "ईमेल बदलीं",
|
|
1257
|
-
expandDetails: "{name} के विवरण फैलाईं",
|
|
1258
|
-
collapseDetails: "{name} के विवरण समेटीं",
|
|
1259
|
-
selectAllFor: "{name} खातिर सभ डेटा तत्व चुनीं",
|
|
1260
|
-
selectElement: "{name} चुनीं",
|
|
1261
|
-
selectElementRequired: "{name} चुनीं (जरूरी)",
|
|
1262
|
-
required: "जरूरी",
|
|
1263
|
-
logoAlt: "लोगो",
|
|
1264
|
-
selectLanguage: "भाषा चुनीं",
|
|
1265
|
-
selectLanguageCurrent: "भाषा चुनीं. मौजूदा भाषा: {lang}",
|
|
1266
|
-
opensInNewTab: "नया टैब में खुलेला",
|
|
1267
|
-
audioStart: "ऑडियो प्लेबैक शुरू करीं",
|
|
1268
|
-
audioPause: "ऑडियो प्लेबैक रोकीं",
|
|
1269
|
-
talkbackStart: "टॉकबैक शुरू करीं",
|
|
1270
|
-
talkbackPause: "टॉकबैक रोकीं",
|
|
1271
|
-
mobileNumber: "मोबाइल नंबर",
|
|
1272
|
-
otpHelper: "हमनी एह नंबर पर एक बेर इस्तेमाल होखे वाला पासवर्ड भेजब।",
|
|
1273
|
-
otpSentTo: "ओटीपी (OTP) भेजल गइल",
|
|
1274
|
-
changeMobile: "मोबाइल नंबर बदलीं",
|
|
1275
|
-
enterNDigitOtp: "{count}-अंक के ओटीपी (OTP) डालीं",
|
|
1276
|
-
otpDigit: "ओटीपी (OTP) अंक {index}",
|
|
1277
|
-
resendIn: "{seconds} सेकंड में ओटीपी (OTP) फेर से भेजीं",
|
|
1278
|
-
sending: "भेजल जा रहल बा…",
|
|
1279
|
-
resendOtp: "ओटीपी (OTP) फेर से भेजीं",
|
|
1280
|
-
getOtp: "ओटीपी (OTP) पाईं",
|
|
1281
|
-
verifying: "सत्यापित कइल जा रहल बा…",
|
|
1282
|
-
verifyOtp: "ओटीपी (OTP) सत्यापित करीं",
|
|
1283
|
-
sendOtp: "ओटीपी (OTP) भेजीं",
|
|
1284
|
-
confirmOtp: "ओटीपी (OTP) के पुष्टि करीं",
|
|
1285
|
-
verifyWithOtp: "ओटीपी (OTP) से सत्यापित करीं",
|
|
1286
|
-
editSelections: "चुनाव में बदलाव करीं",
|
|
1287
|
-
otpIncorrect:
|
|
1288
|
-
"रउरा डालल सत्यापन कोड गलत बा। कृपया जाँच करीं आ फेर से कोशिश करीं।",
|
|
1289
|
-
otpExpired: "रउरा सत्यापन कोड के मियाद खतम हो गइल बा। कृपया नया कोड मांगीं।",
|
|
1290
|
-
otpNotFound: "सत्यापन कोड ना मिलल। कृपया नया कोड मांगीं।",
|
|
1291
|
-
otpVerifyFailed: "ओटीपी (OTP) के सत्यापन ना हो सकल। कृपया फेर से कोशिश करीं।",
|
|
1292
|
-
otpSendFailed: "ओटीपी (OTP) ना भेजल जा सकल। कृपया फेर से कोशिश करीं।",
|
|
1293
|
-
consentConfirmed: "सहमति के पुष्टि भइल",
|
|
1294
|
-
recordedUnder: "ओटीपी (OTP) सत्यापित · {regulation} के तहत दर्ज भइल",
|
|
1295
|
-
recordedUnderAt:
|
|
1296
|
-
"ओटीपी (OTP) सत्यापित · {regulation} के तहत {time} बजे दर्ज भइल",
|
|
1297
|
-
consentReferenceId: "सहमति संदर्भ आईडी",
|
|
1298
|
-
proceed: "आगे बढ़ीं",
|
|
1299
|
-
loadingNotice: "नोटिस लोड हो रहल बा",
|
|
1300
|
-
loadFailedTitle: "सहमति नोटिस लोड ना हो सकल",
|
|
1301
|
-
tryAgain: "कृपया फेर से कोसिस करीं।",
|
|
1302
|
-
close: "बंद करीं",
|
|
1303
|
-
};
|
|
1304
|
-
|
|
1305
1251
|
const brx: StaticStrings = {
|
|
1306
1252
|
useEmailInstead: "इमेल बाहाय",
|
|
1307
1253
|
useMobileInstead: "मबाइल बाहाय",
|
|
@@ -1435,7 +1381,6 @@ export const STATIC_STRINGS: Record<string, StaticStrings> = {
|
|
|
1435
1381
|
te,
|
|
1436
1382
|
ur,
|
|
1437
1383
|
as,
|
|
1438
|
-
bho,
|
|
1439
1384
|
brx,
|
|
1440
1385
|
doi,
|
|
1441
1386
|
};
|
|
@@ -17,6 +17,8 @@ export type RedactoNoticeAssistedProps = {
|
|
|
17
17
|
noticeUuid: string;
|
|
18
18
|
/** API base URL (defaults to the production consent API). */
|
|
19
19
|
baseUrl?: string;
|
|
20
|
+
/** Go ledger base URL — routes the consent write to the ledger; omit to use the legacy consent server. */
|
|
21
|
+
ledgerBaseUrl?: string;
|
|
20
22
|
/** Called when the agent/customer declines (host should unmount the modal). */
|
|
21
23
|
onDecline?: () => void;
|
|
22
24
|
/** Called when the agent clicks "Proceed" on the confirmed screen. */
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
ConsentEventPayload,
|
|
6
6
|
DataElementSelection,
|
|
7
7
|
FetchConsentContentParams,
|
|
8
|
-
FetchNoticeConsentStatusParams,
|
|
9
8
|
FetchTTSAudioUrlsParams,
|
|
10
9
|
InitiateGuardianVerificationParams,
|
|
11
10
|
InitiateGuardianVerificationResponse,
|
|
@@ -18,6 +17,11 @@ import type {
|
|
|
18
17
|
VerifyGuardianStatusResponse,
|
|
19
18
|
} from "./types";
|
|
20
19
|
|
|
20
|
+
// The ledger `check-consent` read is shared with RedactoNoticeConsentInline.
|
|
21
|
+
// Re-exported through this barrel so `./api` importers (and the test mock)
|
|
22
|
+
// resolve it here unchanged.
|
|
23
|
+
export { fetchNoticeConsentStatus } from "../../shared/notice-consent-status";
|
|
24
|
+
|
|
21
25
|
const BASE_URL = "https://api.redacto.io/consent";
|
|
22
26
|
|
|
23
27
|
// Simple in-memory cache for API responses to reduce calls
|
|
@@ -216,71 +220,6 @@ export const submitConsentEvent = async ({
|
|
|
216
220
|
apiCache.clear();
|
|
217
221
|
};
|
|
218
222
|
|
|
219
|
-
/**
|
|
220
|
-
* Fetch the principal's current consent status for a notice from the Go
|
|
221
|
-
* ledger (`check-consent`). The principal resolves from the JWT, so the body
|
|
222
|
-
* is empty. Returns null on any failure so the caller can fall back to the
|
|
223
|
-
* Python-provided overlay (graceful degradation). This is how the banner
|
|
224
|
-
* learns prior-consent / reconsent state post-cutover, since Python's notice
|
|
225
|
-
* validation reads its own (now-empty) consent DB.
|
|
226
|
-
*/
|
|
227
|
-
export const fetchNoticeConsentStatus = async ({
|
|
228
|
-
accessToken,
|
|
229
|
-
ledgerBaseUrl,
|
|
230
|
-
noticeUuid,
|
|
231
|
-
signal,
|
|
232
|
-
}: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
|
|
233
|
-
if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const decodedToken = decodeTokenSafely(accessToken);
|
|
238
|
-
if (!decodedToken) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
|
|
243
|
-
if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
const response = await fetch(
|
|
249
|
-
`${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
|
|
250
|
-
{
|
|
251
|
-
method: "POST",
|
|
252
|
-
headers: {
|
|
253
|
-
Authorization: `Bearer ${accessToken}`,
|
|
254
|
-
"Content-Type": "application/json",
|
|
255
|
-
},
|
|
256
|
-
body: "{}",
|
|
257
|
-
signal,
|
|
258
|
-
}
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
if (!response.ok) {
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const data = (await response.json()) as
|
|
266
|
-
| NoticeConsentStatusResponse
|
|
267
|
-
| { detail?: NoticeConsentStatusResponse };
|
|
268
|
-
// The ledger returns the response directly; tolerate a {detail} envelope too.
|
|
269
|
-
const body =
|
|
270
|
-
data && typeof data === "object" && "detail" in data && data.detail
|
|
271
|
-
? data.detail
|
|
272
|
-
: (data as NoticeConsentStatusResponse);
|
|
273
|
-
|
|
274
|
-
if (!body || !Array.isArray(body.purposes)) {
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
return body;
|
|
278
|
-
} catch {
|
|
279
|
-
// Network / abort / parse failure — degrade to the Python overlay.
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
|
|
284
223
|
/**
|
|
285
224
|
* Translate the ledger's notice consent status into the
|
|
286
225
|
* `{ purpose_selections, reconsent_required }` shape the banner already
|
|
@@ -20,36 +20,15 @@ export type DataElementSelection = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
// ─── Ledger notice check-consent ────────────────────────────────────────────
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
export type
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
export type NoticeConsentStatusPurpose = {
|
|
33
|
-
uuid: string;
|
|
34
|
-
name: string;
|
|
35
|
-
selected: boolean;
|
|
36
|
-
status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED | INACTIVE
|
|
37
|
-
expiry_datetime?: string | null;
|
|
38
|
-
data_elements: NoticeConsentStatusDataElement[];
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type NoticeConsentStatusResponse = {
|
|
42
|
-
consented: boolean;
|
|
43
|
-
all_mandatory_active: boolean;
|
|
44
|
-
purposes: NoticeConsentStatusPurpose[];
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export type FetchNoticeConsentStatusParams = {
|
|
48
|
-
accessToken: string;
|
|
49
|
-
ledgerBaseUrl?: string;
|
|
50
|
-
noticeUuid: string;
|
|
51
|
-
signal?: AbortSignal;
|
|
52
|
-
};
|
|
23
|
+
// These types moved to the shared module so the banner and the inline widget
|
|
24
|
+
// consume one definition; re-exported here so existing `./types` importers
|
|
25
|
+
// keep working unchanged.
|
|
26
|
+
export type {
|
|
27
|
+
FetchNoticeConsentStatusParams,
|
|
28
|
+
NoticeConsentStatusDataElement,
|
|
29
|
+
NoticeConsentStatusPurpose,
|
|
30
|
+
NoticeConsentStatusResponse,
|
|
31
|
+
} from "../../shared/notice-consent-status";
|
|
53
32
|
|
|
54
33
|
export type ConsentContent = {
|
|
55
34
|
code: number;
|
|
@@ -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",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, useRef, useCallback, memo } from "react";
|
|
2
2
|
import { fetchConsentContent, submitConsentEvent } from "./api";
|
|
3
|
+
import {
|
|
4
|
+
fetchNoticeConsentStatus,
|
|
5
|
+
isNoticeFullyConsented,
|
|
6
|
+
} from "../shared/notice-consent-status";
|
|
3
7
|
import type { ConsentContent } from "./api/types";
|
|
4
8
|
import type { Props, TranslationObject } from "./types";
|
|
5
9
|
import { styles } from "./styles";
|
|
@@ -36,6 +40,14 @@ const RedactoConsentInlineComponent = ({
|
|
|
36
40
|
Record<string, boolean>
|
|
37
41
|
>({});
|
|
38
42
|
const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
|
|
43
|
+
// When a ledger is configured, block the auto-submit effect until the ledger
|
|
44
|
+
// check-consent read settles, so an already-consented principal is not
|
|
45
|
+
// re-submitted. Starts true only when a ledger check will actually run
|
|
46
|
+
// (skipped for the magic-link specific_uuid flow); stays false otherwise so
|
|
47
|
+
// the legacy Python-409 path is unaffected.
|
|
48
|
+
const [isCheckingConsent, setIsCheckingConsent] = useState<boolean>(
|
|
49
|
+
Boolean(ledgerBaseUrl) && !applicationId
|
|
50
|
+
);
|
|
39
51
|
const [fetchError, setFetchError] = useState<Error | null>(null);
|
|
40
52
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
41
53
|
|
|
@@ -220,6 +232,45 @@ const RedactoConsentInlineComponent = ({
|
|
|
220
232
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
233
|
}, [org_uuid, workspace_uuid, notice_uuid, language, applicationId]);
|
|
222
234
|
|
|
235
|
+
// Post-cutover, consent records live in the Go ledger, so Python's
|
|
236
|
+
// get-notice no longer returns 409 CONSENT_ALREADY_PROVIDED. When a ledger
|
|
237
|
+
// is configured, ask it directly whether this principal has already fully
|
|
238
|
+
// consented — the same check-consent read the banner uses. Skipped for the
|
|
239
|
+
// magic-link specific_uuid flow (which always re-submits against a specific
|
|
240
|
+
// application) and when no ledger is set (the legacy 409 path governs).
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!ledgerBaseUrl || applicationId) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!accessToken || !content) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let active = true;
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const checkLedgerConsent = async () => {
|
|
251
|
+
try {
|
|
252
|
+
const ledgerStatus = await fetchNoticeConsentStatus({
|
|
253
|
+
accessToken,
|
|
254
|
+
ledgerBaseUrl,
|
|
255
|
+
noticeUuid: notice_uuid,
|
|
256
|
+
signal: controller.signal,
|
|
257
|
+
});
|
|
258
|
+
if (active && ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
|
|
259
|
+
setHasAlreadyConsented(true);
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
if (active) {
|
|
263
|
+
setIsCheckingConsent(false);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
checkLedgerConsent();
|
|
268
|
+
return () => {
|
|
269
|
+
active = false;
|
|
270
|
+
controller.abort();
|
|
271
|
+
};
|
|
272
|
+
}, [ledgerBaseUrl, applicationId, accessToken, content, notice_uuid]);
|
|
273
|
+
|
|
223
274
|
const togglePurposeCollapse = (purposeUuid: string) => {
|
|
224
275
|
setCollapsedPurposes((prev) => ({
|
|
225
276
|
...prev,
|
|
@@ -392,13 +443,18 @@ const RedactoConsentInlineComponent = ({
|
|
|
392
443
|
]
|
|
393
444
|
);
|
|
394
445
|
|
|
395
|
-
// Auto-submit consent when all conditions are met
|
|
446
|
+
// Auto-submit consent when all conditions are met. Waits for the ledger
|
|
447
|
+
// check-consent read to settle (isCheckingConsent) and never fires for an
|
|
448
|
+
// already-consented principal, so the cutover cannot trigger a duplicate
|
|
449
|
+
// submission.
|
|
396
450
|
useEffect(() => {
|
|
397
451
|
if (
|
|
398
452
|
accessToken &&
|
|
399
453
|
content &&
|
|
400
454
|
areAllRequiredElementsChecked &&
|
|
401
455
|
!isSubmitting &&
|
|
456
|
+
!isCheckingConsent &&
|
|
457
|
+
!hasAlreadyConsented &&
|
|
402
458
|
!hasSubmittedRef.current
|
|
403
459
|
) {
|
|
404
460
|
hasSubmittedRef.current = true;
|
|
@@ -417,6 +473,8 @@ const RedactoConsentInlineComponent = ({
|
|
|
417
473
|
content,
|
|
418
474
|
areAllRequiredElementsChecked,
|
|
419
475
|
isSubmitting,
|
|
476
|
+
isCheckingConsent,
|
|
477
|
+
hasAlreadyConsented,
|
|
420
478
|
handleAcceptWithToken,
|
|
421
479
|
]);
|
|
422
480
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState, type CSSProperties } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Users } from 'lucide-react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { PCAvatar, PCPopover } from '../../ui';
|
|
5
5
|
import { useBranding } from '../../context/BrandingContext';
|
|
@@ -56,17 +56,11 @@ export function UserMenu({ onBack }: UserMenuProps) {
|
|
|
56
56
|
const displayName = branding?.brand_name || organization?.name || '';
|
|
57
57
|
const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
borderRadius: 9999,
|
|
65
|
-
background: 'transparent',
|
|
66
|
-
border: `1px solid ${getVar('--pc-border-color')}`,
|
|
67
|
-
color: getVar('--pc-text-secondary'),
|
|
68
|
-
cursor: 'pointer',
|
|
69
|
-
transition: 'background 150ms ease, border-color 150ms ease, box-shadow 150ms ease',
|
|
59
|
+
// The .pc-navbar-avatar-btn control draws the ring and surface, so the avatar fills
|
|
60
|
+
// it and draws neither (see `framed={false}` below).
|
|
61
|
+
const avatarFillStyle: CSSProperties = {
|
|
62
|
+
width: '100%',
|
|
63
|
+
height: '100%',
|
|
70
64
|
};
|
|
71
65
|
|
|
72
66
|
// Shared style for the popover's action rows (switch profile, back/sign out).
|
|
@@ -89,24 +83,25 @@ export function UserMenu({ onBack }: UserMenuProps) {
|
|
|
89
83
|
};
|
|
90
84
|
|
|
91
85
|
const trigger = (
|
|
92
|
-
<
|
|
93
|
-
style={triggerStyle}
|
|
94
|
-
role="presentation"
|
|
95
|
-
onMouseEnter={(e) => {
|
|
96
|
-
(e.currentTarget as HTMLDivElement).style.boxShadow =
|
|
97
|
-
'0 0 0 2px color-mix(in srgb, var(--pc-primary) 18%, transparent)';
|
|
98
|
-
}}
|
|
99
|
-
onMouseLeave={(e) => {
|
|
100
|
-
(e.currentTarget as HTMLDivElement).style.boxShadow = 'none';
|
|
101
|
-
}}
|
|
102
|
-
>
|
|
86
|
+
<button type="button" aria-label={t('accountMenu')} className="pc-navbar-avatar-btn">
|
|
103
87
|
{branding?.icon_url ? (
|
|
104
|
-
<PCAvatar
|
|
88
|
+
<PCAvatar
|
|
89
|
+
src={branding.icon_url}
|
|
90
|
+
name={triggerAvatarName}
|
|
91
|
+
size="md"
|
|
92
|
+
color="info"
|
|
93
|
+
framed={false}
|
|
94
|
+
style={avatarFillStyle}
|
|
95
|
+
/>
|
|
105
96
|
) : (
|
|
106
|
-
<PCAvatar
|
|
97
|
+
<PCAvatar
|
|
98
|
+
name={triggerAvatarName}
|
|
99
|
+
size="md"
|
|
100
|
+
color="info"
|
|
101
|
+
style={avatarFillStyle}
|
|
102
|
+
/>
|
|
107
103
|
)}
|
|
108
|
-
|
|
109
|
-
</div>
|
|
104
|
+
</button>
|
|
110
105
|
);
|
|
111
106
|
|
|
112
107
|
const content = (
|
|
@@ -40,7 +40,6 @@ export const languageOptions = [
|
|
|
40
40
|
{ value: 'mni-Mtei', label: 'ꯃꯩꯇꯩꯂꯣꯟ (Manipuri)' },
|
|
41
41
|
{ value: 'gom', label: 'कोंकणी (Goan Konkani)' },
|
|
42
42
|
{ value: 'sa', label: 'संस्कृतम् (Sanskrit)' },
|
|
43
|
-
{ value: 'bho', label: 'भोजपुरी (Bhojpuri)' },
|
|
44
43
|
{ value: 'brx', label: 'बड़ो (Bodo)' },
|
|
45
44
|
{ value: 'ks', label: 'كٲشُر (Kashmiri)' },
|
|
46
45
|
{ value: 'sat', label: 'ᱥᱟᱱᱛᱟᱲᱤ (Santali)' },
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import enTranslation from '../locales/en/translation.json';
|
|
3
3
|
import asTranslation from '../locales/as/translation.json';
|
|
4
4
|
import bnTranslation from '../locales/bn/translation.json';
|
|
5
|
-
import bhoTranslation from '../locales/bho/translation.json';
|
|
6
5
|
import brxTranslation from '../locales/brx/translation.json';
|
|
7
6
|
import doiTranslation from '../locales/doi/translation.json';
|
|
8
7
|
import gomTranslation from '../locales/gom/translation.json';
|
|
@@ -28,7 +27,6 @@ const activityFetchErrorTranslations: Record<string, string> = {
|
|
|
28
27
|
en: 'Failed to fetch activity data',
|
|
29
28
|
as: 'কাৰ্যকলাপৰ তথ্য আনিবলৈ ব্যৰ্থ',
|
|
30
29
|
bn: 'কার্যকলাপের ডেটা আনতে ব্যর্থ',
|
|
31
|
-
bho: 'गतिविधि डेटा लावे में विफल',
|
|
32
30
|
brx: 'खामानि डाटा लाङो दिहुनायाव असफल',
|
|
33
31
|
doi: 'गतिविधि डेटा लावै मे असफल',
|
|
34
32
|
gom: 'क्रियाकलाप डेटा आणपांत अयशस्वी',
|
|
@@ -68,7 +66,6 @@ const translations: Record<string, Record<string, any>> = {
|
|
|
68
66
|
en: { translation: withSharedActivityErrors('en', enTranslation) },
|
|
69
67
|
as: { translation: withSharedActivityErrors('as', asTranslation) },
|
|
70
68
|
bn: { translation: withSharedActivityErrors('bn', bnTranslation) },
|
|
71
|
-
bho: { translation: withSharedActivityErrors('bho', bhoTranslation) },
|
|
72
69
|
brx: { translation: withSharedActivityErrors('brx', brxTranslation) },
|
|
73
70
|
doi: { translation: withSharedActivityErrors('doi', doiTranslation) },
|
|
74
71
|
gom: { translation: withSharedActivityErrors('gom', gomTranslation) },
|
|
@@ -353,5 +353,6 @@
|
|
|
353
353
|
"couldNotSwitchProfile": "Could not switch to that profile. Please try again.",
|
|
354
354
|
"purposeNotRevocable": "This purpose can't be withdrawn directly.",
|
|
355
355
|
"purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it.",
|
|
356
|
-
"userIdLabel": "User ID: {{id}}"
|
|
356
|
+
"userIdLabel": "User ID: {{id}}",
|
|
357
|
+
"accountMenu": "Account menu"
|
|
357
358
|
}
|