@redacto.io/consent-sdk-react 10.0.0-beta.0 → 10.0.0-beta.2
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 +20 -0
- package/dist/index.d.mts +13 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.js +6 -8
- package/dist/index.mjs +6 -8
- package/dist/privacy-center.d.mts +62 -27
- package/dist/privacy-center.d.ts +62 -27
- package/dist/privacy-center.js +1940 -1365
- package/dist/privacy-center.mjs +1864 -1284
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1 -0
- package/src/RedactoNoticeConsent/api/index.ts +4 -4
- package/src/RedactoNoticeConsent/api/types.ts +3 -2
- package/src/RedactoNoticeConsent/types.ts +1 -1
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +1 -0
- package/src/RedactoNoticeConsentInline/api/index.ts +4 -4
- package/src/RedactoNoticeConsentInline/api/types.ts +2 -1
- package/src/RedactoNoticeConsentInline/types.ts +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +1 -1
- package/src/RedactoPrivacyCenter/api/actions.ts +24 -5
- package/src/RedactoPrivacyCenter/api/client.ts +101 -21
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +64 -35
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +7 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +19 -2
- package/src/RedactoPrivacyCenter/components/Form.tsx +7 -3
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- 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/context/BaseUrlContext.tsx +1 -1
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
- package/src/RedactoPrivacyCenter/context/types.ts +1 -1
- package/src/RedactoPrivacyCenter/lib/types.ts +63 -2
- package/src/RedactoPrivacyCenter/locales/as/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +9 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +9 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +105 -0
- package/tests/Form.test.tsx +53 -7
- package/tests/ManageConsentErrorMapping.test.tsx +85 -0
- package/tests/RevocablePurpose.test.tsx +146 -0
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +171 -0
- package/tests/manageConsentServer.test.ts +38 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
233
|
+
baseUrl?: string;
|
|
234
|
+
ledgerBaseUrl?: string;
|
|
234
235
|
noticeUuid: string;
|
|
235
236
|
purposes: Array<Purpose>;
|
|
236
237
|
declined: boolean;
|
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
175
|
+
baseUrl?: string;
|
|
176
|
+
ledgerBaseUrl?: string;
|
|
176
177
|
noticeUuid: string;
|
|
177
178
|
purposes: Array<Purpose>;
|
|
178
179
|
declined: boolean;
|
|
@@ -12,6 +12,7 @@ import { useAuth } from './context/AuthContext';
|
|
|
12
12
|
import { PCLoader } from './ui';
|
|
13
13
|
import { pcStyles } from './styles/pcStyles';
|
|
14
14
|
import AppShell from './components/AppShell/AppShell';
|
|
15
|
+
import { SelectUserGate } from './components/SelectUserGate';
|
|
15
16
|
|
|
16
17
|
interface PrivacyCenterLayoutProps {
|
|
17
18
|
children: ReactNode;
|
|
@@ -164,11 +165,13 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
|
|
|
164
165
|
|
|
165
166
|
return (
|
|
166
167
|
<OrganizationProvider organization={organization}>
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
{
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
<SelectUserGate>
|
|
169
|
+
<DataAvailabilityProvider>
|
|
170
|
+
<AppShell onBack={onBack}>
|
|
171
|
+
{children}
|
|
172
|
+
</AppShell>
|
|
173
|
+
</DataAvailabilityProvider>
|
|
174
|
+
</SelectUserGate>
|
|
172
175
|
</OrganizationProvider>
|
|
173
176
|
);
|
|
174
177
|
}
|
|
@@ -15,7 +15,7 @@ injectPCStyles();
|
|
|
15
15
|
|
|
16
16
|
interface RedactoPrivacyCenterProps {
|
|
17
17
|
baseUrl: string; // Consent server base URL
|
|
18
|
-
ledgerBaseUrl
|
|
18
|
+
ledgerBaseUrl?: string; // Go ledger base URL — serves user-consents; omit to use the legacy Python privacy-center consent endpoints
|
|
19
19
|
slug: string;
|
|
20
20
|
accessToken: string;
|
|
21
21
|
refreshToken: string;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { BaseRequest } from './client';
|
|
2
2
|
import { isTerminalRefreshError } from './fetcher';
|
|
3
3
|
|
|
4
|
-
export type ConsentApiErrorCode =
|
|
4
|
+
export type ConsentApiErrorCode =
|
|
5
|
+
| 'NOMINATOR_NOT_FOUND'
|
|
6
|
+
| 'NOMINEE_MUST_SPECIFY_NOMINATOR'
|
|
7
|
+
| 'PURPOSE_NOT_REVOCABLE';
|
|
5
8
|
|
|
6
9
|
export class ConsentApiError extends Error {
|
|
7
10
|
code: ConsentApiErrorCode;
|
|
@@ -24,6 +27,7 @@ import type {
|
|
|
24
27
|
GetCaseMessagesParams,
|
|
25
28
|
MessageDocument,
|
|
26
29
|
GetNotificationsServerParams,
|
|
30
|
+
LegacyManageConsentResponse,
|
|
27
31
|
NotificationActionBaseParams,
|
|
28
32
|
NotificationActionResponse,
|
|
29
33
|
NotificationByUuidServerParams,
|
|
@@ -361,10 +365,12 @@ export async function manageConsentServer(params: {
|
|
|
361
365
|
// Privacy center endpoints use JWT Bearer tokens, not session-based auth
|
|
362
366
|
const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
|
|
363
367
|
|
|
364
|
-
//
|
|
365
|
-
// throws otherwise
|
|
366
|
-
// ConsentEventResponse
|
|
367
|
-
|
|
368
|
+
// Ledger mode routes to the Go ledger's submit-consent (partial), which
|
|
369
|
+
// resolves on 2xx and throws otherwise — a resolved call is success (the
|
|
370
|
+
// ledger returns a ConsentEventResponse, no `success` flag). Legacy mode
|
|
371
|
+
// hits the Python manage-consent endpoint, whose unwrapped response
|
|
372
|
+
// carries a `success` flag that must be honored even on 2xx.
|
|
373
|
+
const result = await params.consentClient.manageConsent(
|
|
368
374
|
baseRequest,
|
|
369
375
|
params.purposeId,
|
|
370
376
|
params.contact,
|
|
@@ -377,6 +383,9 @@ export async function manageConsentServer(params: {
|
|
|
377
383
|
params.noticeUuid,
|
|
378
384
|
params.dataElements
|
|
379
385
|
);
|
|
386
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
387
|
+
return Boolean((result as LegacyManageConsentResponse).success);
|
|
388
|
+
}
|
|
380
389
|
return true;
|
|
381
390
|
} catch (error: any) {
|
|
382
391
|
// Normalize nominator-specific errors into stable error codes
|
|
@@ -387,6 +396,16 @@ export async function manageConsentServer(params: {
|
|
|
387
396
|
if (errorMsg.includes('nominee_must_specify_nominator')) {
|
|
388
397
|
throw new ConsentApiError('NOMINEE_MUST_SPECIFY_NOMINATOR', errorMsg);
|
|
389
398
|
}
|
|
399
|
+
// The Python renderer nests the app-level code under `detail.code`
|
|
400
|
+
// (top-level `code` is the numeric HTTP status); a wrapper or the Go ledger
|
|
401
|
+
// may instead surface it at the top level. Read both structured fields
|
|
402
|
+
// rather than string-scanning the message.
|
|
403
|
+
if (
|
|
404
|
+
error?.code === 'purpose_not_revocable' ||
|
|
405
|
+
error?.detail?.code === 'purpose_not_revocable'
|
|
406
|
+
) {
|
|
407
|
+
throw new ConsentApiError('PURPOSE_NOT_REVOCABLE', errorMsg);
|
|
408
|
+
}
|
|
390
409
|
return handleServerActionError(error, `${params.action} consent`);
|
|
391
410
|
}
|
|
392
411
|
}
|
|
@@ -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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
436
|
-
* `partial` submit-consent on the ledger — mirroring
|
|
437
|
-
* relay (`build_consent_management_payload`): the
|
|
438
|
-
* re-submitted with `selected: false` to revoke or
|
|
439
|
-
* regrant/renew, and `partial: true` so the rest of the
|
|
440
|
-
* are left untouched. The principal is resolved from the
|
|
441
|
-
* `contact`/identifiers are sent in the body, and
|
|
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
|
-
//
|
|
551
|
-
|
|
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
|
|
572
|
-
//
|
|
573
|
-
|
|
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:
|
|
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>
|
|
@@ -790,6 +790,13 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
790
790
|
toast.error(t('nominatorNotFound'));
|
|
791
791
|
} else if (error?.code === 'NOMINEE_MUST_SPECIFY_NOMINATOR') {
|
|
792
792
|
toast.error(t('nomineeMustSpecifyNominator'));
|
|
793
|
+
} else if (error?.code === 'PURPOSE_NOT_REVOCABLE') {
|
|
794
|
+
toast.error(
|
|
795
|
+
t(
|
|
796
|
+
'purposeNotRevocableError',
|
|
797
|
+
'This purpose can\'t be withdrawn directly. Please raise a request to remove it.'
|
|
798
|
+
)
|
|
799
|
+
);
|
|
793
800
|
} else {
|
|
794
801
|
toast.error(errorMessages[action]);
|
|
795
802
|
}
|
|
@@ -176,8 +176,14 @@ export const renderModifyButton = (
|
|
|
176
176
|
onModify: (consent: ConsentModalRecord) => void,
|
|
177
177
|
t: TFunction
|
|
178
178
|
) => {
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
// An ACTIVE purpose flagged non-revocable can't be self-service withdrawn.
|
|
180
|
+
// Gate strictly on the explicit `false` so older responses (field absent)
|
|
181
|
+
// stay revocable.
|
|
182
|
+
const isNonRevocable =
|
|
183
|
+
consent.status === ConsentStatusEnum.ACTIVE && consent.revocable === false;
|
|
184
|
+
const isDisabled = consent.status === ConsentStatusEnum.DECLINED || isNonRevocable;
|
|
185
|
+
const nonRevocableMessage = t('purposeNotRevocable', "This purpose can't be withdrawn directly.");
|
|
186
|
+
const button = (
|
|
181
187
|
<button
|
|
182
188
|
type="button"
|
|
183
189
|
style={{
|
|
@@ -194,8 +200,19 @@ export const renderModifyButton = (
|
|
|
194
200
|
if (!isDisabled) onModify(consent);
|
|
195
201
|
}}
|
|
196
202
|
disabled={isDisabled}
|
|
203
|
+
title={isNonRevocable ? nonRevocableMessage : undefined}
|
|
197
204
|
>
|
|
198
205
|
{t('modifyButton')}
|
|
199
206
|
</button>
|
|
200
207
|
);
|
|
208
|
+
|
|
209
|
+
if (isNonRevocable) {
|
|
210
|
+
return (
|
|
211
|
+
<PCTooltip content={nonRevocableMessage} placement="top">
|
|
212
|
+
{button}
|
|
213
|
+
</PCTooltip>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return button;
|
|
201
218
|
};
|
|
@@ -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,
|