@redacto.io/consent-sdk-react 10.0.0-beta.0 → 10.0.0-beta.1

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 (60) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.d.mts +13 -6
  4. package/dist/index.d.ts +13 -6
  5. package/dist/index.js +6 -8
  6. package/dist/index.mjs +6 -8
  7. package/dist/privacy-center.d.mts +60 -27
  8. package/dist/privacy-center.d.ts +60 -27
  9. package/dist/privacy-center.js +1872 -1363
  10. package/dist/privacy-center.mjs +1796 -1282
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1 -0
  13. package/src/RedactoNoticeConsent/api/index.ts +4 -4
  14. package/src/RedactoNoticeConsent/api/types.ts +3 -2
  15. package/src/RedactoNoticeConsent/types.ts +1 -1
  16. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +1 -0
  17. package/src/RedactoNoticeConsentInline/api/index.ts +4 -4
  18. package/src/RedactoNoticeConsentInline/api/types.ts +2 -1
  19. package/src/RedactoNoticeConsentInline/types.ts +1 -1
  20. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  21. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +1 -1
  22. package/src/RedactoPrivacyCenter/api/actions.ts +10 -4
  23. package/src/RedactoPrivacyCenter/api/client.ts +101 -21
  24. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +64 -35
  25. package/src/RedactoPrivacyCenter/components/Form.tsx +7 -3
  26. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +83 -0
  27. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +193 -0
  28. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  29. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
  30. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  31. package/src/RedactoPrivacyCenter/context/types.ts +1 -1
  32. package/src/RedactoPrivacyCenter/lib/types.ts +54 -2
  33. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  34. package/src/RedactoPrivacyCenter/locales/bho/translation.json +7 -1
  35. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  36. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  37. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  38. package/src/RedactoPrivacyCenter/locales/en/translation.json +7 -1
  39. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  40. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  41. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  42. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  43. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  44. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  45. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  46. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  47. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  48. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  49. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  50. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  51. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  52. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  53. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  54. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  55. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  56. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +105 -0
  58. package/tests/Form.test.tsx +53 -7
  59. package/tests/consentClient.test.ts +171 -0
  60. package/tests/manageConsentServer.test.ts +38 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "10.0.0-beta.0",
3
+ "version": "10.0.0-beta.1",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -2145,6 +2145,7 @@ export const RedactoNoticeConsent = ({
2145
2145
  if (content) {
2146
2146
  await submitConsentEvent({
2147
2147
  accessToken,
2148
+ baseUrl,
2148
2149
  ledgerBaseUrl,
2149
2150
  noticeUuid: content.notice_uuid,
2150
2151
  purposes: content.purposes.map((purpose) => {
@@ -131,6 +131,7 @@ export const fetchConsentContent = async ({
131
131
 
132
132
  export const submitConsentEvent = async ({
133
133
  accessToken,
134
+ baseUrl,
134
135
  ledgerBaseUrl,
135
136
  noticeUuid,
136
137
  purposes,
@@ -155,10 +156,9 @@ export const submitConsentEvent = async ({
155
156
  throw new Error("Invalid token: missing organization or workspace UUID");
156
157
  }
157
158
 
158
- if (!ledgerBaseUrl) {
159
- throw new Error("ledgerBaseUrl is required to submit consent");
160
- }
161
- const apiBaseUrl = ledgerBaseUrl;
159
+ // Fall back to the consent server when no ledger is configured, so existing
160
+ // integrations that only pass baseUrl remain backwards compatible.
161
+ const apiBaseUrl = ledgerBaseUrl || baseUrl || BASE_URL;
162
162
 
163
163
  const validatedPurposes = purposes.map((purpose) => {
164
164
  if (!purpose.uuid) {
@@ -46,7 +46,7 @@ export type NoticeConsentStatusResponse = {
46
46
 
47
47
  export type FetchNoticeConsentStatusParams = {
48
48
  accessToken: string;
49
- ledgerBaseUrl: string;
49
+ ledgerBaseUrl?: string;
50
50
  noticeUuid: string;
51
51
  signal?: AbortSignal;
52
52
  };
@@ -230,7 +230,8 @@ export type Purpose = {
230
230
 
231
231
  export type SubmitConsentEventParams = {
232
232
  accessToken: string;
233
- ledgerBaseUrl: string;
233
+ baseUrl?: string;
234
+ ledgerBaseUrl?: string;
234
235
  noticeUuid: string;
235
236
  purposes: Array<Purpose>;
236
237
  declined: boolean;
@@ -5,7 +5,7 @@ export type Props = Readonly<{
5
5
  accessToken: string;
6
6
  refreshToken: string;
7
7
  baseUrl?: string;
8
- ledgerBaseUrl: string;
8
+ ledgerBaseUrl?: string;
9
9
  settings?: Partial<Settings>;
10
10
  language?: string;
11
11
  blockUI?: boolean;
@@ -320,6 +320,7 @@ const RedactoConsentInlineComponent = ({
320
320
  org_uuid,
321
321
  workspace_uuid,
322
322
  accessToken: tokenToUse,
323
+ baseUrl,
323
324
  ledgerBaseUrl,
324
325
  noticeUuid: content.notice_uuid,
325
326
  purposes: content.purposes.map((purpose) => {
@@ -47,16 +47,16 @@ export const submitConsentEvent = async ({
47
47
  org_uuid,
48
48
  workspace_uuid,
49
49
  accessToken,
50
+ baseUrl,
50
51
  ledgerBaseUrl,
51
52
  noticeUuid,
52
53
  purposes,
53
54
  declined,
54
55
  meta_data,
55
56
  }: SubmitConsentEventParams): Promise<void> => {
56
- if (!ledgerBaseUrl) {
57
- throw new Error("ledgerBaseUrl is required to submit consent");
58
- }
59
- const apiBaseUrl = ledgerBaseUrl;
57
+ // Fall back to the consent server when no ledger is configured, so existing
58
+ // integrations that only pass baseUrl remain backwards compatible.
59
+ const apiBaseUrl = ledgerBaseUrl || baseUrl || BASE_URL;
60
60
 
61
61
  const payload: ConsentEventPayload = {
62
62
  notice_uuid: noticeUuid,
@@ -172,7 +172,8 @@ export type SubmitConsentEventParams = {
172
172
  org_uuid: string;
173
173
  workspace_uuid: string;
174
174
  accessToken: string;
175
- ledgerBaseUrl: string;
175
+ baseUrl?: string;
176
+ ledgerBaseUrl?: string;
176
177
  noticeUuid: string;
177
178
  purposes: Array<Purpose>;
178
179
  declined: boolean;
@@ -8,7 +8,7 @@ export type Props = Readonly<{
8
8
  accessToken?: string;
9
9
  refreshToken?: string;
10
10
  baseUrl?: string;
11
- ledgerBaseUrl: string;
11
+ ledgerBaseUrl?: string;
12
12
  settings?: Partial<Settings>;
13
13
  language?: string;
14
14
  onAccept?: () => void;
@@ -12,6 +12,7 @@ import { useAuth } from './context/AuthContext';
12
12
  import { PCLoader } from './ui';
13
13
  import { pcStyles } from './styles/pcStyles';
14
14
  import AppShell from './components/AppShell/AppShell';
15
+ import { SelectUserGate } from './components/SelectUserGate';
15
16
 
16
17
  interface PrivacyCenterLayoutProps {
17
18
  children: ReactNode;
@@ -164,11 +165,13 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
164
165
 
165
166
  return (
166
167
  <OrganizationProvider organization={organization}>
167
- <DataAvailabilityProvider>
168
- <AppShell onBack={onBack}>
169
- {children}
170
- </AppShell>
171
- </DataAvailabilityProvider>
168
+ <SelectUserGate>
169
+ <DataAvailabilityProvider>
170
+ <AppShell onBack={onBack}>
171
+ {children}
172
+ </AppShell>
173
+ </DataAvailabilityProvider>
174
+ </SelectUserGate>
172
175
  </OrganizationProvider>
173
176
  );
174
177
  }
@@ -15,7 +15,7 @@ injectPCStyles();
15
15
 
16
16
  interface RedactoPrivacyCenterProps {
17
17
  baseUrl: string; // Consent server base URL
18
- ledgerBaseUrl: string; // Go ledger base URL — serves user-consents
18
+ ledgerBaseUrl?: string; // Go ledger base URL — serves user-consents; omit to use the legacy Python privacy-center consent endpoints
19
19
  slug: string;
20
20
  accessToken: string;
21
21
  refreshToken: string;
@@ -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,
@@ -361,10 +362,12 @@ export async function manageConsentServer(params: {
361
362
  // Privacy center endpoints use JWT Bearer tokens, not session-based auth
362
363
  const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
363
364
 
364
- // Routes to the Go ledger's submit-consent (partial); resolves on 2xx and
365
- // throws otherwise, so a resolved call is success. The ledger returns a
366
- // ConsentEventResponse (no `success` flag), unlike the old Python endpoint.
367
- await params.consentClient.manageConsent(
365
+ // Ledger mode routes to the Go ledger's submit-consent (partial), which
366
+ // resolves on 2xx and throws otherwise — a resolved call is success (the
367
+ // ledger returns a ConsentEventResponse, no `success` flag). Legacy mode
368
+ // hits the Python manage-consent endpoint, whose unwrapped response
369
+ // carries a `success` flag that must be honored even on 2xx.
370
+ const result = await params.consentClient.manageConsent(
368
371
  baseRequest,
369
372
  params.purposeId,
370
373
  params.contact,
@@ -377,6 +380,9 @@ export async function manageConsentServer(params: {
377
380
  params.noticeUuid,
378
381
  params.dataElements
379
382
  );
383
+ if (result && typeof result === 'object' && 'success' in result) {
384
+ return Boolean((result as LegacyManageConsentResponse).success);
385
+ }
380
386
  return true;
381
387
  } catch (error: any) {
382
388
  // Normalize nominator-specific errors into stable error codes
@@ -2,11 +2,13 @@ import { apiFetch, ApiError } from "./fetcher";
2
2
  import type { ApiFetchOptions, ApiResponse } from "./fetcher";
3
3
  import type {
4
4
  CaseMessage,
5
+ IdentityListResponse,
5
6
  MessageDocument,
6
7
  NotificationActionResponse,
7
8
  NotificationListResponse,
8
9
  NotificationQueryParams,
9
10
  NotificationSummary,
11
+ SelectPrincipalResponse,
10
12
  UnreadCountResponse,
11
13
  UploadDocumentResponse,
12
14
  } from "../lib/types";
@@ -25,13 +27,14 @@ export type BaseRequest = {
25
27
  * Creates a client instance with a specific base URL.
26
28
  * @param baseUrl - Consent server base URL (Python service, e.g. http://host/consent)
27
29
  * @param ledgerBaseUrl - Go ledger base URL (e.g. http://host:8090/consent-ledger).
28
- * Used for user-consents reads which are served from the ledger's `/public/...` paths.
30
+ * Used for user-consents reads/writes served from the ledger's `/public/...` paths.
31
+ * When omitted, those calls fall back to the legacy Python
32
+ * `/dsar/privacy-center` endpoints and payloads (pre-ledger behavior), so
33
+ * integrations without a deployed ledger keep working.
29
34
  */
30
- export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
31
- if (!ledgerBaseUrl) {
32
- throw new Error("ledgerBaseUrl is required to create the consent client");
33
- }
34
- const ledgerBase = ledgerBaseUrl.replace(/\/$/, "");
35
+ export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
36
+ const useLedger = Boolean(ledgerBaseUrl);
37
+ const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
35
38
  /**
36
39
  * Privacy Center API fetch wrapper that uses Authorization: Bearer header.
37
40
  * Privacy center endpoints require JWT tokens in Authorization header, not X-Auth-Token.
@@ -177,6 +180,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
177
180
  }
178
181
 
179
182
  function ledgerUrl(baseRequest: BaseRequest, path: string): string {
183
+ if (!useLedger) {
184
+ throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
185
+ }
180
186
  return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
181
187
  }
182
188
 
@@ -384,7 +390,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
384
390
  if (params.language) queryParams.append("language", params.language);
385
391
  const queryString = queryParams.toString();
386
392
  const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
387
- const url = ledgerUrl(baseRequest, suffix);
393
+ const url = useLedger
394
+ ? ledgerUrl(baseRequest, suffix)
395
+ : privacyCenterUrl(baseRequest, suffix);
388
396
  return privacyCenterApiFetch(url, {
389
397
  ...options,
390
398
  method: "GET",
@@ -420,7 +428,9 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
420
428
  if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
421
429
  const queryString = queryParams.toString();
422
430
  const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
423
- const url = ledgerUrl(baseRequest, suffix);
431
+ const url = useLedger
432
+ ? ledgerUrl(baseRequest, suffix)
433
+ : privacyCenterUrl(baseRequest, suffix);
424
434
  return privacyCenterApiFetch(url, {
425
435
  ...options,
426
436
  method: "GET",
@@ -432,18 +442,23 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
432
442
  /**
433
443
  * Manage consent (privacy center) - revoke, regrant, or renew.
434
444
  *
435
- * The Go ledger has no manage-consent route, so this is expressed as a
436
- * `partial` submit-consent on the ledger — mirroring the Python dual-write
437
- * relay (`build_consent_management_payload`): the affected purpose is
438
- * re-submitted with `selected: false` to revoke or `selected: true` to
439
- * regrant/renew, and `partial: true` so the rest of the notice's purposes
440
- * are left untouched. The principal is resolved from the JWT, so no
441
- * `contact`/identifiers are sent in the body, and `consent_event_uuid` is
442
- * omitted so the ledger mints a fresh event.
445
+ * The Go ledger has no manage-consent route, so with a ledger configured
446
+ * this is expressed as a `partial` submit-consent on the ledger — mirroring
447
+ * the Python dual-write relay (`build_consent_management_payload`): the
448
+ * affected purpose is re-submitted with `selected: false` to revoke or
449
+ * `selected: true` to regrant/renew, and `partial: true` so the rest of the
450
+ * notice's purposes are left untouched. The principal is resolved from the
451
+ * JWT, so no `contact`/identifiers are sent in the body, and
452
+ * `consent_event_uuid` is omitted so the ledger mints a fresh event.
453
+ *
454
+ * Without a ledger, the legacy Python `/manage-consent?status=` endpoint is
455
+ * used with its `{ purpose_uuid, contact, ... }` payload.
443
456
  */
444
457
  async manageConsent(
445
458
  baseRequest: BaseRequest,
446
459
  purposeId: string,
460
+ // Sent to the legacy Python endpoint only; the ledger resolves the
461
+ // principal from the JWT, so no identifiers are sent in ledger mode.
447
462
  contact: string,
448
463
  status: 'revoke' | 'regrant' | 'renew',
449
464
  authToken?: string,
@@ -454,6 +469,25 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
454
469
  noticeUuid?: string,
455
470
  dataElements?: Array<{ uuid: string; selected: boolean }>
456
471
  ): Promise<unknown> {
472
+ if (!useLedger) {
473
+ const url = privacyCenterUrl(baseRequest, `/manage-consent?status=${status}`);
474
+ const body: Record<string, unknown> = { purpose_uuid: purposeId, contact };
475
+ if (nominatorContact) {
476
+ body.nominator_contact = nominatorContact;
477
+ }
478
+ if (Array.isArray(dataElementUuids) && dataElementUuids.length > 0) {
479
+ body.data_element_uuids = dataElementUuids;
480
+ }
481
+ if (productUuid) {
482
+ body.product_uuid = productUuid;
483
+ }
484
+ return privacyCenterApiFetch(url, {
485
+ ...options,
486
+ method: "POST",
487
+ body: JSON.stringify(body),
488
+ authToken: resolveAuthToken(authToken, baseRequest),
489
+ });
490
+ }
457
491
  if (!noticeUuid) {
458
492
  throw new Error("notice_uuid is required to manage consent via the ledger");
459
493
  }
@@ -519,6 +553,44 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
519
553
  });
520
554
  },
521
555
 
556
+ /**
557
+ * List all data principals that share this session's contact
558
+ * (shared-contact profile picker). Identity is derived server-side from the
559
+ * JWT — the caller sends nothing but its bearer token.
560
+ */
561
+ async listIdentities(
562
+ baseRequest: BaseRequest,
563
+ authToken?: string,
564
+ options?: Omit<ApiFetchOptions, "getAuthToken">
565
+ ): Promise<IdentityListResponse> {
566
+ const url = privacyCenterUrl(baseRequest, '/identities');
567
+ return privacyCenterApiFetch<IdentityListResponse>(url, {
568
+ ...options,
569
+ cache: 'no-store',
570
+ authToken: resolveAuthToken(authToken, baseRequest),
571
+ });
572
+ },
573
+
574
+ /**
575
+ * Re-scope the session to a chosen principal uuid (must be one of the
576
+ * principals sharing this session's contact). Returns fresh access/refresh
577
+ * tokens scoped to that principal.
578
+ */
579
+ async selectPrincipal(
580
+ baseRequest: BaseRequest,
581
+ principalUuid: string,
582
+ authToken?: string,
583
+ options?: Omit<ApiFetchOptions, "getAuthToken">
584
+ ): Promise<SelectPrincipalResponse> {
585
+ const url = privacyCenterUrl(baseRequest, '/identities/select');
586
+ return privacyCenterApiFetch<SelectPrincipalResponse>(url, {
587
+ ...options,
588
+ method: 'POST',
589
+ body: JSON.stringify({ uuid: principalUuid }),
590
+ authToken: resolveAuthToken(authToken, baseRequest),
591
+ });
592
+ },
593
+
522
594
  /**
523
595
  * Get receipts (privacy center) — consent receipts for the authenticated user
524
596
  */
@@ -547,8 +619,13 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
547
619
  if (params.created_before) queryParams.append("created_before", params.created_before);
548
620
  if (params.language) queryParams.append("language", params.language);
549
621
  const queryString = queryParams.toString();
550
- // Receipts are served by the Go ledger (paginated list under /public/...).
551
- const url = ledgerUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
622
+ // With a ledger configured, receipts are served by the Go ledger
623
+ // (paginated list under /public/...); otherwise the legacy Python
624
+ // privacy-center list is used. normalizeReceipts tolerates both envelopes.
625
+ const receiptsSuffix = `/receipts${queryString ? `?${queryString}` : ""}`;
626
+ const url = useLedger
627
+ ? ledgerUrl(baseRequest, receiptsSuffix)
628
+ : privacyCenterUrl(baseRequest, receiptsSuffix);
552
629
  return privacyCenterApiFetch<unknown>(url, {
553
630
  ...options,
554
631
  method: "GET",
@@ -568,9 +645,12 @@ export function createConsentClient(baseUrl: string, ledgerBaseUrl: string) {
568
645
  receiptUuid: string,
569
646
  authToken?: string
570
647
  ): Promise<Blob> {
571
- // The ledger serves the receipt PDF at /receipts/{uuid}/download; the
572
- // receipt_uuid comes from the ledger list above, so both must be the ledger.
573
- const url = ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`);
648
+ // The ledger serves the receipt PDF at /receipts/{uuid}/download, the
649
+ // legacy Python service at /receipts/{uuid}/pdf. The receiptUuid always
650
+ // comes from the same-mode list above, so list and download stay consistent.
651
+ const url = useLedger
652
+ ? ledgerUrl(baseRequest, `/receipts/${receiptUuid}/download`)
653
+ : privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
574
654
  const token = resolveAuthToken(authToken, baseRequest);
575
655
  const requestHeaders = new Headers();
576
656
  requestHeaders.set("Accept", "application/pdf");
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useState, type CSSProperties } from 'react';
2
- import { ChevronDown } from 'lucide-react';
2
+ import { ChevronDown, Users } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
3
4
  import { PCAvatar, PCPopover } from '../../ui';
4
5
  import { useBranding } from '../../context/BrandingContext';
5
6
  import { useOrganization } from '../../context/OrganizationContext';
7
+ import { useProfileSwitch } from '../../context/ProfileSwitchContext';
6
8
  import { getVar } from '../../lib/utils';
7
9
  import { usePrivacyCenterExitAction } from './usePrivacyCenterExitAction';
8
10
 
@@ -30,6 +32,7 @@ function initialsFromEmail(email: string): string {
30
32
  }
31
33
 
32
34
  export function UserMenu({ onBack }: UserMenuProps) {
35
+ const { t } = useTranslation();
33
36
  const branding = useBranding();
34
37
  const { organization } = useOrganization();
35
38
  const [email, setEmail] = useState('');
@@ -40,6 +43,7 @@ export function UserMenu({ onBack }: UserMenuProps) {
40
43
 
41
44
  const { handleAction, label: actionLabel, Icon: ActionIcon } =
42
45
  usePrivacyCenterExitAction(onBack);
46
+ const { canSwitch, switchProfile } = useProfileSwitch();
43
47
 
44
48
  const displayName = branding?.brand_name || organization?.name || '';
45
49
  const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
@@ -57,6 +61,25 @@ export function UserMenu({ onBack }: UserMenuProps) {
57
61
  transition: 'background 150ms ease, border-color 150ms ease, box-shadow 150ms ease',
58
62
  };
59
63
 
64
+ // Shared style for the popover's action rows (switch profile, back/sign out).
65
+ const menuItemStyle: CSSProperties = {
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ gap: 10,
69
+ width: '100%',
70
+ padding: '10px 14px',
71
+ border: 'none',
72
+ borderRadius: 8,
73
+ background: 'transparent',
74
+ color: getVar('--pc-text-primary'),
75
+ cursor: 'pointer',
76
+ fontFamily: 'inherit',
77
+ fontSize: 14,
78
+ fontWeight: 500,
79
+ textAlign: 'left',
80
+ transition: 'background 150ms ease',
81
+ };
82
+
60
83
  const trigger = (
61
84
  <div
62
85
  style={triggerStyle}
@@ -139,7 +162,10 @@ export function UserMenu({ onBack }: UserMenuProps) {
139
162
  display: 'flex',
140
163
  alignItems: 'center',
141
164
  gap: 10,
142
- borderBottom: onBack ? `1px solid ${getVar('--pc-border-color')}` : 'none',
165
+ borderBottom:
166
+ onBack || canSwitch
167
+ ? `1px solid ${getVar('--pc-border-color')}`
168
+ : 'none',
143
169
  }}
144
170
  >
145
171
  <PCAvatar name={initialsFromEmail(email)} size="sm" color="info" />
@@ -162,40 +188,43 @@ export function UserMenu({ onBack }: UserMenuProps) {
162
188
  </div>
163
189
  )}
164
190
 
165
- {/* Action row */}
166
- {onBack && (
191
+ {/* Action rows */}
192
+ {(canSwitch || onBack) && (
167
193
  <div style={{ padding: '6px 8px 8px' }}>
168
- <button
169
- type="button"
170
- role="menuitem"
171
- onClick={handleAction}
172
- style={{
173
- display: 'flex',
174
- alignItems: 'center',
175
- gap: 10,
176
- width: '100%',
177
- padding: '10px 14px',
178
- border: 'none',
179
- borderRadius: 8,
180
- background: 'transparent',
181
- color: getVar('--pc-text-primary'),
182
- cursor: 'pointer',
183
- fontFamily: 'inherit',
184
- fontSize: 14,
185
- fontWeight: 500,
186
- textAlign: 'left',
187
- transition: 'background 150ms ease',
188
- }}
189
- onMouseEnter={(e) => {
190
- e.currentTarget.style.background = getVar('--pc-bg-hover');
191
- }}
192
- onMouseLeave={(e) => {
193
- e.currentTarget.style.background = 'transparent';
194
- }}
195
- >
196
- <ActionIcon size={16} strokeWidth={2} />
197
- <span>{actionLabel}</span>
198
- </button>
194
+ {canSwitch && (
195
+ <button
196
+ type="button"
197
+ role="menuitem"
198
+ onClick={switchProfile}
199
+ style={menuItemStyle}
200
+ onMouseEnter={(e) => {
201
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
202
+ }}
203
+ onMouseLeave={(e) => {
204
+ e.currentTarget.style.background = 'transparent';
205
+ }}
206
+ >
207
+ <Users size={16} strokeWidth={2} />
208
+ <span>{t('switchProfile')}</span>
209
+ </button>
210
+ )}
211
+ {onBack && (
212
+ <button
213
+ type="button"
214
+ role="menuitem"
215
+ onClick={handleAction}
216
+ style={menuItemStyle}
217
+ onMouseEnter={(e) => {
218
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
219
+ }}
220
+ onMouseLeave={(e) => {
221
+ e.currentTarget.style.background = 'transparent';
222
+ }}
223
+ >
224
+ <ActionIcon size={16} strokeWidth={2} />
225
+ <span>{actionLabel}</span>
226
+ </button>
227
+ )}
199
228
  </div>
200
229
  )}
201
230
  </div>
@@ -143,7 +143,7 @@ export default function Form({
143
143
  const [isSubmitted, setIsSubmitted] = useState(false);
144
144
  const { t } = useTranslation();
145
145
  const { accessToken, refreshToken, onError, updateTokens } = useAuth();
146
- const { consentClient, baseUrl } = useBaseUrl();
146
+ const { consentClient, baseUrl, ledgerBaseUrl } = useBaseUrl();
147
147
  const organizationId = organization?.uuid;
148
148
  const workspaceId = organization?.workspaces[0]?.uuid;
149
149
 
@@ -283,10 +283,13 @@ export default function Form({
283
283
  // an arbitrary contact, which the JWT-scoped ledger endpoint cannot serve,
284
284
  // so those keep the Python form/data groups. Returns the form data unchanged
285
285
  // when ineligible or when the ledger read fails (graceful fallback to
286
- // whatever groups form/data supplied).
286
+ // whatever groups form/data supplied). Without a configured ledgerBaseUrl,
287
+ // consents still live in Python and form/data groups are authoritative, so
288
+ // the overlay must not run (fetchUserConsents would return the legacy Python
289
+ // shape, which consentGroupToFormProductGroup does not map).
287
290
  const withLedgerPickerGroups = useCallback(
288
291
  async (form: FormData, language?: string): Promise<FormData> => {
289
- if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
292
+ if (!ledgerBaseUrl || isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
290
293
  return form;
291
294
  }
292
295
  const consents = await fetchUserConsents({
@@ -313,6 +316,7 @@ export default function Form({
313
316
  };
314
317
  },
315
318
  [
319
+ ledgerBaseUrl,
316
320
  isPopup,
317
321
  isCaseManagementContext,
318
322
  organizationId,
@@ -0,0 +1,83 @@
1
+ import { ChevronRight } from 'lucide-react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PCText, PCTitle } from '../ui';
4
+ import type { SelectUserProps } from '../lib/types';
5
+
6
+ /**
7
+ * "Choose your profile" picker, shown when the signed-in contact maps to more
8
+ * than one data principal (shared contact). Selecting one re-scopes the session
9
+ * to that principal uuid. Purely presentational — SelectUserGate owns the fetch
10
+ * and the re-scope. Structural styles live in styles/injectStyles.ts
11
+ * (.pc-select-user*).
12
+ */
13
+ export const SelectUser = ({
14
+ email,
15
+ candidates,
16
+ onSelect,
17
+ busyOrgUserId,
18
+ error,
19
+ }: SelectUserProps) => {
20
+ const { t } = useTranslation();
21
+ const subtitle = email
22
+ ? t('emailLinkedToMultipleProfiles', { email })
23
+ : t('contactLinkedToMultipleProfiles');
24
+
25
+ // A candidate with no uuid can't be selected (it's the value we submit),
26
+ // so drop it rather than render an unclickable row.
27
+ const selectable = candidates.filter((candidate) => candidate.uuid);
28
+
29
+ return (
30
+ <div className="pc-select-user">
31
+ <div className="pc-select-user__header">
32
+ <PCTitle as="h2">{t('chooseYourProfile')}</PCTitle>
33
+ <PCText as="p" size="base" color="secondary" align="center">
34
+ {subtitle}
35
+ </PCText>
36
+ </div>
37
+
38
+ <div className="pc-select-user__list">
39
+ {selectable.map((candidate) => {
40
+ const principalUuid = candidate.uuid || '';
41
+ const primaryLabel =
42
+ candidate.primary_email || candidate.primary_mobile || candidate.uuid;
43
+ const secondaryLabel =
44
+ candidate.primary_mobile || candidate.primary_email || '';
45
+ const isBusy = busyOrgUserId === principalUuid;
46
+ return (
47
+ <button
48
+ key={candidate.uuid}
49
+ type="button"
50
+ className="pc-select-user__item"
51
+ onClick={() => onSelect(principalUuid)}
52
+ disabled={!principalUuid || busyOrgUserId !== null}
53
+ >
54
+ <span className="pc-select-user__item-text">
55
+ <span className="pc-select-user__item-primary">
56
+ {primaryLabel}
57
+ </span>
58
+ {secondaryLabel ? (
59
+ <span className="pc-select-user__item-secondary">
60
+ {secondaryLabel}
61
+ </span>
62
+ ) : null}
63
+ </span>
64
+ {isBusy ? (
65
+ <span className="pc-select-user__item-status">
66
+ {t('signingInEllipsis')}
67
+ </span>
68
+ ) : (
69
+ <ChevronRight size={18} className="pc-select-user__item-chevron" />
70
+ )}
71
+ </button>
72
+ );
73
+ })}
74
+ </div>
75
+
76
+ {error ? (
77
+ <p className="pc-select-user__error" role="alert">
78
+ {error}
79
+ </p>
80
+ ) : null}
81
+ </div>
82
+ );
83
+ };