@redacto.io/consent-sdk-react 10.0.0-beta.0 → 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 +22 -25
- package/CHANGELOG.md +123 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +43 -8
- package/dist/index.d.ts +43 -8
- package/dist/index.js +345 -237
- package/dist/index.mjs +344 -238
- package/dist/privacy-center.d.mts +113 -37
- package/dist/privacy-center.d.ts +113 -37
- package/dist/privacy-center.js +2317 -1910
- package/dist/privacy-center.mjs +2245 -1835
- 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 +75 -33
- package/src/RedactoNoticeConsent/api/index.ts +90 -177
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +60 -44
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +29 -3
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
- package/src/RedactoNoticeConsentInline/types.ts +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
- package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
- package/src/RedactoPrivacyCenter/api/client.ts +216 -39
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
- package/src/RedactoPrivacyCenter/context/types.ts +39 -1
- 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 +78 -2
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/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 +8 -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 +112 -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 +190 -7
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +217 -0
- package/tests/manageConsentServer.test.ts +38 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { useOrganization } from '../context/OrganizationContext';
|
|
10
|
+
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
11
|
+
import { useAuth } from '../context/AuthContext';
|
|
12
|
+
import { ProfileSwitchProvider } from '../context/ProfileSwitchContext';
|
|
13
|
+
import { PCLoader } from '../ui';
|
|
14
|
+
import { decodeJwtToken } from '../lib/utils';
|
|
15
|
+
import { isSandboxMode } from '../lib/sandbox';
|
|
16
|
+
import type {
|
|
17
|
+
GatePhase,
|
|
18
|
+
IdentityCandidate,
|
|
19
|
+
SelectUserGateProps,
|
|
20
|
+
} from '../lib/types';
|
|
21
|
+
import { PICKED_ORG_USER_KEY } from './constant';
|
|
22
|
+
import { SelectUser } from './SelectUser';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shared-contact profile gate. On mount it asks the backend which principals
|
|
26
|
+
* share the signed-in contact; when more than one exists (and the session isn't
|
|
27
|
+
* already pinned to a chosen one), it renders the "choose your profile" picker
|
|
28
|
+
* instead of the consent view. Picking re-scopes the session via updateTokens,
|
|
29
|
+
* then renders children. It sits OUTSIDE DataAvailabilityProvider so no consent
|
|
30
|
+
* fetch runs against an unscoped token, and degrades to children on any error.
|
|
31
|
+
*
|
|
32
|
+
* Once resolved it also exposes `switchProfile` via ProfileSwitchContext, so a
|
|
33
|
+
* signed-in shared contact can re-open the picker and move to another profile.
|
|
34
|
+
*/
|
|
35
|
+
export const SelectUserGate = ({ children }: SelectUserGateProps) => {
|
|
36
|
+
const { t } = useTranslation();
|
|
37
|
+
const { organization } = useOrganization();
|
|
38
|
+
const organizationId = organization?.uuid || '';
|
|
39
|
+
const workspaceId = organization?.workspaces?.[0]?.uuid || '';
|
|
40
|
+
const { consentClient } = useBaseUrl();
|
|
41
|
+
const { accessToken, updateTokens } = useAuth();
|
|
42
|
+
|
|
43
|
+
const [phase, setPhase] = useState<GatePhase>('checking');
|
|
44
|
+
const [candidates, setCandidates] = useState<IdentityCandidate[]>([]);
|
|
45
|
+
const [busyOrgUserId, setBusyOrgUserId] = useState<string | null>(null);
|
|
46
|
+
const [selectError, setSelectError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
// The token's principal. Routine refreshes (checkAndRefreshAuth,
|
|
49
|
+
// refreshOtpToken) hand back a fresh token string for the SAME principal;
|
|
50
|
+
// keying the gate check off this decoded id rather than the raw token keeps
|
|
51
|
+
// those refreshes from re-running the check and flashing the picker/spinner.
|
|
52
|
+
const tokenOrgUserId =
|
|
53
|
+
(decodeJwtToken(accessToken)?.user_data?.org_user_id as string) || '';
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let cancelled = false;
|
|
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
|
+
|
|
65
|
+
if (!organizationId || !workspaceId || !accessToken) {
|
|
66
|
+
setPhase('resolved');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Already pinned to a chosen principal this session? Pass straight through
|
|
71
|
+
// to children — but STILL resolve the sibling list in the background so the
|
|
72
|
+
// user menu can offer "switch profile". The default/unscoped token has
|
|
73
|
+
// org_user_id = the contact and no stored pick, so the picker still shows.
|
|
74
|
+
const picked =
|
|
75
|
+
typeof window !== 'undefined'
|
|
76
|
+
? sessionStorage.getItem(PICKED_ORG_USER_KEY)
|
|
77
|
+
: null;
|
|
78
|
+
const isPinned = Boolean(picked) && picked === tokenOrgUserId;
|
|
79
|
+
|
|
80
|
+
// Only block on the spinner when the picker might actually open; a pinned
|
|
81
|
+
// session renders children immediately and resolves siblings quietly.
|
|
82
|
+
setPhase(isPinned ? 'resolved' : 'checking');
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
const res = await consentClient.listIdentities({
|
|
86
|
+
organisationUuid: organizationId,
|
|
87
|
+
workspaceUuid: workspaceId,
|
|
88
|
+
authToken: accessToken,
|
|
89
|
+
});
|
|
90
|
+
if (cancelled) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const list = res?.identities ?? [];
|
|
94
|
+
setCandidates(list);
|
|
95
|
+
if (list.length > 1 && !isPinned) {
|
|
96
|
+
setPhase('picking');
|
|
97
|
+
} else {
|
|
98
|
+
setPhase('resolved');
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Ledger/endpoint unavailable — never block the Privacy Center.
|
|
102
|
+
if (!cancelled) {
|
|
103
|
+
setCandidates([]);
|
|
104
|
+
setPhase('resolved');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
cancelled = true;
|
|
111
|
+
};
|
|
112
|
+
// consentClient is a stable factory result; re-running on its identity would
|
|
113
|
+
// risk a loop. Re-evaluate only when the tenant or the token's principal
|
|
114
|
+
// changes — not on every raw-token refresh. accessToken is read inside for
|
|
115
|
+
// the fetch but intentionally omitted so same-principal refreshes are no-ops.
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [organizationId, workspaceId, tokenOrgUserId]);
|
|
118
|
+
|
|
119
|
+
const handleSelect = async (principalUuid: string) => {
|
|
120
|
+
if (!principalUuid || busyOrgUserId) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
setBusyOrgUserId(principalUuid);
|
|
124
|
+
setSelectError(null);
|
|
125
|
+
try {
|
|
126
|
+
const res = await consentClient.selectPrincipal(
|
|
127
|
+
{
|
|
128
|
+
organisationUuid: organizationId,
|
|
129
|
+
workspaceUuid: workspaceId,
|
|
130
|
+
authToken: accessToken,
|
|
131
|
+
},
|
|
132
|
+
principalUuid,
|
|
133
|
+
);
|
|
134
|
+
if (!res?.token || !res?.refresh_token) {
|
|
135
|
+
throw new Error('select-principal returned no tokens');
|
|
136
|
+
}
|
|
137
|
+
// The picker selects by opaque uuid, but the "already pinned" check
|
|
138
|
+
// compares against the token's own org_user_id. Derive the pin from the
|
|
139
|
+
// NEW token so it matches on later reloads — it's the signed-in
|
|
140
|
+
// principal's own id, not a co-principal's, so nothing leaks.
|
|
141
|
+
if (typeof window !== 'undefined') {
|
|
142
|
+
const pinnedOrgUserId = decodeJwtToken(res.token)?.user_data
|
|
143
|
+
?.org_user_id as string | undefined;
|
|
144
|
+
if (pinnedOrgUserId) {
|
|
145
|
+
sessionStorage.setItem(PICKED_ORG_USER_KEY, pinnedOrgUserId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Resolve before swapping the token so the picker doesn't flash back.
|
|
149
|
+
setPhase('resolved');
|
|
150
|
+
updateTokens(res.token, res.refresh_token);
|
|
151
|
+
} catch {
|
|
152
|
+
setBusyOrgUserId(null);
|
|
153
|
+
setSelectError(t('couldNotSwitchProfile'));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Re-open the picker so a shared contact can move to another of its profiles.
|
|
158
|
+
// Drops the session pin and shows the picker with the already-loaded
|
|
159
|
+
// candidates; picking re-scopes the token exactly like the first choice.
|
|
160
|
+
const switchProfile = useCallback(() => {
|
|
161
|
+
if (typeof window !== 'undefined') {
|
|
162
|
+
sessionStorage.removeItem(PICKED_ORG_USER_KEY);
|
|
163
|
+
}
|
|
164
|
+
setSelectError(null);
|
|
165
|
+
setBusyOrgUserId(null);
|
|
166
|
+
setPhase('picking');
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const switchValue = useMemo(
|
|
170
|
+
() => ({ canSwitch: candidates.length > 1, switchProfile }),
|
|
171
|
+
[candidates.length, switchProfile],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
let inner: ReactNode;
|
|
175
|
+
if (phase === 'checking') {
|
|
176
|
+
inner = (
|
|
177
|
+
<div className="pc-select-user-loading">
|
|
178
|
+
<PCLoader variant="spinner" color="info" size="lg" />
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
} else if (phase === 'picking') {
|
|
182
|
+
inner = (
|
|
183
|
+
<SelectUser
|
|
184
|
+
candidates={candidates}
|
|
185
|
+
onSelect={handleSelect}
|
|
186
|
+
busyOrgUserId={busyOrgUserId}
|
|
187
|
+
error={selectError}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
inner = <>{children}</>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return <ProfileSwitchProvider value={switchValue}>{inner}</ProfileSwitchProvider>;
|
|
195
|
+
};
|
|
@@ -39,3 +39,14 @@ export const MARKER_TINT: Record<BadgeVariant, MarkerTint> = {
|
|
|
39
39
|
// Internal error thrown when an activity page fails to load (surfaced to the
|
|
40
40
|
// user via the i18n `failedToFetchActivityData` toast).
|
|
41
41
|
export const ACTIVITY_FETCH_ERROR_MESSAGE = 'Failed to fetch activity data';
|
|
42
|
+
|
|
43
|
+
// ─── Shared-contact profile picker ───────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// sessionStorage key pinning the principal the user chose this session, so
|
|
46
|
+
// routine token refreshes skip the picker.
|
|
47
|
+
export const PICKED_ORG_USER_KEY = 'pc_picked_org_user_id';
|
|
48
|
+
|
|
49
|
+
// The shared-contact picker/switch user-facing strings are localized via i18n —
|
|
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) => {
|
|
@@ -13,7 +13,7 @@ export const BaseUrlProvider = ({
|
|
|
13
13
|
}: {
|
|
14
14
|
children: ReactNode;
|
|
15
15
|
baseUrl: string; // Consent server base URL
|
|
16
|
-
ledgerBaseUrl
|
|
16
|
+
ledgerBaseUrl?: string; // Go ledger base URL — serves user-consents
|
|
17
17
|
}) => {
|
|
18
18
|
const consentClient = useMemo(
|
|
19
19
|
() => createConsentClient(baseUrl, ledgerBaseUrl),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { ProfileSwitchValue } from '../lib/types';
|
|
3
|
+
|
|
4
|
+
// Lets any descendant of SelectUserGate (e.g. the user menu) re-open the
|
|
5
|
+
// shared-contact profile picker. Defaults to a no-op so consumers rendered
|
|
6
|
+
// outside the gate degrade gracefully.
|
|
7
|
+
const ProfileSwitchContext = createContext<ProfileSwitchValue>({
|
|
8
|
+
canSwitch: false,
|
|
9
|
+
switchProfile: () => {},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const ProfileSwitchProvider = ProfileSwitchContext.Provider;
|
|
13
|
+
|
|
14
|
+
export const useProfileSwitch = (): ProfileSwitchValue =>
|
|
15
|
+
useContext(ProfileSwitchContext);
|
|
@@ -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;
|
|
@@ -14,6 +52,6 @@ export type AuthContextValue = Readonly<{
|
|
|
14
52
|
|
|
15
53
|
export type BaseUrlContextValue = Readonly<{
|
|
16
54
|
baseUrl: string;
|
|
17
|
-
ledgerBaseUrl
|
|
55
|
+
ledgerBaseUrl?: string;
|
|
18
56
|
consentClient: ReturnType<typeof createConsentClient>;
|
|
19
57
|
}>;
|
|
@@ -394,6 +394,7 @@ export const manageConsent = async ({
|
|
|
394
394
|
productUuid,
|
|
395
395
|
noticeUuid,
|
|
396
396
|
dataElements,
|
|
397
|
+
language,
|
|
397
398
|
}: {
|
|
398
399
|
organizationId: string;
|
|
399
400
|
workspaceId: string;
|
|
@@ -407,6 +408,7 @@ export const manageConsent = async ({
|
|
|
407
408
|
productUuid?: string;
|
|
408
409
|
noticeUuid?: string;
|
|
409
410
|
dataElements?: Array<{ uuid: string; selected: boolean }>;
|
|
411
|
+
language?: string;
|
|
410
412
|
}): Promise<boolean | null> => {
|
|
411
413
|
try {
|
|
412
414
|
// Ensure we have a valid token before making the API call
|
|
@@ -429,6 +431,7 @@ export const manageConsent = async ({
|
|
|
429
431
|
productUuid,
|
|
430
432
|
noticeUuid,
|
|
431
433
|
dataElements,
|
|
434
|
+
language,
|
|
432
435
|
});
|
|
433
436
|
return result;
|
|
434
437
|
} catch (error: any) {
|
|
@@ -40,7 +40,6 @@ export const languageOptions = [
|
|
|
40
40
|
{ value: 'mni-Mtei', label: 'ꯃꯩꯇꯩꯂꯣꯟ (Manipuri)' },
|
|
41
41
|
{ value: 'gom', label: 'कोंकणी (Goan Konkani)' },
|
|
42
42
|
{ value: 'sa', label: 'संस्कृतम् (Sanskrit)' },
|
|
43
|
-
{ value: 'bho', label: 'भोजपुरी (Bhojpuri)' },
|
|
44
43
|
{ value: 'brx', label: 'बड़ो (Bodo)' },
|
|
45
44
|
{ value: 'ks', label: 'كٲشُر (Kashmiri)' },
|
|
46
45
|
{ value: 'sat', label: 'ᱥᱟᱱᱛᱟᱲᱤ (Santali)' },
|
|
@@ -198,13 +197,20 @@ export const LEGACY_REFRESH_TOKEN_COOKIE = 'refresh_token';
|
|
|
198
197
|
|
|
199
198
|
// ─── DSAR request form ─────────────────────────────────────────────────────
|
|
200
199
|
|
|
201
|
-
// Max product groups requested from
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
200
|
+
// Max product groups requested from user-consents when sourcing the DSAR
|
|
201
|
+
// request-form product picker (Approach A). The Go ledger caps user-consents
|
|
202
|
+
// page size at 100; the picker needs all of a principal's groups, so we
|
|
203
|
+
// request the max. A principal with >100 product groups would need
|
|
204
|
+
// group-level pagination (follow-up).
|
|
206
205
|
export const FORM_PICKER_GROUP_LIMIT = 100;
|
|
207
206
|
|
|
207
|
+
// Sentinel `product_uuid` of the synthetic product group the DSAR picker
|
|
208
|
+
// builds from a backend's legacy flat `purposes` list (pre-grouping Python
|
|
209
|
+
// deployments, and the popup / case-management contexts that cannot read
|
|
210
|
+
// user-consents). It is a client-side construct with no server counterpart,
|
|
211
|
+
// so a case raised from it must be submitted without a product anchor.
|
|
212
|
+
export const LEGACY_PRODUCT_GROUP_UUID = '__legacy_flat_purposes__';
|
|
213
|
+
|
|
208
214
|
// ─── Notification Hub ──────────────────────────────────────────────────────
|
|
209
215
|
|
|
210
216
|
export const POLL_INTERVAL = 30_000;
|