@redacto.io/consent-sdk-react 10.0.0-beta.7 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  3. package/dist/index.d.mts +29 -3
  4. package/dist/index.d.ts +29 -3
  5. package/dist/index.js +162 -56
  6. package/dist/index.mjs +161 -57
  7. package/dist/privacy-center.d.mts +54 -12
  8. package/dist/privacy-center.d.ts +54 -12
  9. package/dist/privacy-center.js +265 -77
  10. package/dist/privacy-center.mjs +264 -78
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
  13. package/src/RedactoNoticeConsent/api/index.ts +78 -48
  14. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  15. package/src/RedactoNoticeConsent/api/types.ts +57 -11
  16. package/src/RedactoNoticeConsent/types.ts +28 -2
  17. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  18. package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
  19. package/src/RedactoPrivacyCenter/api/client.ts +122 -25
  20. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  21. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  22. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  23. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
  24. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  25. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  26. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  27. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  28. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -5
  29. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  30. package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
  31. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  32. package/src/shared/sandbox.ts +6 -0
  33. package/tests/Form.test.tsx +142 -5
  34. package/tests/consentClient.test.ts +46 -0
@@ -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 Go-ledger `ConsentGroup` (from the user-consents endpoint) onto the
12
- * DSAR request form's `FormProductGroup`. Approach A: the request-form product
13
- * picker is now sourced from the ledger instead of Python's `form/data`, since
14
- * consents live in the ledger after the cutover.
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
- uuid: purpose.purpose_uuid,
40
- purpose_uuid: purpose.purpose_uuid,
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, type ReactNode } from 'react';
1
+ import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
2
2
  import { setRefreshTokenCookie, decodeJwtToken, resolveContactFromJwt, setSessionExpiredListener } from '../lib/utils';
3
- import type { AuthContextValue } from './types';
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
- interface AuthProviderProps {
10
- children: ReactNode;
11
- accessToken: string;
12
- refreshToken: string;
13
- onError: (error: Error) => boolean;
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
- // Extract organization UUID
39
- const orgUuid = decoded.organisation_uuid;
40
- if (orgUuid && typeof orgUuid === 'string') {
41
- sessionStorage.setItem('pc_org_id', orgUuid);
42
- }
43
-
44
- // Extract workspace UUID
45
- const workspaceUuid = decoded.workspace_uuid;
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
- children,
59
- accessToken: initialAccessToken,
60
- refreshToken: initialRefreshToken,
61
- onError,
62
- }: AuthProviderProps) => {
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;
@@ -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) {
@@ -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 the Go ledger's user-consents when
201
- // sourcing the DSAR request-form product picker (Approach A). The ledger caps
202
- // user-consents page size at 100; the picker needs all of a principal's
203
- // groups, so we request the max. A principal with >100 product groups would
204
- // need group-level pagination (follow-up).
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";