@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.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +39 -0
  3. package/dist/index.d.mts +3 -1
  4. package/dist/index.d.ts +3 -1
  5. package/dist/index.js +135 -101
  6. package/dist/index.mjs +135 -101
  7. package/dist/privacy-center.js +461 -778
  8. package/dist/privacy-center.mjs +311 -628
  9. package/package.json +1 -1
  10. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
  11. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
  12. package/src/RedactoNoticeAssisted/api/index.ts +7 -1
  13. package/src/RedactoNoticeAssisted/api/types.ts +2 -0
  14. package/src/RedactoNoticeAssisted/i18n.ts +0 -55
  15. package/src/RedactoNoticeAssisted/types.ts +2 -0
  16. package/src/RedactoNoticeConsent/api/index.ts +5 -66
  17. package/src/RedactoNoticeConsent/api/types.ts +9 -30
  18. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  19. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +59 -1
  20. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +22 -27
  21. package/src/RedactoPrivacyCenter/lib/constants.ts +0 -1
  22. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  23. package/src/RedactoPrivacyCenter/locales/en/translation.json +2 -1
  24. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
  25. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  26. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  27. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  28. package/src/shared/notice-consent-status.ts +190 -0
  29. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -343
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "10.0.0-beta.3",
3
+ "version": "10.0.0-beta.5",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -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
- const url = `${publicScope(baseUrl, organisationUuid, workspaceUuid)}/submit-consent`;
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
- // 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
- };
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 { ChevronDown, Users } from 'lucide-react';
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
- const triggerStyle: CSSProperties = {
60
- display: 'inline-flex',
61
- alignItems: 'center',
62
- gap: 6,
63
- padding: '4px 6px 4px 4px',
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
- <div
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 src={branding.icon_url} name={triggerAvatarName} size="sm" color="info" />
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 name={triggerAvatarName} size="sm" color="info" />
97
+ <PCAvatar
98
+ name={triggerAvatarName}
99
+ size="md"
100
+ color="info"
101
+ style={avatarFillStyle}
102
+ />
107
103
  )}
108
- <ChevronDown size={14} strokeWidth={2} />
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
  }