@redacto.io/consent-sdk-react 9.5.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.
- package/.turbo/turbo-build.log +22 -25
- package/CHANGELOG.md +16 -2
- package/dist/index.d.mts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +116 -11
- package/dist/index.mjs +116 -11
- package/dist/privacy-center.d.mts +76 -19
- package/dist/privacy-center.d.ts +76 -19
- package/dist/privacy-center.js +2022 -1378
- package/dist/privacy-center.mjs +1946 -1297
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +5 -0
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +29 -3
- package/src/RedactoNoticeConsent/api/index.ts +130 -1
- package/src/RedactoNoticeConsent/api/types.ts +33 -0
- package/src/RedactoNoticeConsent/types.ts +1 -0
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +3 -0
- package/src/RedactoNoticeConsentInline/api/index.ts +4 -1
- package/src/RedactoNoticeConsentInline/api/types.ts +1 -0
- package/src/RedactoNoticeConsentInline/types.ts +1 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +3 -1
- package/src/RedactoPrivacyCenter/api/actions.ts +15 -2
- package/src/RedactoPrivacyCenter/api/client.ts +146 -20
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +64 -35
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +12 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +63 -7
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +83 -0
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +193 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +64 -1
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +8 -2
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
- package/src/RedactoPrivacyCenter/context/types.ts +1 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +8 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +9 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +63 -1
- package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/bho/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 +7 -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 +105 -0
- package/tests/Form.test.tsx +58 -7
- package/tests/consentClient.test.ts +171 -0
- package/tests/manageConsentServer.test.ts +38 -0
- package/tests/mocks.ts +1 -0
|
@@ -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";
|
|
@@ -23,9 +25,16 @@ export type BaseRequest = {
|
|
|
23
25
|
/**
|
|
24
26
|
* Consent service client factory.
|
|
25
27
|
* Creates a client instance with a specific base URL.
|
|
26
|
-
* @param baseUrl - Consent server base URL
|
|
28
|
+
* @param baseUrl - Consent server base URL (Python service, e.g. http://host/consent)
|
|
29
|
+
* @param ledgerBaseUrl - Go ledger base URL (e.g. http://host:8090/consent-ledger).
|
|
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.
|
|
27
34
|
*/
|
|
28
|
-
export function createConsentClient(baseUrl: string) {
|
|
35
|
+
export function createConsentClient(baseUrl: string, ledgerBaseUrl?: string) {
|
|
36
|
+
const useLedger = Boolean(ledgerBaseUrl);
|
|
37
|
+
const ledgerBase = (ledgerBaseUrl ?? "").replace(/\/$/, "");
|
|
29
38
|
/**
|
|
30
39
|
* Privacy Center API fetch wrapper that uses Authorization: Bearer header.
|
|
31
40
|
* Privacy center endpoints require JWT tokens in Authorization header, not X-Auth-Token.
|
|
@@ -170,6 +179,13 @@ export function createConsentClient(baseUrl: string) {
|
|
|
170
179
|
return `${baseUrl}/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}/dsar/privacy-center${path}`;
|
|
171
180
|
}
|
|
172
181
|
|
|
182
|
+
function ledgerUrl(baseRequest: BaseRequest, path: string): string {
|
|
183
|
+
if (!useLedger) {
|
|
184
|
+
throw new Error("ledgerUrl called without a configured ledgerBaseUrl");
|
|
185
|
+
}
|
|
186
|
+
return `${ledgerBase}/public/organisations/${baseRequest.organisationUuid}/workspaces/${baseRequest.workspaceUuid}${path}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
function caseUrl(baseRequest: BaseRequest, caseUuid: string, path = ''): string {
|
|
174
190
|
return privacyCenterUrl(baseRequest, `/cases/${caseUuid}${path}`);
|
|
175
191
|
}
|
|
@@ -373,7 +389,10 @@ export function createConsentClient(baseUrl: string) {
|
|
|
373
389
|
if (params.product_uuid) queryParams.append("product_uuid", params.product_uuid);
|
|
374
390
|
if (params.language) queryParams.append("language", params.language);
|
|
375
391
|
const queryString = queryParams.toString();
|
|
376
|
-
const
|
|
392
|
+
const suffix = `/user-consents${queryString ? `?${queryString}` : ""}`;
|
|
393
|
+
const url = useLedger
|
|
394
|
+
? ledgerUrl(baseRequest, suffix)
|
|
395
|
+
: privacyCenterUrl(baseRequest, suffix);
|
|
377
396
|
return privacyCenterApiFetch(url, {
|
|
378
397
|
...options,
|
|
379
398
|
method: "GET",
|
|
@@ -408,10 +427,10 @@ export function createConsentClient(baseUrl: string) {
|
|
|
408
427
|
if (params.language) queryParams.append("language", params.language);
|
|
409
428
|
if (params.nominator_uuid) queryParams.append("nominator_uuid", params.nominator_uuid);
|
|
410
429
|
const queryString = queryParams.toString();
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
430
|
+
const suffix = `/user-consents/${productUuid}/purposes${queryString ? `?${queryString}` : ""}`;
|
|
431
|
+
const url = useLedger
|
|
432
|
+
? ledgerUrl(baseRequest, suffix)
|
|
433
|
+
: privacyCenterUrl(baseRequest, suffix);
|
|
415
434
|
return privacyCenterApiFetch(url, {
|
|
416
435
|
...options,
|
|
417
436
|
method: "GET",
|
|
@@ -421,30 +440,88 @@ export function createConsentClient(baseUrl: string) {
|
|
|
421
440
|
},
|
|
422
441
|
|
|
423
442
|
/**
|
|
424
|
-
* Manage consent (privacy center) - revoke, regrant, or renew
|
|
443
|
+
* Manage consent (privacy center) - revoke, regrant, or renew.
|
|
444
|
+
*
|
|
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.
|
|
425
456
|
*/
|
|
426
457
|
async manageConsent(
|
|
427
458
|
baseRequest: BaseRequest,
|
|
428
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.
|
|
429
462
|
contact: string,
|
|
430
463
|
status: 'revoke' | 'regrant' | 'renew',
|
|
431
464
|
authToken?: string,
|
|
432
465
|
options?: Omit<ApiFetchOptions, "getAuthToken">,
|
|
433
466
|
nominatorContact?: string,
|
|
434
467
|
dataElementUuids?: string[],
|
|
435
|
-
productUuid?: string
|
|
468
|
+
productUuid?: string,
|
|
469
|
+
noticeUuid?: string,
|
|
470
|
+
dataElements?: Array<{ uuid: string; selected: boolean }>
|
|
436
471
|
): Promise<unknown> {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
});
|
|
444
490
|
}
|
|
445
|
-
if (
|
|
446
|
-
|
|
491
|
+
if (!noticeUuid) {
|
|
492
|
+
throw new Error("notice_uuid is required to manage consent via the ledger");
|
|
447
493
|
}
|
|
494
|
+
const purposeSelected = status !== 'revoke';
|
|
495
|
+
const elements = (dataElements ?? []).map((de) => {
|
|
496
|
+
let selected: boolean;
|
|
497
|
+
if (status === 'revoke') {
|
|
498
|
+
selected = false;
|
|
499
|
+
} else if (status === 'regrant') {
|
|
500
|
+
// Only the data elements the user chose to re-grant become selected.
|
|
501
|
+
selected = Array.isArray(dataElementUuids)
|
|
502
|
+
? dataElementUuids.includes(de.uuid)
|
|
503
|
+
: de.selected;
|
|
504
|
+
} else {
|
|
505
|
+
// renew — preserve the currently-granted set.
|
|
506
|
+
selected = de.selected;
|
|
507
|
+
}
|
|
508
|
+
return { uuid: de.uuid, selected };
|
|
509
|
+
});
|
|
510
|
+
const body = {
|
|
511
|
+
notice_uuid: noticeUuid,
|
|
512
|
+
partial: true,
|
|
513
|
+
declined: false,
|
|
514
|
+
select_all_mandatory: false,
|
|
515
|
+
self_declared_adult: false,
|
|
516
|
+
purposes: [
|
|
517
|
+
{
|
|
518
|
+
purpose_uuid: purposeId,
|
|
519
|
+
selected: purposeSelected,
|
|
520
|
+
data_elements: elements,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
};
|
|
524
|
+
const url = ledgerUrl(baseRequest, "/submit-consent");
|
|
448
525
|
return privacyCenterApiFetch(url, {
|
|
449
526
|
...options,
|
|
450
527
|
method: "POST",
|
|
@@ -476,6 +553,44 @@ export function createConsentClient(baseUrl: string) {
|
|
|
476
553
|
});
|
|
477
554
|
},
|
|
478
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
|
+
|
|
479
594
|
/**
|
|
480
595
|
* Get receipts (privacy center) — consent receipts for the authenticated user
|
|
481
596
|
*/
|
|
@@ -504,7 +619,13 @@ export function createConsentClient(baseUrl: string) {
|
|
|
504
619
|
if (params.created_before) queryParams.append("created_before", params.created_before);
|
|
505
620
|
if (params.language) queryParams.append("language", params.language);
|
|
506
621
|
const queryString = queryParams.toString();
|
|
507
|
-
|
|
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);
|
|
508
629
|
return privacyCenterApiFetch<unknown>(url, {
|
|
509
630
|
...options,
|
|
510
631
|
method: "GET",
|
|
@@ -524,7 +645,12 @@ export function createConsentClient(baseUrl: string) {
|
|
|
524
645
|
receiptUuid: string,
|
|
525
646
|
authToken?: string
|
|
526
647
|
): Promise<Blob> {
|
|
527
|
-
|
|
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`);
|
|
528
654
|
const token = resolveAuthToken(authToken, baseRequest);
|
|
529
655
|
const requestHeaders = new Headers();
|
|
530
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:
|
|
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
|
|
166
|
-
{onBack && (
|
|
191
|
+
{/* Action rows */}
|
|
192
|
+
{(canSwitch || onBack) && (
|
|
167
193
|
<div style={{ padding: '6px 8px 8px' }}>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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>
|
|
@@ -754,6 +754,15 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
754
754
|
};
|
|
755
755
|
|
|
756
756
|
try {
|
|
757
|
+
// The ledger's submit-consent needs the notice the purpose was consented
|
|
758
|
+
// under, plus the purpose's data elements with their current selected
|
|
759
|
+
// state — both live on the loaded consent record (selectedRecord).
|
|
760
|
+
const noticeUuid = selectedRecord?.notice_uuid ?? undefined;
|
|
761
|
+
const dataElements = (selectedRecord?.data_elements ?? []).map((el) => ({
|
|
762
|
+
uuid: el.uuid,
|
|
763
|
+
selected: el.selected,
|
|
764
|
+
}));
|
|
765
|
+
|
|
757
766
|
const response = await manageConsent({
|
|
758
767
|
organizationId,
|
|
759
768
|
workspaceId,
|
|
@@ -765,6 +774,8 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
765
774
|
nominatorContact,
|
|
766
775
|
dataElementUuids,
|
|
767
776
|
productUuid,
|
|
777
|
+
noticeUuid,
|
|
778
|
+
dataElements,
|
|
768
779
|
});
|
|
769
780
|
|
|
770
781
|
if (response) {
|
|
@@ -787,7 +798,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
787
798
|
handleCloseModal();
|
|
788
799
|
}
|
|
789
800
|
},
|
|
790
|
-
[organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient]
|
|
801
|
+
[organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord]
|
|
791
802
|
);
|
|
792
803
|
|
|
793
804
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
@@ -10,9 +10,10 @@ import { PiUploadBold } from 'react-icons/pi';
|
|
|
10
10
|
import { toast } from '../utils/toast';
|
|
11
11
|
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
|
|
12
12
|
import { PCModal } from '../ui/PCModal';
|
|
13
|
-
import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
|
|
13
|
+
import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup } from './groupHelpers';
|
|
14
14
|
import { pcStyles } from '../styles/pcStyles';
|
|
15
|
-
import { createCaseRequest, getFormData } from '../lib/api';
|
|
15
|
+
import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
|
|
16
|
+
import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
|
|
16
17
|
import { ensureValidToken } from '../lib/utils';
|
|
17
18
|
import { useAuth } from '../context/AuthContext';
|
|
18
19
|
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
@@ -142,7 +143,7 @@ export default function Form({
|
|
|
142
143
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
143
144
|
const { t } = useTranslation();
|
|
144
145
|
const { accessToken, refreshToken, onError, updateTokens } = useAuth();
|
|
145
|
-
const { consentClient, baseUrl } = useBaseUrl();
|
|
146
|
+
const { consentClient, baseUrl, ledgerBaseUrl } = useBaseUrl();
|
|
146
147
|
const organizationId = organization?.uuid;
|
|
147
148
|
const workspaceId = organization?.workspaces[0]?.uuid;
|
|
148
149
|
|
|
@@ -274,6 +275,57 @@ export default function Form({
|
|
|
274
275
|
[],
|
|
275
276
|
);
|
|
276
277
|
|
|
278
|
+
// Approach A: source the DSAR request-form product picker from the Go
|
|
279
|
+
// ledger's user-consents. After the consent cutover, consents live in the
|
|
280
|
+
// ledger, so Python's form/data returns no product groups; we overlay the
|
|
281
|
+
// ledger's product-grouped consents onto the form data instead. Only the
|
|
282
|
+
// end-user privacy-center path is eligible — popup / case-management resolve
|
|
283
|
+
// an arbitrary contact, which the JWT-scoped ledger endpoint cannot serve,
|
|
284
|
+
// so those keep the Python form/data groups. Returns the form data unchanged
|
|
285
|
+
// when ineligible or when the ledger read fails (graceful fallback to
|
|
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).
|
|
290
|
+
const withLedgerPickerGroups = useCallback(
|
|
291
|
+
async (form: FormData, language?: string): Promise<FormData> => {
|
|
292
|
+
if (!ledgerBaseUrl || isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
|
|
293
|
+
return form;
|
|
294
|
+
}
|
|
295
|
+
const consents = await fetchUserConsents({
|
|
296
|
+
organizationId,
|
|
297
|
+
workspaceId,
|
|
298
|
+
authContext,
|
|
299
|
+
consentClient,
|
|
300
|
+
// No status filter: include every status (active / expired / withdrawn
|
|
301
|
+
// / declined), matching Python form/data, so a DSAR can target products
|
|
302
|
+
// the user no longer actively consents to.
|
|
303
|
+
limit: FORM_PICKER_GROUP_LIMIT,
|
|
304
|
+
nominatedLimit: FORM_PICKER_GROUP_LIMIT,
|
|
305
|
+
language,
|
|
306
|
+
});
|
|
307
|
+
if (!consents) {
|
|
308
|
+
return form;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
...form,
|
|
312
|
+
direct_groups: (consents.direct ?? []).map(consentGroupToFormProductGroup),
|
|
313
|
+
nominated_groups: (consents.nominated ?? []).map(
|
|
314
|
+
consentGroupToFormProductGroup,
|
|
315
|
+
),
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
[
|
|
319
|
+
ledgerBaseUrl,
|
|
320
|
+
isPopup,
|
|
321
|
+
isCaseManagementContext,
|
|
322
|
+
organizationId,
|
|
323
|
+
workspaceId,
|
|
324
|
+
authContext,
|
|
325
|
+
consentClient,
|
|
326
|
+
],
|
|
327
|
+
);
|
|
328
|
+
|
|
277
329
|
const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
|
|
278
330
|
if (!organizationId || !workspaceId || !data) {
|
|
279
331
|
return;
|
|
@@ -314,7 +366,7 @@ export default function Form({
|
|
|
314
366
|
return;
|
|
315
367
|
}
|
|
316
368
|
|
|
317
|
-
setData(translatedFormData);
|
|
369
|
+
setData(await withLedgerPickerGroups(translatedFormData, langValue));
|
|
318
370
|
|
|
319
371
|
if (translatedFormData.grievance_options) {
|
|
320
372
|
const currentGrievanceSelections = { ...selectedGrievances };
|
|
@@ -333,7 +385,7 @@ export default function Form({
|
|
|
333
385
|
} finally {
|
|
334
386
|
setIsTranslating(false);
|
|
335
387
|
}
|
|
336
|
-
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t]);
|
|
388
|
+
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withLedgerPickerGroups]);
|
|
337
389
|
|
|
338
390
|
// Clear product / org-level selections when request type changes.
|
|
339
391
|
// Per-product state (selectedPurposes, selectedCorrectionElements,
|
|
@@ -486,7 +538,11 @@ export default function Form({
|
|
|
486
538
|
}
|
|
487
539
|
}
|
|
488
540
|
|
|
489
|
-
|
|
541
|
+
const pickerData = await withLedgerPickerGroups(
|
|
542
|
+
displayData,
|
|
543
|
+
langParam || undefined,
|
|
544
|
+
);
|
|
545
|
+
setData(pickerData);
|
|
490
546
|
setGrievanceOptions(displayData.grievance_options || []);
|
|
491
547
|
const initialGrievances = (
|
|
492
548
|
displayData.grievance_options || []
|
|
@@ -509,7 +565,7 @@ export default function Form({
|
|
|
509
565
|
};
|
|
510
566
|
|
|
511
567
|
void initializePrivacyCenterForm();
|
|
512
|
-
}, [organizationId, workspaceId, isPopup, authContext, consentClient]);
|
|
568
|
+
}, [organizationId, workspaceId, isPopup, authContext, consentClient, withLedgerPickerGroups]);
|
|
513
569
|
|
|
514
570
|
/**
|
|
515
571
|
* Maps translated correction element names back to English names using UUID lookup.
|
|
@@ -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
|
+
};
|