@redacto.io/consent-sdk-react 10.0.0-beta.8 → 10.0.0-beta.9
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/CHANGELOG.md +10 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +162 -56
- package/dist/index.mjs +161 -57
- package/dist/privacy-center.d.mts +51 -10
- package/dist/privacy-center.d.ts +51 -10
- package/dist/privacy-center.js +250 -70
- package/dist/privacy-center.mjs +249 -71
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
- package/src/RedactoNoticeConsent/api/index.ts +78 -48
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +57 -11
- package/src/RedactoNoticeConsent/types.ts +28 -2
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
- package/src/RedactoPrivacyCenter/api/client.ts +116 -24
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
- 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/constants.ts +12 -5
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +142 -5
|
@@ -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;
|
|
@@ -197,13 +197,20 @@ export const LEGACY_REFRESH_TOKEN_COOKIE = 'refresh_token';
|
|
|
197
197
|
|
|
198
198
|
// ─── DSAR request form ─────────────────────────────────────────────────────
|
|
199
199
|
|
|
200
|
-
// Max product groups requested from
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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).
|
|
205
205
|
export const FORM_PICKER_GROUP_LIMIT = 100;
|
|
206
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
|
+
|
|
207
214
|
// ─── Notification Hub ──────────────────────────────────────────────────────
|
|
208
215
|
|
|
209
216
|
export const POLL_INTERVAL = 30_000;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox-mode singleton for the Privacy Center consent PoC.
|
|
3
|
+
*
|
|
4
|
+
* The Privacy Center resolves org/workspace/subject from the JWT into
|
|
5
|
+
* sessionStorage and gates every fetch behind an OTP-refresh loop. Sandbox mode
|
|
6
|
+
* bypasses all of that: a pasted static token plus an explicit test subject and
|
|
7
|
+
* org/workspace. Because the Privacy Center is a single instance per page (the
|
|
8
|
+
* same assumption the OTP refresh circuit breaker in `utils.ts` already makes),
|
|
9
|
+
* the sandbox config lives in one module-level slot that `AuthProvider`
|
|
10
|
+
* configures on mount and the fetch/auth layers read.
|
|
11
|
+
*
|
|
12
|
+
* When configured:
|
|
13
|
+
* - `syncJwtDataToSessionStorage` seeds sessionStorage from these fields
|
|
14
|
+
* instead of decoding the JWT,
|
|
15
|
+
* - `ensureValidToken` / `checkAndRefreshAuth` return the static token without
|
|
16
|
+
* ever calling `/otp/refresh`,
|
|
17
|
+
* - `privacyCenterApiFetch` sends the single `X-Consent-Token` header instead
|
|
18
|
+
* of `Authorization: Bearer` (its presence marks the request as
|
|
19
|
+
* `environment=test`). There is no subject header — the acting identity
|
|
20
|
+
* travels in the request's own payload: query params (`primary_email` /
|
|
21
|
+
* `primary_mobile`) on GET/query reads, and the body on POSTs. See
|
|
22
|
+
* `sandboxAuthHeaders` / `sandboxQueryIdentity` / `sandboxBodyIdentity` /
|
|
23
|
+
* `sandboxContact`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { SANDBOX_TOKEN_HEADER } from '../../shared/sandbox';
|
|
27
|
+
import type { SandboxConfig, SandboxIdentityPayload } from './types';
|
|
28
|
+
|
|
29
|
+
export { SANDBOX_TOKEN_HEADER };
|
|
30
|
+
export type { SandboxConfig };
|
|
31
|
+
|
|
32
|
+
let sandboxConfig: SandboxConfig | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set (or clear, with `null`) the active sandbox configuration. Called by
|
|
36
|
+
* `AuthProvider` when the sandbox token prop is present. Idempotent.
|
|
37
|
+
*/
|
|
38
|
+
export const setSandboxConfig = (config: SandboxConfig | null): void => {
|
|
39
|
+
sandboxConfig = config;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The active sandbox config, or null when sandbox mode is off. */
|
|
43
|
+
export const getSandboxConfig = (): SandboxConfig | null => sandboxConfig;
|
|
44
|
+
|
|
45
|
+
/** True when the Privacy Center is running in sandbox mode. */
|
|
46
|
+
export const isSandboxMode = (): boolean => sandboxConfig !== null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Auth headers for a Privacy Center request in sandbox mode, or null when
|
|
50
|
+
* sandbox mode is off (so the caller falls back to `Authorization: Bearer`).
|
|
51
|
+
* Only the `X-Consent-Token` credential is sent; its presence marks the request
|
|
52
|
+
* as `environment=test`. There is no subject header — the acting identity
|
|
53
|
+
* travels in the request's own payload: query params on GET/query reads (see
|
|
54
|
+
* {@link sandboxQueryIdentity}) and the body on POSTs (see
|
|
55
|
+
* {@link sandboxBodyIdentity} / {@link sandboxContact}).
|
|
56
|
+
*/
|
|
57
|
+
export const sandboxAuthHeaders = (): Record<string, string> | null => {
|
|
58
|
+
if (!sandboxConfig) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
[SANDBOX_TOKEN_HEADER]: sandboxConfig.token,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The identity payload fragment for the active sandbox acting principal:
|
|
68
|
+
* `{ org_user_id }` when a UCIC is set (it takes precedence), else
|
|
69
|
+
* `{ primary_email }` when the subject looks like an email (contains "@"),
|
|
70
|
+
* otherwise `{ primary_mobile }`. Null when sandbox mode is off. Merge this into
|
|
71
|
+
* a JSON request body (POST) so the server can resolve the acting test identity.
|
|
72
|
+
*/
|
|
73
|
+
export const sandboxBodyIdentity = (): SandboxIdentityPayload | null => {
|
|
74
|
+
if (!sandboxConfig) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (sandboxConfig.ucic) {
|
|
78
|
+
return { org_user_id: sandboxConfig.ucic };
|
|
79
|
+
}
|
|
80
|
+
return sandboxConfig.subject.includes('@')
|
|
81
|
+
? { primary_email: sandboxConfig.subject }
|
|
82
|
+
: { primary_mobile: sandboxConfig.subject };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The identity query fragment for the active sandbox acting principal:
|
|
87
|
+
* `{ org_user_id }` when a UCIC is set (it takes precedence), else
|
|
88
|
+
* `{ primary_email }` when the subject looks like an email (contains "@"),
|
|
89
|
+
* otherwise `{ primary_mobile }`. Null when sandbox mode is off. Append these to
|
|
90
|
+
* a GET/query request's query string so the server can resolve the acting test
|
|
91
|
+
* identity (it reads `org_user_id` / `primary_email` / `primary_mobile` query
|
|
92
|
+
* params on sandbox reads).
|
|
93
|
+
*/
|
|
94
|
+
export const sandboxQueryIdentity = (): SandboxIdentityPayload | null =>
|
|
95
|
+
sandboxBodyIdentity();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The raw sandbox contact string, for endpoints whose server field is a single
|
|
99
|
+
* `contact` (the server classifies it as email/phone/UCIC) — create-case,
|
|
100
|
+
* manage-consent. Prefers the UCIC over the email/mobile subject. Null when
|
|
101
|
+
* sandbox mode is off.
|
|
102
|
+
*/
|
|
103
|
+
export const sandboxContact = (): string | null =>
|
|
104
|
+
sandboxConfig ? sandboxConfig.ucic || sandboxConfig.subject : null;
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
export type SandboxConfig = Readonly<{
|
|
2
|
+
token: string;
|
|
3
|
+
/** UCIC — the client's own user id (`org_user_id`). Takes precedence over `subject`. */
|
|
4
|
+
ucic?: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
organisationUuid: string;
|
|
7
|
+
workspaceUuid: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
/** The identity payload fragment for the sandbox acting principal. */
|
|
12
|
+
export type SandboxIdentityPayload =
|
|
13
|
+
| { org_user_id: string }
|
|
14
|
+
| { primary_email: string }
|
|
15
|
+
| { primary_mobile: string };
|
|
16
|
+
|
|
1
17
|
export type Workspace = {
|
|
2
18
|
uuid: string;
|
|
3
19
|
suid: string;
|
|
@@ -248,6 +264,10 @@ export type NominatorInfo = {
|
|
|
248
264
|
|
|
249
265
|
export type PurposeItem = {
|
|
250
266
|
purpose_uuid: string;
|
|
267
|
+
// Alias emitted by the legacy Python user-consents payload, which keys some
|
|
268
|
+
// purposes as `uuid` instead of `purpose_uuid`. Optional: the Go ledger
|
|
269
|
+
// always sends `purpose_uuid`.
|
|
270
|
+
uuid?: string;
|
|
251
271
|
name: string;
|
|
252
272
|
description: string | null;
|
|
253
273
|
status: ConsentStatusEnum | string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { refreshOtpTokenServer } from './api';
|
|
2
2
|
import { isTerminalRefreshError } from '../api/fetcher';
|
|
3
|
+
import { getSandboxConfig } from './sandbox';
|
|
3
4
|
import {
|
|
4
5
|
MIN_REFRESH_BACKOFF_MS,
|
|
5
6
|
MAX_REFRESH_BACKOFF_MS,
|
|
@@ -373,6 +374,12 @@ export const checkAndRefreshAuth = async (
|
|
|
373
374
|
): Promise<boolean> => {
|
|
374
375
|
if (typeof window === 'undefined') return false;
|
|
375
376
|
|
|
377
|
+
// Sandbox PoC: the static token is always "valid" and there is nothing to
|
|
378
|
+
// refresh, so authentication is satisfied unconditionally.
|
|
379
|
+
if (getSandboxConfig()) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
376
383
|
const accessToken = sessionStorage.getItem('access_token');
|
|
377
384
|
const email = sessionStorage.getItem('email');
|
|
378
385
|
const orgSlug = sessionStorage.getItem('pc_org_slug');
|
|
@@ -555,6 +562,14 @@ export const ensureValidToken = async (
|
|
|
555
562
|
): Promise<string | null> => {
|
|
556
563
|
if (typeof window === 'undefined') return null;
|
|
557
564
|
|
|
565
|
+
// Sandbox PoC: no JWT, no expiry, no refresh — hand back the static token so
|
|
566
|
+
// callers proceed straight to the request (which carries the X-Consent-Token
|
|
567
|
+
// header instead of Authorization, and the acting identity in its payload).
|
|
568
|
+
const sandbox = getSandboxConfig();
|
|
569
|
+
if (sandbox) {
|
|
570
|
+
return sandbox.token;
|
|
571
|
+
}
|
|
572
|
+
|
|
558
573
|
// Use context accessToken if provided, otherwise fall back to sessionStorage
|
|
559
574
|
const accessToken = authContext?.accessToken || sessionStorage.getItem('access_token');
|
|
560
575
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP header carrying the static sandbox token (the credential). Its presence
|
|
3
|
+
* marks a request as `environment=test`. Shared by the Notice-consent and
|
|
4
|
+
* Privacy-Center sandbox paths so the header name is defined once.
|
|
5
|
+
*/
|
|
6
|
+
export const SANDBOX_TOKEN_HEADER = "X-Consent-Token";
|
package/tests/Form.test.tsx
CHANGED
|
@@ -524,12 +524,14 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
524
524
|
expect(submitButton.disabled).toBe(true);
|
|
525
525
|
});
|
|
526
526
|
|
|
527
|
-
it('
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
527
|
+
it('runs the picker overlay against the legacy endpoint when no ledgerBaseUrl is configured', async () => {
|
|
528
|
+
// `getUserConsents` serves the same grouped payload from either backend
|
|
529
|
+
// (ledger `/public/...` or Python `/dsar/privacy-center/...`), so the
|
|
530
|
+
// overlay must run in both modes — Python's form/data no longer emits
|
|
531
|
+
// product groups, and skipping the read leaves the picker empty.
|
|
531
532
|
baseUrlStub.ledgerBaseUrl = undefined;
|
|
532
533
|
setupSession();
|
|
534
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
533
535
|
getFormData.mockResolvedValue(
|
|
534
536
|
makeFormData({
|
|
535
537
|
direct_groups: [
|
|
@@ -545,12 +547,12 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
545
547
|
|
|
546
548
|
render(<Form onCreateRequest={vi.fn()} />);
|
|
547
549
|
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
550
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
548
551
|
|
|
549
552
|
pickRequestType(RequestTypeEnum.ERASURE);
|
|
550
553
|
await waitFor(() => {
|
|
551
554
|
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
552
555
|
});
|
|
553
|
-
expect(fetchUserConsents).not.toHaveBeenCalled();
|
|
554
556
|
});
|
|
555
557
|
|
|
556
558
|
it('runs the ledger picker overlay when ledgerBaseUrl is configured', async () => {
|
|
@@ -565,6 +567,141 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
565
567
|
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
566
568
|
});
|
|
567
569
|
|
|
570
|
+
it('keeps the form/data groups when user-consents comes back with no groups', async () => {
|
|
571
|
+
// A successful-but-empty read must never overwrite groups form/data did
|
|
572
|
+
// supply — that turned a populated picker into "No products available".
|
|
573
|
+
setupSession();
|
|
574
|
+
fetchUserConsents.mockResolvedValue({ direct: [], nominated: [] });
|
|
575
|
+
getFormData.mockResolvedValue(
|
|
576
|
+
makeFormData({
|
|
577
|
+
direct_groups: [
|
|
578
|
+
makeGroup(),
|
|
579
|
+
makeGroup({
|
|
580
|
+
product_uuid: 'prod-direct-2',
|
|
581
|
+
product_name: 'Acme Rewards',
|
|
582
|
+
purposes: [basePurpose('purp-2', 'Analytics')],
|
|
583
|
+
}),
|
|
584
|
+
],
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
589
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
590
|
+
|
|
591
|
+
pickRequestType(RequestTypeEnum.ERASURE);
|
|
592
|
+
await waitFor(() => {
|
|
593
|
+
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
594
|
+
});
|
|
595
|
+
expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
|
|
596
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('falls back to the legacy flat purposes list when the backend exposes no product groups', async () => {
|
|
600
|
+
// Pre-grouping backends return purposes without any product grouping.
|
|
601
|
+
// Those consents must stay reachable under one synthetic group instead
|
|
602
|
+
// of the picker claiming the principal has none.
|
|
603
|
+
setupSession();
|
|
604
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
605
|
+
getFormData.mockResolvedValue(
|
|
606
|
+
makeFormData({
|
|
607
|
+
direct_groups: [],
|
|
608
|
+
nominated_groups: [],
|
|
609
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
614
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
615
|
+
|
|
616
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
617
|
+
|
|
618
|
+
await waitFor(() => {
|
|
619
|
+
expect(screen.getByText('Your data')).toBeInTheDocument();
|
|
620
|
+
});
|
|
621
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
622
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('keeps the empty state when the flat purposes carry no data elements', async () => {
|
|
626
|
+
// Nothing selectable survives `buildGroupPurposes`, so the synthetic group
|
|
627
|
+
// would render an unusable panel — the empty state is the honest answer.
|
|
628
|
+
setupSession();
|
|
629
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
630
|
+
getFormData.mockResolvedValue(
|
|
631
|
+
makeFormData({
|
|
632
|
+
direct_groups: [],
|
|
633
|
+
nominated_groups: [],
|
|
634
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing', []) }],
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
639
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
640
|
+
|
|
641
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
642
|
+
|
|
643
|
+
await waitFor(() => {
|
|
644
|
+
expect(screen.getByText('No products available')).toBeInTheDocument();
|
|
645
|
+
});
|
|
646
|
+
expect(screen.queryByText('Your data')).not.toBeInTheDocument();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('submits the legacy flat fallback as an org-level case without a product anchor', async () => {
|
|
650
|
+
// The synthetic group has no server-side product, so the case must go up
|
|
651
|
+
// in the pre-grouping org-level shape rather than carrying a made-up
|
|
652
|
+
// `product_uuid` the backend cannot resolve.
|
|
653
|
+
setupSession();
|
|
654
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
655
|
+
getFormData.mockResolvedValue(
|
|
656
|
+
makeFormData({
|
|
657
|
+
direct_groups: [],
|
|
658
|
+
nominated_groups: [],
|
|
659
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
createCaseRequest.mockResolvedValueOnce({
|
|
663
|
+
case_id: 'CASE-FLAT',
|
|
664
|
+
uuid: 'uuid-flat',
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
668
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
669
|
+
|
|
670
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
671
|
+
|
|
672
|
+
await waitFor(() => {
|
|
673
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const marketingCheckbox = screen
|
|
677
|
+
.getByText('Marketing')
|
|
678
|
+
.closest('label')
|
|
679
|
+
?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
|
680
|
+
expect(marketingCheckbox).toBeTruthy();
|
|
681
|
+
fireEvent.click(marketingCheckbox!);
|
|
682
|
+
|
|
683
|
+
const confirmCheckbox = (
|
|
684
|
+
screen.getAllByRole('checkbox') as HTMLInputElement[]
|
|
685
|
+
).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
|
|
686
|
+
expect(confirmCheckbox).toBeTruthy();
|
|
687
|
+
fireEvent.click(confirmCheckbox!);
|
|
688
|
+
|
|
689
|
+
const submitButton = screen.getByRole('button', { name: /^submit$/i });
|
|
690
|
+
expect(submitButton).not.toBeDisabled();
|
|
691
|
+
await act(async () => {
|
|
692
|
+
fireEvent.click(submitButton);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await waitFor(() => expect(createCaseRequest).toHaveBeenCalledTimes(1));
|
|
696
|
+
|
|
697
|
+
const payload = createCaseRequest.mock.calls[0][2];
|
|
698
|
+
expect('product_uuid' in payload).toBe(false);
|
|
699
|
+
expect('data_principal_uuid' in payload).toBe(false);
|
|
700
|
+
expect(payload.request_details.purposes).toEqual([
|
|
701
|
+
{ purpose_uuid: 'purp-flat', data_element_uuids: ['purp-flat-de1'] },
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
568
705
|
it('renders the product step for DSAR types when direct_groups is present', async () => {
|
|
569
706
|
setupSession();
|
|
570
707
|
const direct = makeGroup({
|