@redacto.io/consent-sdk-react 10.0.0-beta.1 → 10.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +109 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +32 -4
- package/dist/index.d.ts +32 -4
- package/dist/index.js +340 -230
- package/dist/index.mjs +339 -231
- package/dist/privacy-center.d.mts +55 -12
- package/dist/privacy-center.d.ts +55 -12
- package/dist/privacy-center.js +848 -950
- package/dist/privacy-center.mjs +698 -802
- package/package.json +1 -1
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
- package/src/RedactoNoticeAssisted/api/index.ts +7 -1
- package/src/RedactoNoticeAssisted/api/types.ts +2 -0
- package/src/RedactoNoticeAssisted/i18n.ts +0 -55
- package/src/RedactoNoticeAssisted/types.ts +2 -0
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +74 -33
- package/src/RedactoNoticeConsent/api/index.ts +88 -175
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +58 -43
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +28 -2
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +85 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +10 -1
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +2 -0
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
- package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
- package/src/RedactoPrivacyCenter/api/client.ts +122 -25
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +47 -45
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +25 -13
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -6
- package/src/RedactoPrivacyCenter/components/constant.ts +3 -3
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/types.ts +38 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +25 -1
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/en/translation.json +4 -3
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -3
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
- package/src/shared/notice-consent-status.ts +190 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +142 -5
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +46 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -340
|
@@ -10,10 +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, consentGroupToFormProductGroup } from './groupHelpers';
|
|
13
|
+
import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup, legacyFlatGroup } from './groupHelpers';
|
|
14
14
|
import { pcStyles } from '../styles/pcStyles';
|
|
15
15
|
import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
|
|
16
|
-
import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
|
|
16
|
+
import { FORM_PICKER_GROUP_LIMIT, LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
|
|
17
17
|
import { ensureValidToken } from '../lib/utils';
|
|
18
18
|
import { useAuth } from '../context/AuthContext';
|
|
19
19
|
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
@@ -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
|
|
146
|
+
const { consentClient, baseUrl } = useBaseUrl();
|
|
147
147
|
const organizationId = organization?.uuid;
|
|
148
148
|
const workspaceId = organization?.workspaces[0]?.uuid;
|
|
149
149
|
|
|
@@ -235,12 +235,31 @@ export default function Form({
|
|
|
235
235
|
|
|
236
236
|
// Union of direct + nominated product groups exposed by the backend.
|
|
237
237
|
// Drives both the product-cards step and the per-product picker beneath.
|
|
238
|
+
// When the backend exposes no groups at all but did return the legacy flat
|
|
239
|
+
// `purposes` list (pre-grouping Python deployments, and the popup /
|
|
240
|
+
// case-management contexts that cannot read user-consents), fall back to a
|
|
241
|
+
// single synthetic group so the purposes stay reachable instead of the
|
|
242
|
+
// picker claiming the principal has no consents.
|
|
238
243
|
const allGroups = useMemo<FormProductGroup[]>(() => {
|
|
239
244
|
if (!data) {
|
|
240
245
|
return [];
|
|
241
246
|
}
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
const groups = [
|
|
248
|
+
...(data.direct_groups ?? []),
|
|
249
|
+
...(data.nominated_groups ?? []),
|
|
250
|
+
];
|
|
251
|
+
if (groups.length > 0) {
|
|
252
|
+
return groups;
|
|
253
|
+
}
|
|
254
|
+
const flatGroup = legacyFlatGroup(data, t('yourData'));
|
|
255
|
+
// `buildGroupPurposes` is what the panel renders from — it drops purposes
|
|
256
|
+
// with no data elements, so a flat list that survives nothing must keep
|
|
257
|
+
// showing the empty state rather than an unusable panel.
|
|
258
|
+
if (buildGroupPurposes(flatGroup).length === 0) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
return [flatGroup];
|
|
262
|
+
}, [data, t]);
|
|
244
263
|
|
|
245
264
|
// TECH-3712 UX: when the DP has exactly one consent group total
|
|
246
265
|
// (direct + nominated combined), the product-selection step is friction —
|
|
@@ -275,21 +294,22 @@ export default function Form({
|
|
|
275
294
|
[],
|
|
276
295
|
);
|
|
277
296
|
|
|
278
|
-
// Approach A: source the DSAR request-form product picker from
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
297
|
+
// Approach A: source the DSAR request-form product picker from user-consents
|
|
298
|
+
// rather than from Python's form/data, which stopped emitting product groups
|
|
299
|
+
// after the consent cutover. `consentClient.getUserConsents` serves the same
|
|
300
|
+
// grouped payload from either backend — the Go ledger's
|
|
301
|
+
// `/public/.../user-consents` when a ledgerBaseUrl is configured, Python's
|
|
302
|
+
// `/dsar/privacy-center/user-consents` otherwise (the same source the
|
|
303
|
+
// Manage Consent view reads in both modes) — so the overlay runs regardless
|
|
304
|
+
// of which backend the SDK points at. Only the end-user privacy-center path
|
|
305
|
+
// is eligible: popup / case-management resolve an arbitrary contact, which
|
|
306
|
+
// the JWT-scoped user-consents endpoint cannot serve, so those keep the
|
|
307
|
+
// form/data groups. Returns the form data unchanged when ineligible, when
|
|
308
|
+
// the read fails, or when it comes back with no groups at all — an empty
|
|
309
|
+
// result must never overwrite groups form/data did supply.
|
|
310
|
+
const withUserConsentPickerGroups = useCallback(
|
|
291
311
|
async (form: FormData, language?: string): Promise<FormData> => {
|
|
292
|
-
if (
|
|
312
|
+
if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
|
|
293
313
|
return form;
|
|
294
314
|
}
|
|
295
315
|
const consents = await fetchUserConsents({
|
|
@@ -304,19 +324,18 @@ export default function Form({
|
|
|
304
324
|
nominatedLimit: FORM_PICKER_GROUP_LIMIT,
|
|
305
325
|
language,
|
|
306
326
|
});
|
|
307
|
-
|
|
327
|
+
const direct = consents?.direct ?? [];
|
|
328
|
+
const nominated = consents?.nominated ?? [];
|
|
329
|
+
if (direct.length === 0 && nominated.length === 0) {
|
|
308
330
|
return form;
|
|
309
331
|
}
|
|
310
332
|
return {
|
|
311
333
|
...form,
|
|
312
|
-
direct_groups:
|
|
313
|
-
nominated_groups:
|
|
314
|
-
consentGroupToFormProductGroup,
|
|
315
|
-
),
|
|
334
|
+
direct_groups: direct.map(consentGroupToFormProductGroup),
|
|
335
|
+
nominated_groups: nominated.map(consentGroupToFormProductGroup),
|
|
316
336
|
};
|
|
317
337
|
},
|
|
318
338
|
[
|
|
319
|
-
ledgerBaseUrl,
|
|
320
339
|
isPopup,
|
|
321
340
|
isCaseManagementContext,
|
|
322
341
|
organizationId,
|
|
@@ -366,7 +385,7 @@ export default function Form({
|
|
|
366
385
|
return;
|
|
367
386
|
}
|
|
368
387
|
|
|
369
|
-
setData(await
|
|
388
|
+
setData(await withUserConsentPickerGroups(translatedFormData, langValue));
|
|
370
389
|
|
|
371
390
|
if (translatedFormData.grievance_options) {
|
|
372
391
|
const currentGrievanceSelections = { ...selectedGrievances };
|
|
@@ -385,7 +404,7 @@ export default function Form({
|
|
|
385
404
|
} finally {
|
|
386
405
|
setIsTranslating(false);
|
|
387
406
|
}
|
|
388
|
-
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t,
|
|
407
|
+
}, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withUserConsentPickerGroups]);
|
|
389
408
|
|
|
390
409
|
// Clear product / org-level selections when request type changes.
|
|
391
410
|
// Per-product state (selectedPurposes, selectedCorrectionElements,
|
|
@@ -538,7 +557,7 @@ export default function Form({
|
|
|
538
557
|
}
|
|
539
558
|
}
|
|
540
559
|
|
|
541
|
-
const pickerData = await
|
|
560
|
+
const pickerData = await withUserConsentPickerGroups(
|
|
542
561
|
displayData,
|
|
543
562
|
langParam || undefined,
|
|
544
563
|
);
|
|
@@ -565,7 +584,7 @@ export default function Form({
|
|
|
565
584
|
};
|
|
566
585
|
|
|
567
586
|
void initializePrivacyCenterForm();
|
|
568
|
-
}, [organizationId, workspaceId, isPopup, authContext, consentClient,
|
|
587
|
+
}, [organizationId, workspaceId, isPopup, authContext, consentClient, withUserConsentPickerGroups]);
|
|
569
588
|
|
|
570
589
|
/**
|
|
571
590
|
* Maps translated correction element names back to English names using UUID lookup.
|
|
@@ -671,7 +690,13 @@ export default function Form({
|
|
|
671
690
|
}
|
|
672
691
|
|
|
673
692
|
const isProductScoped = isProductScopedRequestType(requestType);
|
|
674
|
-
|
|
693
|
+
// The synthetic legacy-flat group has no server-side product, so it must
|
|
694
|
+
// not anchor the case — those submissions keep the pre-grouping
|
|
695
|
+
// org-level shape (`product_uuid` is optional on the backend).
|
|
696
|
+
const isLegacyFlatSelection =
|
|
697
|
+
selectedGroup?.product_uuid === LEGACY_PRODUCT_GROUP_UUID;
|
|
698
|
+
const anchorGroup =
|
|
699
|
+
isProductScoped && !isLegacyFlatSelection ? selectedGroup : null;
|
|
675
700
|
|
|
676
701
|
const requestDetails: {
|
|
677
702
|
purposes: UpdatePurpose[];
|
|
@@ -32,7 +32,7 @@ export const ModifyConsentModal = ({
|
|
|
32
32
|
action: 'revoke' as ConsentAction,
|
|
33
33
|
buttonLabel: t('revokeConsent'),
|
|
34
34
|
buttonVariant: 'danger' as const,
|
|
35
|
-
warningMessage: t('revokeConsentWarning'),
|
|
35
|
+
warningMessage: selectedRecord.revoke_warning_message || t('revokeConsentWarning'),
|
|
36
36
|
description: nominatorLabel
|
|
37
37
|
? t('aboutToModifyConsentOnBehalf', { nominator: nominatorLabel })
|
|
38
38
|
: t('aboutToModifyConsent'),
|
|
@@ -11,16 +11,12 @@ import type { SelectUserProps } from '../lib/types';
|
|
|
11
11
|
* (.pc-select-user*).
|
|
12
12
|
*/
|
|
13
13
|
export const SelectUser = ({
|
|
14
|
-
email,
|
|
15
14
|
candidates,
|
|
16
15
|
onSelect,
|
|
17
16
|
busyOrgUserId,
|
|
18
17
|
error,
|
|
19
18
|
}: SelectUserProps) => {
|
|
20
19
|
const { t } = useTranslation();
|
|
21
|
-
const subtitle = email
|
|
22
|
-
? t('emailLinkedToMultipleProfiles', { email })
|
|
23
|
-
: t('contactLinkedToMultipleProfiles');
|
|
24
20
|
|
|
25
21
|
// A candidate with no uuid can't be selected (it's the value we submit),
|
|
26
22
|
// so drop it rather than render an unclickable row.
|
|
@@ -31,17 +27,33 @@ export const SelectUser = ({
|
|
|
31
27
|
<div className="pc-select-user__header">
|
|
32
28
|
<PCTitle as="h2">{t('chooseYourProfile')}</PCTitle>
|
|
33
29
|
<PCText as="p" size="base" color="secondary" align="center">
|
|
34
|
-
{
|
|
30
|
+
{t('loginIdentifierLinkedToMultipleProfiles')}
|
|
35
31
|
</PCText>
|
|
36
32
|
</div>
|
|
37
33
|
|
|
38
34
|
<div className="pc-select-user__list">
|
|
39
35
|
{selectable.map((candidate) => {
|
|
40
36
|
const principalUuid = candidate.uuid || '';
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
37
|
+
const email = candidate.primary_email || '';
|
|
38
|
+
const mobile = candidate.primary_mobile || '';
|
|
39
|
+
const orgUserId = candidate.org_user_id || '';
|
|
40
|
+
// org_user_id is unique per principal (ledger-enforced within the
|
|
41
|
+
// org/workspace), so it leads the row as the reliable way to tell
|
|
42
|
+
// shared-contact profiles apart when the email/mobile are identical.
|
|
43
|
+
// Fall back to a contact when a principal has no org_user_id, so the
|
|
44
|
+
// row still shows something identifiable.
|
|
45
|
+
const primaryLabel = orgUserId
|
|
46
|
+
? t('userIdLabel', { id: orgUserId })
|
|
47
|
+
: email || mobile || candidate.uuid;
|
|
48
|
+
// Email and mobile drop below the id. Skip any value that already
|
|
49
|
+
// surfaced as the primary label (the fallback case) or duplicates the
|
|
50
|
+
// other contact, so we never echo the same value twice.
|
|
51
|
+
const secondaryLabels = [email, mobile].filter(
|
|
52
|
+
(value, index, all) =>
|
|
53
|
+
value !== '' &&
|
|
54
|
+
value !== primaryLabel &&
|
|
55
|
+
all.indexOf(value) === index
|
|
56
|
+
);
|
|
45
57
|
const isBusy = busyOrgUserId === principalUuid;
|
|
46
58
|
return (
|
|
47
59
|
<button
|
|
@@ -55,11 +67,11 @@ export const SelectUser = ({
|
|
|
55
67
|
<span className="pc-select-user__item-primary">
|
|
56
68
|
{primaryLabel}
|
|
57
69
|
</span>
|
|
58
|
-
{
|
|
59
|
-
<span className="pc-select-user__item-secondary">
|
|
60
|
-
{
|
|
70
|
+
{secondaryLabels.map((label) => (
|
|
71
|
+
<span key={label} className="pc-select-user__item-secondary">
|
|
72
|
+
{label}
|
|
61
73
|
</span>
|
|
62
|
-
)
|
|
74
|
+
))}
|
|
63
75
|
</span>
|
|
64
76
|
{isBusy ? (
|
|
65
77
|
<span className="pc-select-user__item-status">
|
|
@@ -12,6 +12,7 @@ import { useAuth } from '../context/AuthContext';
|
|
|
12
12
|
import { ProfileSwitchProvider } from '../context/ProfileSwitchContext';
|
|
13
13
|
import { PCLoader } from '../ui';
|
|
14
14
|
import { decodeJwtToken } from '../lib/utils';
|
|
15
|
+
import { isSandboxMode } from '../lib/sandbox';
|
|
15
16
|
import type {
|
|
16
17
|
GatePhase,
|
|
17
18
|
IdentityCandidate,
|
|
@@ -54,6 +55,13 @@ export const SelectUserGate = ({ children }: SelectUserGateProps) => {
|
|
|
54
55
|
useEffect(() => {
|
|
55
56
|
let cancelled = false;
|
|
56
57
|
|
|
58
|
+
// Sandbox PoC: there is no JWT and no shared-contact concept — the subject is
|
|
59
|
+
// fixed by props. Skip the identity lookup and render children directly.
|
|
60
|
+
if (isSandboxMode()) {
|
|
61
|
+
setPhase('resolved');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
if (!organizationId || !workspaceId || !accessToken) {
|
|
58
66
|
setPhase('resolved');
|
|
59
67
|
return;
|
|
@@ -171,14 +179,8 @@ export const SelectUserGate = ({ children }: SelectUserGateProps) => {
|
|
|
171
179
|
</div>
|
|
172
180
|
);
|
|
173
181
|
} else if (phase === 'picking') {
|
|
174
|
-
const decoded = decodeJwtToken(accessToken);
|
|
175
|
-
const email =
|
|
176
|
-
(decoded?.user_data?.primary_email as string) ||
|
|
177
|
-
(decoded?.primary_email as string) ||
|
|
178
|
-
null;
|
|
179
182
|
inner = (
|
|
180
183
|
<SelectUser
|
|
181
|
-
email={email}
|
|
182
184
|
candidates={candidates}
|
|
183
185
|
onSelect={handleSelect}
|
|
184
186
|
busyOrgUserId={busyOrgUserId}
|
|
@@ -47,6 +47,6 @@ export const ACTIVITY_FETCH_ERROR_MESSAGE = 'Failed to fetch activity data';
|
|
|
47
47
|
export const PICKED_ORG_USER_KEY = 'pc_picked_org_user_id';
|
|
48
48
|
|
|
49
49
|
// The shared-contact picker/switch user-facing strings are localized via i18n —
|
|
50
|
-
// see the chooseYourProfile /
|
|
51
|
-
//
|
|
52
|
-
//
|
|
50
|
+
// see the chooseYourProfile / loginIdentifierLinkedToMultipleProfiles /
|
|
51
|
+
// signingInEllipsis / couldNotSwitchProfile / switchProfile keys in
|
|
52
|
+
// locales/*/translation.json.
|
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import { LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
|
|
1
2
|
import type {
|
|
2
3
|
ConsentDataElement,
|
|
3
4
|
ConsentGroup,
|
|
4
5
|
DataElement,
|
|
6
|
+
FormData,
|
|
5
7
|
FormProductGroup,
|
|
6
8
|
Purpose,
|
|
7
9
|
PurposeItem,
|
|
8
10
|
} from '../lib/types';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
* Map a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Map a `ConsentGroup` (from the user-consents endpoint — served by the Go
|
|
14
|
+
* ledger, or by Python's `/dsar/privacy-center/user-consents` when no
|
|
15
|
+
* ledgerBaseUrl is configured) onto the DSAR request form's
|
|
16
|
+
* `FormProductGroup`. Approach A: the request-form product picker is sourced
|
|
17
|
+
* from user-consents instead of Python's `form/data`, since after the cutover
|
|
18
|
+
* `form/data` no longer emits product groups.
|
|
15
19
|
*
|
|
16
20
|
* The shapes are near-identical; the only adaptations are:
|
|
17
21
|
* - purpose id rename: ledger `purpose_uuid` -> form `uuid` (which the picker
|
|
@@ -36,8 +40,11 @@ export const consentGroupToFormProductGroup = (
|
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
const purposeItemToFormPurpose = (purpose: PurposeItem): Purpose => ({
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
// The ledger keys purposes as `purpose_uuid`; the legacy Python
|
|
44
|
+
// user-consents payload keys some of them as `uuid`. Accept either so the
|
|
45
|
+
// picker and the submitted `purpose_uuid` resolve on both backends.
|
|
46
|
+
uuid: purpose.purpose_uuid ?? purpose.uuid ?? '',
|
|
47
|
+
purpose_uuid: purpose.purpose_uuid ?? purpose.uuid ?? '',
|
|
41
48
|
name: purpose.name,
|
|
42
49
|
description: purpose.description ?? '',
|
|
43
50
|
industries: '',
|
|
@@ -51,6 +58,35 @@ const purposeItemToFormPurpose = (purpose: PurposeItem): Purpose => ({
|
|
|
51
58
|
),
|
|
52
59
|
});
|
|
53
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Build a single synthetic product group from a backend's legacy flat
|
|
63
|
+
* `form/data.purposes` list.
|
|
64
|
+
*
|
|
65
|
+
* Used as the last fallback for the DSAR picker: pre-grouping Python
|
|
66
|
+
* deployments (and the popup / case-management contexts, which resolve an
|
|
67
|
+
* arbitrary contact that the JWT-scoped user-consents endpoint cannot serve)
|
|
68
|
+
* return purposes without any product grouping. Rendering them under one
|
|
69
|
+
* group keeps the picker usable instead of showing "No products available"
|
|
70
|
+
* while the response actually carries consents — it is the lone group, so the
|
|
71
|
+
* product-selection step is skipped and the purpose list renders directly,
|
|
72
|
+
* matching the pre-grouping UX.
|
|
73
|
+
*
|
|
74
|
+
* The group carries `LEGACY_PRODUCT_GROUP_UUID`, which the submit path uses to
|
|
75
|
+
* omit the product anchor (there is no real product to scope the case to).
|
|
76
|
+
*/
|
|
77
|
+
export const legacyFlatGroup = (
|
|
78
|
+
form: FormData,
|
|
79
|
+
label: string,
|
|
80
|
+
): FormProductGroup => ({
|
|
81
|
+
product_uuid: LEGACY_PRODUCT_GROUP_UUID,
|
|
82
|
+
product_name: label,
|
|
83
|
+
product_description: null,
|
|
84
|
+
nominator: null,
|
|
85
|
+
purposes: (form.purposes ?? [])
|
|
86
|
+
.map((wrapper) => wrapper?.purpose)
|
|
87
|
+
.filter((purpose): purpose is Purpose => Boolean(purpose)),
|
|
88
|
+
});
|
|
89
|
+
|
|
54
90
|
const consentDataElementToFormDataElement = (
|
|
55
91
|
element: ConsentDataElement,
|
|
56
92
|
): DataElement => ({
|
|
@@ -1,17 +1,74 @@
|
|
|
1
|
-
import { createContext, useContext, useState, useEffect, useCallback, useMemo
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
2
|
import { setRefreshTokenCookie, decodeJwtToken, resolveContactFromJwt, setSessionExpiredListener } from '../lib/utils';
|
|
3
|
-
import
|
|
3
|
+
import { setSandboxConfig } from '../lib/sandbox';
|
|
4
|
+
import type {
|
|
5
|
+
AuthContextValue,
|
|
6
|
+
AuthProviderProps,
|
|
7
|
+
SeedSessionStorageData,
|
|
8
|
+
} from './types';
|
|
4
9
|
|
|
5
10
|
export type { AuthContextValue } from './types';
|
|
6
11
|
|
|
7
12
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Raw sessionStorage writer for the five keys the Privacy Center reads
|
|
16
|
+
* (access_token / pc_org_id / pc_workspace_id / pc_org_slug / email). Single
|
|
17
|
+
* source of truth for those key names so the JWT and sandbox seeding paths can
|
|
18
|
+
* never drift. Each key is written only when its value is a non-empty string;
|
|
19
|
+
* `accessToken` is optional because the JWT path stores it separately. The
|
|
20
|
+
* `contact` value goes under the `email` key even when it is a mobile number
|
|
21
|
+
* (matching resolveContactFromJwt, which also stores mobile/org_user_id
|
|
22
|
+
* subjects there). No-ops when `window` is unavailable (SSR).
|
|
23
|
+
*/
|
|
24
|
+
const seedSessionStorage = (data: SeedSessionStorageData): void => {
|
|
25
|
+
if (typeof window === 'undefined') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (data.accessToken) sessionStorage.setItem('access_token', data.accessToken);
|
|
29
|
+
if (data.orgId) sessionStorage.setItem('pc_org_id', data.orgId);
|
|
30
|
+
if (data.workspaceId) sessionStorage.setItem('pc_workspace_id', data.workspaceId);
|
|
31
|
+
if (data.slug) sessionStorage.setItem('pc_org_slug', data.slug);
|
|
32
|
+
if (data.contact) sessionStorage.setItem('email', data.contact);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Seed the sessionStorage keys the Privacy Center reads (org/workspace/slug/
|
|
37
|
+
* email/access_token) from the sandbox props and register the module-level
|
|
38
|
+
* sandbox config, so the fetch + auth layers switch to sandbox headers and skip
|
|
39
|
+
* token refresh. Returns true when sandbox mode was configured.
|
|
40
|
+
*/
|
|
41
|
+
const configureSandbox = (props: AuthProviderProps): boolean => {
|
|
42
|
+
const token = props.token?.trim();
|
|
43
|
+
if (!token) {
|
|
44
|
+
setSandboxConfig(null);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const organisationUuid = props.organisationUuid?.trim() ?? '';
|
|
49
|
+
const workspaceUuid = props.workspaceUuid?.trim() ?? '';
|
|
50
|
+
const ucic = props.sandboxUcic?.trim() ?? '';
|
|
51
|
+
const subject = props.sandboxSubject?.trim() ?? '';
|
|
52
|
+
const slug = props.slug?.trim() ?? '';
|
|
53
|
+
|
|
54
|
+
setSandboxConfig({
|
|
55
|
+
token,
|
|
56
|
+
ucic: ucic || undefined,
|
|
57
|
+
subject,
|
|
58
|
+
organisationUuid,
|
|
59
|
+
workspaceUuid,
|
|
60
|
+
slug,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
seedSessionStorage({
|
|
64
|
+
accessToken: token,
|
|
65
|
+
orgId: organisationUuid,
|
|
66
|
+
workspaceId: workspaceUuid,
|
|
67
|
+
slug,
|
|
68
|
+
contact: ucic || subject,
|
|
69
|
+
});
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
15
72
|
|
|
16
73
|
/**
|
|
17
74
|
* Extract and store organization/auth data from JWT token to sessionStorage
|
|
@@ -31,55 +88,68 @@ const syncJwtDataToSessionStorage = (accessToken: string): void => {
|
|
|
31
88
|
const contact = resolveContactFromJwt(decoded)
|
|
32
89
|
|| sessionStorage.getItem('email')
|
|
33
90
|
|| (typeof localStorage !== 'undefined' ? localStorage.getItem('userEmail') : null);
|
|
34
|
-
if (contact && typeof contact === 'string') {
|
|
35
|
-
sessionStorage.setItem('email', contact);
|
|
36
|
-
}
|
|
37
91
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (workspaceUuid && typeof workspaceUuid === 'string') {
|
|
47
|
-
sessionStorage.setItem('pc_workspace_id', workspaceUuid);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Extract organization slug
|
|
51
|
-
const orgSlug = decoded.organisation_slug;
|
|
52
|
-
if (orgSlug && typeof orgSlug === 'string') {
|
|
53
|
-
sessionStorage.setItem('pc_org_slug', orgSlug);
|
|
54
|
-
}
|
|
92
|
+
// The access_token is stored separately by the caller (state initializer /
|
|
93
|
+
// updateTokens), so it is intentionally omitted here.
|
|
94
|
+
seedSessionStorage({
|
|
95
|
+
orgId: typeof decoded.organisation_uuid === 'string' ? decoded.organisation_uuid : undefined,
|
|
96
|
+
workspaceId: typeof decoded.workspace_uuid === 'string' ? decoded.workspace_uuid : undefined,
|
|
97
|
+
slug: typeof decoded.organisation_slug === 'string' ? decoded.organisation_slug : undefined,
|
|
98
|
+
contact: typeof contact === 'string' ? contact : undefined,
|
|
99
|
+
});
|
|
55
100
|
};
|
|
56
101
|
|
|
57
|
-
export const AuthProvider = ({
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
102
|
+
export const AuthProvider = (props: AuthProviderProps) => {
|
|
103
|
+
const {
|
|
104
|
+
children,
|
|
105
|
+
accessToken: initialAccessToken,
|
|
106
|
+
refreshToken: initialRefreshToken,
|
|
107
|
+
token: sandboxToken,
|
|
108
|
+
sandboxUcic,
|
|
109
|
+
sandboxSubject,
|
|
110
|
+
organisationUuid,
|
|
111
|
+
workspaceUuid,
|
|
112
|
+
slug,
|
|
113
|
+
onError,
|
|
114
|
+
} = props;
|
|
115
|
+
|
|
116
|
+
// Sandbox PoC: configure the module singleton + seed sessionStorage BEFORE any
|
|
117
|
+
// child renders (same reason the JWT sync runs in the state initializer). This
|
|
118
|
+
// MUST run during render, not in a useEffect: child effects (which fire the
|
|
119
|
+
// first data fetch and read `sandboxAuthHeaders()` synchronously) run on commit
|
|
120
|
+
// BEFORE the parent's effects, so an effect here would configure the singleton
|
|
121
|
+
// too late. A useState lazy initializer runs once on the first render, ahead of
|
|
122
|
+
// any child, and is not re-run on subsequent parent re-renders — so it sets the
|
|
123
|
+
// config before the first fetch without the every-render side effect. When
|
|
124
|
+
// active, the JWT path below is skipped entirely.
|
|
125
|
+
const [sandboxActive] = useState(() => configureSandbox(props));
|
|
126
|
+
|
|
63
127
|
// Sync to sessionStorage synchronously during initialization so child
|
|
64
128
|
// components (ConsentManager, SideBarPC) find the data when they mount.
|
|
65
129
|
// useEffect runs AFTER children render — too late for components that
|
|
66
130
|
// read sessionStorage during their render phase.
|
|
67
131
|
const [accessToken, setAccessToken] = useState(() => {
|
|
132
|
+
if (sandboxActive) {
|
|
133
|
+
return sandboxToken ?? '';
|
|
134
|
+
}
|
|
68
135
|
if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
|
|
69
136
|
if (typeof window !== 'undefined') {
|
|
70
137
|
sessionStorage.setItem('access_token', initialAccessToken);
|
|
71
138
|
syncJwtDataToSessionStorage(initialAccessToken);
|
|
72
139
|
}
|
|
73
140
|
}
|
|
74
|
-
return initialAccessToken;
|
|
141
|
+
return initialAccessToken ?? '';
|
|
75
142
|
});
|
|
76
143
|
const [refreshToken, setRefreshToken] = useState(() => {
|
|
144
|
+
if (sandboxActive) {
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
77
147
|
if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
|
|
78
148
|
if (typeof window !== 'undefined') {
|
|
79
149
|
setRefreshTokenCookie(initialRefreshToken);
|
|
80
150
|
}
|
|
81
151
|
}
|
|
82
|
-
return initialRefreshToken;
|
|
152
|
+
return initialRefreshToken ?? '';
|
|
83
153
|
});
|
|
84
154
|
|
|
85
155
|
// Terminal "session expired" flag. Set by utils' refresh routines (via the
|
|
@@ -97,8 +167,12 @@ export const AuthProvider = ({
|
|
|
97
167
|
return () => setSessionExpiredListener(null);
|
|
98
168
|
}, [markSessionExpired]);
|
|
99
169
|
|
|
100
|
-
// Re-sync when props change (for token refresh scenarios)
|
|
170
|
+
// Re-sync when props change (for token refresh scenarios). Sandbox mode has no
|
|
171
|
+
// JWT and no refresh, so these are no-ops there.
|
|
101
172
|
useEffect(() => {
|
|
173
|
+
if (sandboxActive) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
102
176
|
if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
|
|
103
177
|
setAccessToken(initialAccessToken);
|
|
104
178
|
if (typeof window !== 'undefined') {
|
|
@@ -106,9 +180,12 @@ export const AuthProvider = ({
|
|
|
106
180
|
syncJwtDataToSessionStorage(initialAccessToken);
|
|
107
181
|
}
|
|
108
182
|
}
|
|
109
|
-
}, [initialAccessToken]);
|
|
183
|
+
}, [initialAccessToken, sandboxActive]);
|
|
110
184
|
|
|
111
185
|
useEffect(() => {
|
|
186
|
+
if (sandboxActive) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
112
189
|
if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
|
|
113
190
|
setRefreshToken(initialRefreshToken);
|
|
114
191
|
// A fresh refresh token means the user re-authenticated — clear the
|
|
@@ -118,7 +195,22 @@ export const AuthProvider = ({
|
|
|
118
195
|
setRefreshTokenCookie(initialRefreshToken);
|
|
119
196
|
}
|
|
120
197
|
}
|
|
121
|
-
}, [initialRefreshToken]);
|
|
198
|
+
}, [initialRefreshToken, sandboxActive]);
|
|
199
|
+
|
|
200
|
+
// Sandbox PoC: re-configure the module singleton when the sandbox inputs
|
|
201
|
+
// change after mount (the lazy initializer above only runs once, on first
|
|
202
|
+
// render — this mirrors the JWT re-sync effects for prop changes). Clears the
|
|
203
|
+
// singleton on unmount so a later JWT-mode mount never inherits a stale sandbox
|
|
204
|
+
// config. First-fetch ordering is unaffected: the initializer already set the
|
|
205
|
+
// config before any child effect ran.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!sandboxActive) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
configureSandbox(props);
|
|
211
|
+
return () => setSandboxConfig(null);
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, [sandboxActive, sandboxToken, sandboxUcic, sandboxSubject, organisationUuid, workspaceUuid, slug]);
|
|
122
214
|
|
|
123
215
|
// Internal method to update tokens after refresh
|
|
124
216
|
const updateTokens = useCallback((newAccessToken: string, newRefreshToken?: string) => {
|
|
@@ -1,5 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
import type { createConsentClient } from '../api/client';
|
|
2
3
|
|
|
4
|
+
export type AuthProviderProps = Readonly<{
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
accessToken?: string;
|
|
7
|
+
refreshToken?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Sandbox PoC: when set, the Privacy Center skips the JWT entirely — it seeds
|
|
10
|
+
* org/workspace/subject from the props below, sends the single
|
|
11
|
+
* `X-Consent-Token` header, carries the acting identity in each request's own
|
|
12
|
+
* payload, and never calls `/otp/refresh`.
|
|
13
|
+
*/
|
|
14
|
+
token?: string;
|
|
15
|
+
/** Sandbox UCIC — the client's own user id (`org_user_id`); takes precedence over the subject. */
|
|
16
|
+
sandboxUcic?: string;
|
|
17
|
+
/** Sandbox subject (email or mobile) — carried in the request payload. */
|
|
18
|
+
sandboxSubject?: string;
|
|
19
|
+
/** Sandbox org UUID (replaces the JWT-decoded org). */
|
|
20
|
+
organisationUuid?: string;
|
|
21
|
+
/** Sandbox workspace UUID (replaces the JWT-decoded workspace). */
|
|
22
|
+
workspaceUuid?: string;
|
|
23
|
+
/** Organisation slug — used to seed sandbox sessionStorage. */
|
|
24
|
+
slug?: string;
|
|
25
|
+
onError: (error: Error) => boolean;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The five-key sessionStorage payload the Privacy Center seeds (access_token /
|
|
30
|
+
* pc_org_id / pc_workspace_id / pc_org_slug / email). `contact` lands under the
|
|
31
|
+
* `email` key even when it is a mobile/UCIC subject.
|
|
32
|
+
*/
|
|
33
|
+
export type SeedSessionStorageData = {
|
|
34
|
+
accessToken?: string;
|
|
35
|
+
orgId?: string;
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
slug?: string;
|
|
38
|
+
contact?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
3
41
|
export type AuthContextValue = Readonly<{
|
|
4
42
|
accessToken: string;
|
|
5
43
|
refreshToken: string;
|