@osano/osano-cmp-react-native 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/commonjs/ConsentManager.js +23 -0
- package/lib/commonjs/ConsentManager.js.map +1 -1
- package/lib/commonjs/components/Button.js +7 -2
- package/lib/commonjs/components/Button.js.map +1 -1
- package/lib/commonjs/iab/tcf/iab-storage-utils.js +53 -49
- package/lib/commonjs/iab/tcf/iab-storage-utils.js.map +1 -1
- package/lib/commonjs/iab/tcf/middleware.js +20 -23
- package/lib/commonjs/iab/tcf/middleware.js.map +1 -1
- package/lib/commonjs/iab/tcf/reducer.js +0 -22
- package/lib/commonjs/iab/tcf/reducer.js.map +1 -1
- package/lib/commonjs/index.js +16 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/reducer.js +5 -1
- package/lib/commonjs/reducer.js.map +1 -1
- package/lib/commonjs/services/consent.js +182 -81
- package/lib/commonjs/services/consent.js.map +1 -1
- package/lib/commonjs/utils/Translator.js +25 -20
- package/lib/commonjs/utils/Translator.js.map +1 -1
- package/lib/module/ConsentManager.js +23 -0
- package/lib/module/ConsentManager.js.map +1 -1
- package/lib/module/components/Button.js +7 -2
- package/lib/module/components/Button.js.map +1 -1
- package/lib/module/iab/tcf/iab-storage-utils.js +53 -49
- package/lib/module/iab/tcf/iab-storage-utils.js.map +1 -1
- package/lib/module/iab/tcf/middleware.js +22 -25
- package/lib/module/iab/tcf/middleware.js.map +1 -1
- package/lib/module/iab/tcf/reducer.js +1 -23
- package/lib/module/iab/tcf/reducer.js.map +1 -1
- package/lib/module/index.js +17 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/reducer.js +5 -1
- package/lib/module/reducer.js.map +1 -1
- package/lib/module/services/consent.js +178 -79
- package/lib/module/services/consent.js.map +1 -1
- package/lib/module/utils/Translator.js +25 -20
- package/lib/module/utils/Translator.js.map +1 -1
- package/lib/typescript/src/ConsentManager.d.ts +11 -1
- package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
- package/lib/typescript/src/components/Button.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts +7 -7
- package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/middleware.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/reducer.d.ts +1 -0
- package/lib/typescript/src/iab/tcf/reducer.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/reducer.d.ts.map +1 -1
- package/lib/typescript/src/services/consent.d.ts +28 -0
- package/lib/typescript/src/services/consent.d.ts.map +1 -1
- package/lib/typescript/src/utils/Translator.d.ts +2 -0
- package/lib/typescript/src/utils/Translator.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ConsentManager.ts +24 -1
- package/src/components/Button.tsx +10 -2
- package/src/iab/tcf/iab-storage-utils.ts +101 -56
- package/src/iab/tcf/middleware.ts +36 -32
- package/src/iab/tcf/reducer.ts +2 -29
- package/src/index.tsx +15 -1
- package/src/reducer.ts +4 -0
- package/src/services/consent.ts +223 -90
- package/src/types/consent-config.d.ts +5 -1
- package/src/types/store.d.ts +5 -0
- package/src/utils/Translator.ts +35 -25
package/src/services/consent.ts
CHANGED
|
@@ -29,10 +29,9 @@ const CONSENTING_PLATFORM_TAG = IS_OFFICIAL_SAMPLE
|
|
|
29
29
|
? `${Platform.OS}.sample-react-native`
|
|
30
30
|
: `${Platform.OS}.react-native`;
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
32
|
+
// Local-testing hook only — any key set here clobbers the fetched config
|
|
33
|
+
// (including the x-osano-* headers), so it must ship empty.
|
|
34
|
+
const overrideConfig: Partial<Config> = {};
|
|
36
35
|
|
|
37
36
|
export type ConsentPayload = {
|
|
38
37
|
customerId: string;
|
|
@@ -94,6 +93,36 @@ const lastConsentDateKey = (configId: string, userSuffix: string) =>
|
|
|
94
93
|
const legacyLastConsentDateKey = (userSuffix: string) =>
|
|
95
94
|
`${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
|
|
96
95
|
|
|
96
|
+
const parseStoredTimestamp = (json: string | null | undefined): string => {
|
|
97
|
+
if (!json) {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(json) as string;
|
|
102
|
+
} catch {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const retrieveTimestampForSuffix = async (
|
|
108
|
+
configId: string,
|
|
109
|
+
userSuffix: string,
|
|
110
|
+
): Promise<string> => {
|
|
111
|
+
try {
|
|
112
|
+
const scoped = parseStoredTimestamp(
|
|
113
|
+
await getStorage(lastConsentDateKey(configId, userSuffix)),
|
|
114
|
+
);
|
|
115
|
+
if (scoped) {
|
|
116
|
+
return scoped;
|
|
117
|
+
}
|
|
118
|
+
return parseStoredTimestamp(
|
|
119
|
+
await getStorage(legacyLastConsentDateKey(userSuffix)),
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
97
126
|
function consentRecordedRemotely(
|
|
98
127
|
configId: string,
|
|
99
128
|
success: boolean,
|
|
@@ -208,48 +237,47 @@ export const migrateConsentStorage = async (
|
|
|
208
237
|
return null;
|
|
209
238
|
}
|
|
210
239
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
214
243
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (oldConsent) {
|
|
221
|
-
let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
|
|
222
|
-
if (consent.length > 0) {
|
|
223
|
-
consent = formatConsentArray(
|
|
224
|
-
normalizeDoNotSellConsent(
|
|
225
|
-
makeConsentFromCategories(consent),
|
|
226
|
-
{
|
|
227
|
-
enableDoNotSell: cmp.config.enableDoNotSell,
|
|
228
|
-
doNotSellCategories: cmp.config.doNotSellCategories,
|
|
229
|
-
},
|
|
230
|
-
),
|
|
231
|
-
);
|
|
232
|
-
// Store with new user-specific key
|
|
233
|
-
storeConsent(cmp, consent).then(() => {
|
|
234
|
-
// Clean up old storage
|
|
235
|
-
clearStorage(oldKey);
|
|
236
|
-
clearStorage(oldTimestampKey);
|
|
237
|
-
resolve(consent);
|
|
238
|
-
});
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
resolve(null); // No old consent found
|
|
243
|
-
})
|
|
244
|
-
.catch(() => {
|
|
245
|
-
resolve(null); // Old key not found
|
|
246
|
-
});
|
|
247
|
-
break;
|
|
248
|
-
default:
|
|
249
|
-
resolve(null);
|
|
250
|
-
break;
|
|
244
|
+
const oldKey = `${STORAGE_KEY}_${cmp.configId}`;
|
|
245
|
+
try {
|
|
246
|
+
const oldConsent = await getStorage(oldKey);
|
|
247
|
+
if (!oldConsent) {
|
|
248
|
+
return null;
|
|
251
249
|
}
|
|
252
|
-
|
|
250
|
+
let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
|
|
251
|
+
if (consent.length === 0) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
consent = formatConsentArray(
|
|
255
|
+
normalizeDoNotSellConsent(makeConsentFromCategories(consent), {
|
|
256
|
+
enableDoNotSell: cmp.config.enableDoNotSell,
|
|
257
|
+
doNotSellCategories: cmp.config.doNotSellCategories,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
const sourceTimestamp = await retrieveTimestampForSuffix(cmp.configId, '');
|
|
261
|
+
const parsedSourceTimestamp = new Date(sourceTimestamp);
|
|
262
|
+
const migrationTimestamp = Number.isNaN(parsedSourceTimestamp.getTime())
|
|
263
|
+
? new Date(0)
|
|
264
|
+
: parsedSourceTimestamp;
|
|
265
|
+
await storeConsent(cmp, consent, undefined, migrationTimestamp);
|
|
266
|
+
await Promise.all(
|
|
267
|
+
[
|
|
268
|
+
oldKey,
|
|
269
|
+
lastConsentDateKey(cmp.configId, ''),
|
|
270
|
+
legacyLastConsentDateKey(''),
|
|
271
|
+
].map(async (key) => {
|
|
272
|
+
try {
|
|
273
|
+
await clearStorage(key);
|
|
274
|
+
} catch {}
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
return consent;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
253
281
|
};
|
|
254
282
|
|
|
255
283
|
// The consent to fall back to when a visitor has no stored consent yet. Flavor 7
|
|
@@ -284,6 +312,25 @@ const clearToFirstLoadDefault = async (
|
|
|
284
312
|
return seed;
|
|
285
313
|
};
|
|
286
314
|
|
|
315
|
+
const retrieveStoredConsent = async (
|
|
316
|
+
cmp: ConsentManager,
|
|
317
|
+
): Promise<ConsentCategoryType[] | null> => {
|
|
318
|
+
const hashedExtUsrData = hashExtUsrData(
|
|
319
|
+
cmp.customerId,
|
|
320
|
+
cmp.configId,
|
|
321
|
+
cmp.extUsrData,
|
|
322
|
+
);
|
|
323
|
+
const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
|
|
324
|
+
try {
|
|
325
|
+
const stored = await getStorage(
|
|
326
|
+
`${STORAGE_KEY}_${cmp.configId}${userSuffix}`,
|
|
327
|
+
);
|
|
328
|
+
return stored ? (JSON.parse(stored) as ConsentCategoryType[]) : null;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
287
334
|
// After an expired/invalid record, settle on a persisted essential-only state.
|
|
288
335
|
// Persisting (rather than only clearing the payload with clearConsent) keeps a
|
|
289
336
|
// subsequent load in this safe state instead of falling back to the first-load
|
|
@@ -293,17 +340,47 @@ const clearToFirstLoadDefault = async (
|
|
|
293
340
|
// storage would otherwise re-seed the first-load default.
|
|
294
341
|
const settleEssentialOnly = (
|
|
295
342
|
cmp: ConsentManager,
|
|
296
|
-
|
|
297
|
-
|
|
343
|
+
// Keep invalidation sticky until the visitor records a new choice.
|
|
344
|
+
consentDate?: string | Date,
|
|
345
|
+
): Promise<ConsentCategoryType[]> => {
|
|
346
|
+
const categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
|
|
347
|
+
if (
|
|
348
|
+
cmp.config?.isDoNotSellVisible &&
|
|
349
|
+
(cmp.config.enableDoNotSellDefault ||
|
|
350
|
+
cmp.consent?.includes(ConsentCategory.OPT_OUT))
|
|
351
|
+
) {
|
|
352
|
+
categories.push(ConsentCategory.OPT_OUT);
|
|
353
|
+
}
|
|
354
|
+
return storeConsent(cmp, categories, undefined, consentDate);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const markConsentInvalidated = (cmp: ConsentManager) => {
|
|
358
|
+
cmp.consentInvalidated = true;
|
|
359
|
+
cmp.consentPromptRequired = true;
|
|
360
|
+
};
|
|
298
361
|
|
|
299
362
|
const retrieveConsent = async (cmp: ConsentManager) => {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// essential-only
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
363
|
+
// Invalidate stored consent for either independent reason — the record
|
|
364
|
+
// predates the forceReconsent stamp, or it aged past its expiration window —
|
|
365
|
+
// and settle on essential-only. Only read the timestamp when there is a stamp
|
|
366
|
+
// or window to evaluate, so a no-signal load skips the read as the previous
|
|
367
|
+
// expiry-only path did. `consentInvalidated` (read in index.tsx) drives the
|
|
368
|
+
// auto re-prompt; it is set only for a previously recorded consent, never on
|
|
369
|
+
// the first-run path.
|
|
370
|
+
const forceSeconds = effectiveForceReconsentSeconds(cmp);
|
|
371
|
+
const expirationSeconds = await effectiveExpirationSeconds(cmp);
|
|
372
|
+
if (forceSeconds !== null || expirationSeconds !== null) {
|
|
373
|
+
const timestamp = await retrieveLastConsentTimestamp(cmp);
|
|
374
|
+
const expired = isTimestampExpired(timestamp, expirationSeconds);
|
|
375
|
+
const forced = predatesForceReconsent(cmp, timestamp);
|
|
376
|
+
if (expired || forced) {
|
|
377
|
+
const storedConsent = await retrieveStoredConsent(cmp);
|
|
378
|
+
if (storedConsent?.length) {
|
|
379
|
+
cmp.consent = storedConsent;
|
|
380
|
+
markConsentInvalidated(cmp);
|
|
381
|
+
return settleEssentialOnly(cmp, timestamp);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
307
384
|
}
|
|
308
385
|
return new Promise<ConsentCategoryType[]>((resolve) => {
|
|
309
386
|
const resConsent = (consentJson: string | null | undefined) => {
|
|
@@ -337,7 +414,15 @@ const retrieveConsent = async (cmp: ConsentManager) => {
|
|
|
337
414
|
// Try migration if new key not found
|
|
338
415
|
const migratedConsent = await migrateConsentStorage(cmp);
|
|
339
416
|
if (migratedConsent) {
|
|
340
|
-
|
|
417
|
+
const timestamp = await retrieveLastConsentTimestamp(cmp);
|
|
418
|
+
const expired = isTimestampExpired(timestamp, expirationSeconds);
|
|
419
|
+
const forced = predatesForceReconsent(cmp, timestamp);
|
|
420
|
+
if (expired || forced) {
|
|
421
|
+
markConsentInvalidated(cmp);
|
|
422
|
+
resolve(await settleEssentialOnly(cmp, timestamp));
|
|
423
|
+
} else {
|
|
424
|
+
resolve(migratedConsent);
|
|
425
|
+
}
|
|
341
426
|
} else {
|
|
342
427
|
// No consent found, seed the first-load default
|
|
343
428
|
clearToFirstLoadDefault(cmp).then(resolve);
|
|
@@ -365,33 +450,7 @@ const retrieveLastConsentTimestamp = async (
|
|
|
365
450
|
cmp.extUsrData,
|
|
366
451
|
);
|
|
367
452
|
const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
|
|
368
|
-
|
|
369
|
-
const parse = (json: string | null | undefined): string => {
|
|
370
|
-
if (!json) {
|
|
371
|
-
return '';
|
|
372
|
-
}
|
|
373
|
-
try {
|
|
374
|
-
return JSON.parse(json) as string;
|
|
375
|
-
} catch {
|
|
376
|
-
return '';
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
const scoped = parse(
|
|
382
|
-
await getStorage(lastConsentDateKey(cmp.configId, userSuffix)),
|
|
383
|
-
);
|
|
384
|
-
if (scoped) {
|
|
385
|
-
return scoped;
|
|
386
|
-
}
|
|
387
|
-
// Fall back to the legacy un-scoped key for installs written before the
|
|
388
|
-
// timestamp was scoped by configId.
|
|
389
|
-
return parse(await getStorage(legacyLastConsentDateKey(userSuffix)));
|
|
390
|
-
} catch {
|
|
391
|
-
// Fail closed: a storage rejection resolves to no timestamp instead of
|
|
392
|
-
// hanging the consent load.
|
|
393
|
-
return '';
|
|
394
|
-
}
|
|
453
|
+
return retrieveTimestampForSuffix(cmp.configId, userSuffix);
|
|
395
454
|
};
|
|
396
455
|
|
|
397
456
|
/**
|
|
@@ -436,6 +495,72 @@ const isTimestampExpired = (
|
|
|
436
495
|
return Date.now() - consentedAtMs > seconds * 1000;
|
|
437
496
|
};
|
|
438
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Resolves the effective `forceReconsent` signal in whole seconds, validating
|
|
500
|
+
* before trusting it. The raw field ships over the network and is human-edited,
|
|
501
|
+
* so treat any malformed value as "no signal" (null) rather than invalidating
|
|
502
|
+
* every consent:
|
|
503
|
+
*
|
|
504
|
+
* - absent / null → null (no signal)
|
|
505
|
+
* - <= 0 → null (guards zero and negatives)
|
|
506
|
+
* - in the future → null (clock skew, or a value mistakenly stamped in
|
|
507
|
+
* milliseconds, which reads as ~1000× "now" ⇒ future)
|
|
508
|
+
*
|
|
509
|
+
* Failing toward *not* invalidating means the worst a bad value causes is a
|
|
510
|
+
* missed prompt, never a permanent lockout.
|
|
511
|
+
*/
|
|
512
|
+
export const effectiveForceReconsentSeconds = (
|
|
513
|
+
cmp: ConsentManager,
|
|
514
|
+
): number | null => {
|
|
515
|
+
const raw: unknown = cmp.config?.forceReconsent;
|
|
516
|
+
if (raw == null) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
const seconds =
|
|
520
|
+
typeof raw === 'number'
|
|
521
|
+
? raw
|
|
522
|
+
: typeof raw === 'string' && raw.trim() !== ''
|
|
523
|
+
? Number(raw)
|
|
524
|
+
: Number.NaN;
|
|
525
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
if (seconds > Math.floor(Date.now() / 1000)) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
return seconds;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* True when a consent record predates the server's `forceReconsent` stamp.
|
|
536
|
+
* `timestamp` is the recorded-consent instant (stored as an ISO/ms string).
|
|
537
|
+
*
|
|
538
|
+
* Unit alignment is the subtle part: the signal is in SECONDS and the timestamp
|
|
539
|
+
* is in MILLISECONDS, so the signal is scaled up (`* 1000`). The boundary is
|
|
540
|
+
* strict `<` — consent recorded exactly at the stamp instant stays valid.
|
|
541
|
+
*
|
|
542
|
+
* A missing/unparseable timestamp fails closed (returns false), so first-run
|
|
543
|
+
* (no timestamp) is not wrongly invalidated. `storeConsent` always co-stamps,
|
|
544
|
+
* so a consented-but-timestampless record is unreachable here anyway.
|
|
545
|
+
*/
|
|
546
|
+
export const predatesForceReconsent = (
|
|
547
|
+
cmp: ConsentManager,
|
|
548
|
+
timestamp: string | number | Date | null | undefined,
|
|
549
|
+
): boolean => {
|
|
550
|
+
const forceSeconds = effectiveForceReconsentSeconds(cmp);
|
|
551
|
+
if (forceSeconds === null) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
if (!timestamp) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
const consentedAtMs = new Date(timestamp).getTime();
|
|
558
|
+
if (Number.isNaN(consentedAtMs)) {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
return consentedAtMs < forceSeconds * 1000;
|
|
562
|
+
};
|
|
563
|
+
|
|
439
564
|
/**
|
|
440
565
|
* Returns true when the locally stored consent has aged past its expiration
|
|
441
566
|
* window (see {@link effectiveExpirationSeconds}).
|
|
@@ -455,6 +580,8 @@ export const recordConsent = (
|
|
|
455
580
|
cmp: ConsentManager,
|
|
456
581
|
consent?: ConsentCategoryType[],
|
|
457
582
|
) => {
|
|
583
|
+
cmp.consentInvalidated = false;
|
|
584
|
+
cmp.consentPromptRequired = false;
|
|
458
585
|
const payload = createConsentRecordPayload(cmp, consent);
|
|
459
586
|
return API.post(`${CONSENT_URI}/record`, payload, {
|
|
460
587
|
referrer: cmp.consentingDomain,
|
|
@@ -539,18 +666,22 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
539
666
|
|
|
540
667
|
// Evaluate the remote record against the server's own timestamp (when the
|
|
541
668
|
// visitor actually consented), not "now", so an already-expired remote
|
|
542
|
-
// record is never restored and re-dated as fresh.
|
|
669
|
+
// record is never restored and re-dated as fresh. A record that predates
|
|
670
|
+
// the server's forceReconsent stamp is stale for the same reason — applying
|
|
671
|
+
// it would silently re-grant the very consent the signal invalidated.
|
|
543
672
|
const seconds = await effectiveExpirationSeconds(cmp);
|
|
544
|
-
const
|
|
673
|
+
const remoteForced = predatesForceReconsent(cmp, lastConsentTimestamp);
|
|
674
|
+
const remoteStale =
|
|
675
|
+
isTimestampExpired(lastConsentTimestamp, seconds) || remoteForced;
|
|
545
676
|
|
|
546
677
|
// If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
|
|
547
678
|
if (extUsrData && extUsrData.trim() !== '') {
|
|
548
|
-
if (
|
|
549
|
-
// The authoritative remote record
|
|
550
|
-
// essential-only
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
return settleEssentialOnly(cmp);
|
|
679
|
+
if (remoteStale) {
|
|
680
|
+
// The authoritative remote record is stale (expired or predates the
|
|
681
|
+
// stamp); settle essential-only and flag the invalidation rather than
|
|
682
|
+
// restoring it.
|
|
683
|
+
markConsentInvalidated(cmp);
|
|
684
|
+
return settleEssentialOnly(cmp, lastConsentTimestamp);
|
|
554
685
|
}
|
|
555
686
|
const remoteConsentCategories = formatConsentArray(
|
|
556
687
|
normalizeDoNotSellConsent(
|
|
@@ -573,6 +704,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
573
704
|
true,
|
|
574
705
|
lastConsentTimestamp,
|
|
575
706
|
);
|
|
707
|
+
cmp.consentPromptRequired = false;
|
|
576
708
|
return remoteConsentCategories;
|
|
577
709
|
}
|
|
578
710
|
|
|
@@ -580,7 +712,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
580
712
|
const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
|
|
581
713
|
try {
|
|
582
714
|
if (
|
|
583
|
-
!
|
|
715
|
+
!remoteStale &&
|
|
584
716
|
(new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
|
|
585
717
|
internalTimestamp === '')
|
|
586
718
|
) {
|
|
@@ -598,6 +730,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
598
730
|
true,
|
|
599
731
|
lastConsentTimestamp,
|
|
600
732
|
);
|
|
733
|
+
cmp.consentPromptRequired = false;
|
|
601
734
|
|
|
602
735
|
return remoteConsentCategories;
|
|
603
736
|
}
|
|
@@ -56,7 +56,11 @@ export type Config = {
|
|
|
56
56
|
customerId: string;
|
|
57
57
|
domains: Array<string>;
|
|
58
58
|
forceManagePreferences: boolean;
|
|
59
|
-
|
|
59
|
+
// Server-stamped Unix timestamp in whole SECONDS. When present, consent
|
|
60
|
+
// recorded before this instant is treated as no longer valid, re-prompting the
|
|
61
|
+
// visitor. Read-only on the client (the server authors it); absent/undefined
|
|
62
|
+
// means "no signal". See src/services/consent.ts effectiveForceReconsentSeconds.
|
|
63
|
+
forceReconsent?: number;
|
|
60
64
|
gdprApplies?: boolean;
|
|
61
65
|
googleConsent: boolean;
|
|
62
66
|
gpcSupport: boolean;
|
package/src/types/store.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export type OsanoState = {
|
|
|
30
30
|
scrollToType: string;
|
|
31
31
|
scrollToId: number;
|
|
32
32
|
stack: string[];
|
|
33
|
+
// Transient marker set by a RELOAD whose consent was invalidated (expired or
|
|
34
|
+
// predating the forceReconsent stamp). The IAB TCF middleware reads it on
|
|
35
|
+
// RELOAD/SET_CONFIG to wipe stale TC strings instead of rehydrating them.
|
|
36
|
+
// Reset on SAVE_BEGIN (the user re-consented) and on the next clean load.
|
|
37
|
+
consentInvalidated?: boolean;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
export type ActionCreator<S, T> = (payload?: T) => OsanoAction<S, T>;
|
package/src/utils/Translator.ts
CHANGED
|
@@ -100,6 +100,8 @@ export default class Translator extends EventEmitter {
|
|
|
100
100
|
private _langLoadFailureSubscription?: EventSubscription;
|
|
101
101
|
|
|
102
102
|
private _loading: Record<string, Promise<string | undefined>> = {};
|
|
103
|
+
private _loadedCustomerId?: string;
|
|
104
|
+
private _loadedConfigId?: string;
|
|
103
105
|
load(
|
|
104
106
|
desiredLocale?: string,
|
|
105
107
|
onLangLoadSuccess?: () => void,
|
|
@@ -114,6 +116,16 @@ export default class Translator extends EventEmitter {
|
|
|
114
116
|
const locale = allLocales.shift();
|
|
115
117
|
const customerId = this._consentManager.customerId;
|
|
116
118
|
const configId = this._consentManager.configId;
|
|
119
|
+
// Overrides are keyed only by locale — stale copy from a previous
|
|
120
|
+
// customer/config would survive the switch and skip the refetch below.
|
|
121
|
+
if (
|
|
122
|
+
customerId !== this._loadedCustomerId ||
|
|
123
|
+
configId !== this._loadedConfigId
|
|
124
|
+
) {
|
|
125
|
+
this._overrides = {};
|
|
126
|
+
this._loadedCustomerId = customerId;
|
|
127
|
+
this._loadedConfigId = configId;
|
|
128
|
+
}
|
|
117
129
|
this._langLoadFailureSubscription =
|
|
118
130
|
onLangLoadFailure && this.on('langLoadFailure', onLangLoadFailure);
|
|
119
131
|
this._langLoadSuccessSubscription =
|
|
@@ -284,40 +296,38 @@ export default class Translator extends EventEmitter {
|
|
|
284
296
|
const pathStr = Array.isArray(path) ? path.join('.') : path;
|
|
285
297
|
const fallback =
|
|
286
298
|
typeof defaultPhrase === 'string' ? defaultPhrase : `${pathStr}`;
|
|
287
|
-
const phrases = locales[`${locale}`];
|
|
288
|
-
const override = this._overrides[`${locale}`];
|
|
289
299
|
|
|
290
300
|
let translation: string | number | LocalizedStrings | undefined;
|
|
291
301
|
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
[
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
// load() stores phrases under whatever locale it managed to fetch (e.g.
|
|
303
|
+
// en-us.json may 403 while en.json exists), so search every fallback
|
|
304
|
+
// bucket. Overrides beat built-in phrases only within the active language
|
|
305
|
+
// — customer copy loaded via the English fallback must not repaint a
|
|
306
|
+
// non-English UI.
|
|
307
|
+
const normalized = locale.toLowerCase().replace(/_/g, '-');
|
|
308
|
+
const lookupLocales = [
|
|
309
|
+
...new Set([locale, ...Translator.fallbackLanguages(normalized)]),
|
|
310
|
+
].filter((lookupLocale): lookupLocale is string => !!lookupLocale);
|
|
311
|
+
const baseLanguage = normalized.split('-')[0];
|
|
312
|
+
const overrideLocales = lookupLocales.filter(
|
|
313
|
+
(lookupLocale) =>
|
|
314
|
+
lookupLocale === locale || lookupLocale.split('-')[0] === baseLanguage,
|
|
315
|
+
);
|
|
316
|
+
const searchPaths: [LocalizedStrings | undefined, string][] = [];
|
|
317
|
+
for (const lookupLocale of overrideLocales) {
|
|
318
|
+
const override = this._overrides[`${lookupLocale}`];
|
|
319
|
+
searchPaths.push([override, `mobile.${pathStr}`], [override, pathStr]);
|
|
320
|
+
}
|
|
321
|
+
for (const lookupLocale of lookupLocales) {
|
|
322
|
+
const phrases = locales[`${lookupLocale}`];
|
|
323
|
+
searchPaths.push([phrases, `mobile.${pathStr}`], [phrases, pathStr]);
|
|
324
|
+
}
|
|
304
325
|
for (const [source, searchPath] of searchPaths) {
|
|
305
326
|
if (source) {
|
|
306
327
|
translation = get(source, searchPath);
|
|
307
328
|
if (translation) break;
|
|
308
329
|
}
|
|
309
330
|
}
|
|
310
|
-
// Base translatedResourceBundle (fallback to default locale)
|
|
311
|
-
if (translation == null && locale !== Translator.DEFAULT_LANGUAGE) {
|
|
312
|
-
const defaultPhrases = locales[Translator.DEFAULT_LANGUAGE];
|
|
313
|
-
if (defaultPhrases) {
|
|
314
|
-
translation = get(
|
|
315
|
-
defaultPhrases,
|
|
316
|
-
`mobile.${pathStr}`,
|
|
317
|
-
get(defaultPhrases, pathStr),
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
331
|
// Use fallback if nothing found
|
|
322
332
|
if (!translation) {
|
|
323
333
|
translation = fallback;
|