@redacto.io/consent-sdk-react 9.3.0 → 9.4.0-beta.0
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 +10 -10
- package/CHANGELOG.md +6 -0
- package/README.md +93 -12
- package/dist/index.d.mts +39 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +4454 -334
- package/dist/index.mjs +4454 -335
- package/dist/privacy-center.d.mts +4 -1
- package/dist/privacy-center.d.ts +4 -1
- package/dist/privacy-center.js +3905 -3660
- package/dist/privacy-center.mjs +4172 -3921
- package/package.json +1 -1
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +847 -0
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +2133 -0
- package/src/RedactoNoticeAssisted/api/index.ts +269 -0
- package/src/RedactoNoticeAssisted/api/types.ts +180 -0
- package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeAssisted/constants.ts +51 -0
- package/src/RedactoNoticeAssisted/i18n.ts +1463 -0
- package/src/RedactoNoticeAssisted/index.ts +1 -0
- package/src/RedactoNoticeAssisted/injectStyles.ts +251 -0
- package/src/RedactoNoticeAssisted/styles.ts +730 -0
- package/src/RedactoNoticeAssisted/types.ts +24 -0
- package/src/RedactoNoticeAssisted/useMediaQuery.ts +36 -0
- package/src/RedactoPrivacyCenter/NotificationsPage.tsx +8 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +6 -11
- package/src/RedactoPrivacyCenter/api/client.ts +15 -3
- package/src/RedactoPrivacyCenter/api/fetcher.ts +80 -1
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +193 -213
- package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +239 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +3 -1
- package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +8 -1
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +23 -17
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +24 -10
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +16 -3
- package/src/RedactoPrivacyCenter/components/constant.ts +25 -0
- package/src/RedactoPrivacyCenter/components/types.ts +32 -1
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +25 -23
- package/src/RedactoPrivacyCenter/lib/constants.ts +23 -10
- package/src/RedactoPrivacyCenter/lib/queries.ts +9 -2
- package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +127 -0
- package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +85 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +213 -63
- package/src/RedactoPrivacyCenter/locales/en/translation.json +3 -0
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +16 -0
- package/src/index.ts +2 -0
- package/src/shared/api-errors.ts +9 -0
- package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +0 -141
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import { refreshOtpTokenServer } from './api';
|
|
2
|
+
import { isTerminalRefreshError } from '../api/fetcher';
|
|
3
|
+
import {
|
|
4
|
+
MIN_REFRESH_BACKOFF_MS,
|
|
5
|
+
MAX_REFRESH_BACKOFF_MS,
|
|
6
|
+
REFRESH_TOKEN_STORAGE_KEY,
|
|
7
|
+
LEGACY_REFRESH_TOKEN_COOKIE,
|
|
8
|
+
} from './constants';
|
|
2
9
|
import type { BaseUrlContextValue } from '../context/BaseUrlContext';
|
|
3
10
|
|
|
4
11
|
// Token refresh lock to prevent concurrent refresh attempts
|
|
5
12
|
let isRefreshing = false;
|
|
6
13
|
let refreshPromise: Promise<string | null> | null = null;
|
|
7
14
|
|
|
15
|
+
// ─── OTP refresh circuit breaker (incident 2026-06-22) ──────────────────────
|
|
16
|
+
// ensureValidToken is called from 3 independent 30s pollers, before every
|
|
17
|
+
// authenticated API call, and from render-driven effects. Without a memory of
|
|
18
|
+
// failure, a single dead refresh token gets re-submitted on every one of those
|
|
19
|
+
// triggers, hammering POST /otp/refresh until the tab closes. These guards make a
|
|
20
|
+
// terminally-failed token cost exactly one request, and apply backoff to transient
|
|
21
|
+
// failures.
|
|
22
|
+
//
|
|
23
|
+
// `deadRefreshToken` holds the *value* of a refresh token that terminally failed.
|
|
24
|
+
// It auto-clears the moment a different token value appears (i.e. the user
|
|
25
|
+
// re-authenticated), so the breaker never permanently bricks a session.
|
|
26
|
+
let deadRefreshToken: string | null = null;
|
|
27
|
+
// Transient-failure backoff window (epoch ms) and the current backoff duration.
|
|
28
|
+
let transientRefreshFailureUntil = 0;
|
|
29
|
+
let transientRefreshBackoffMs = 0;
|
|
30
|
+
|
|
8
31
|
// Buffer time in seconds before token expiration to proactively refresh
|
|
9
32
|
// Using 30 seconds to ensure tokens are refreshed before they expire
|
|
10
33
|
const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
|
|
@@ -20,25 +43,140 @@ export const getCookieValue = (name: string) => {
|
|
|
20
43
|
return null;
|
|
21
44
|
};
|
|
22
45
|
|
|
46
|
+
// ─── Refresh token persistence ──────────────────────────────────────────────
|
|
47
|
+
// The refresh token lives in localStorage (key REFRESH_TOKEN_STORAGE_KEY), not a
|
|
48
|
+
// cookie. A path-scoped cookie is not readable when the host mounts the privacy
|
|
49
|
+
// center under a basePath (e.g. consent-app at /consent → /consent/privacy-center/...),
|
|
50
|
+
// so a rotated token can't be read back and the stale token gets re-presented,
|
|
51
|
+
// causing a 401 "Refresh token not found or already used" storm. localStorage is
|
|
52
|
+
// origin-scoped (no path matching), so it is readable under any basePath.
|
|
53
|
+
//
|
|
54
|
+
// localStorage access throws when storage is disabled or over quota (private
|
|
55
|
+
// modes, embedded webviews). Guard every access so the SDK degrades to "refresh
|
|
56
|
+
// fails -> re-auth" instead of crashing the render tree (setRefreshTokenCookie
|
|
57
|
+
// runs during AuthProvider's render). Mirrors components/AppShell/hooks/useSidebarState.ts.
|
|
58
|
+
const readStorage = (key: string): string | null => {
|
|
59
|
+
try {
|
|
60
|
+
return window.localStorage.getItem(key);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const writeStorage = (key: string, value: string): void => {
|
|
67
|
+
try {
|
|
68
|
+
window.localStorage.setItem(key, value);
|
|
69
|
+
} catch {
|
|
70
|
+
// localStorage unavailable / quota exceeded — ignore
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const removeStorage = (key: string): void => {
|
|
75
|
+
try {
|
|
76
|
+
window.localStorage.removeItem(key);
|
|
77
|
+
} catch {
|
|
78
|
+
// localStorage unavailable — ignore
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
23
82
|
/**
|
|
24
|
-
*
|
|
83
|
+
* Expire the SDK's own legacy refresh_token cookie (older versions wrote it at
|
|
84
|
+
* path=/privacy-center). Scoped to that path only on purpose: a refresh_token
|
|
85
|
+
* cookie at path=/ belongs to the host app, and clearing it would sign the host
|
|
86
|
+
* out.
|
|
87
|
+
*/
|
|
88
|
+
const clearLegacyRefreshTokenCookie = (): void => {
|
|
89
|
+
if (typeof window === 'undefined') {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
document.cookie = `${LEGACY_REFRESH_TOKEN_COOKIE}=; path=/privacy-center; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Persist the refresh token. Named `setRefreshTokenCookie` for backward
|
|
97
|
+
* compatibility (it is part of the package's exported surface); it now writes
|
|
98
|
+
* to localStorage rather than a cookie, and clears any cookie an older SDK
|
|
99
|
+
* version left at path=/privacy-center.
|
|
25
100
|
* @param refreshToken - The refresh token value to set
|
|
26
101
|
*/
|
|
27
102
|
export const setRefreshTokenCookie = (refreshToken: string): void => {
|
|
28
103
|
if (typeof window === 'undefined') {
|
|
29
104
|
return;
|
|
30
105
|
}
|
|
106
|
+
writeStorage(REFRESH_TOKEN_STORAGE_KEY, refreshToken);
|
|
107
|
+
clearLegacyRefreshTokenCookie();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the persisted refresh token from localStorage.
|
|
112
|
+
*
|
|
113
|
+
* Intentionally does NOT fall back to reading a `refresh_token` cookie: a cookie
|
|
114
|
+
* read is name-only (the path isn't visible to JS), so it cannot tell the SDK's
|
|
115
|
+
* own legacy cookie from a host app's unrelated `refresh_token` cookie — reading
|
|
116
|
+
* the latter would leak a host credential to the OTP refresh endpoint. Existing
|
|
117
|
+
* sessions are preserved by AuthProvider re-seeding storage from the token prop.
|
|
118
|
+
*/
|
|
119
|
+
export const getStoredRefreshToken = (): string | null => {
|
|
120
|
+
if (typeof window === 'undefined') {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return readStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the refresh token currently in play: from auth context (props mode) or
|
|
128
|
+
* persisted storage (legacy mode). Centralised so the circuit breaker and the
|
|
129
|
+
* refresh routine always agree on exactly which token value is being used.
|
|
130
|
+
*/
|
|
131
|
+
const getActiveRefreshToken = (authContext?: { refreshToken?: string }): string | null => {
|
|
132
|
+
if (authContext) {
|
|
133
|
+
return authContext.refreshToken && authContext.refreshToken.trim() !== ''
|
|
134
|
+
? authContext.refreshToken
|
|
135
|
+
: null;
|
|
136
|
+
}
|
|
137
|
+
if (typeof window === 'undefined') return null;
|
|
138
|
+
return getStoredRefreshToken();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear all OTP-refresh circuit-breaker state. Called after a successful refresh
|
|
143
|
+
* and exposed for tests. Deliberately NOT called from signOutPrivacyCenter: in
|
|
144
|
+
* props mode the refresh token lives in a prop that sign-out cannot clear, so
|
|
145
|
+
* clearing the breaker there would immediately re-arm the retry storm. Recovery
|
|
146
|
+
* instead happens automatically when a *different* token value appears.
|
|
147
|
+
*/
|
|
148
|
+
export const resetRefreshCircuit = (): void => {
|
|
149
|
+
deadRefreshToken = null;
|
|
150
|
+
transientRefreshFailureUntil = 0;
|
|
151
|
+
transientRefreshBackoffMs = 0;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* True when attempting a refresh right now would be pointless — the active refresh
|
|
156
|
+
* token has already terminally failed, or we're inside the transient backoff
|
|
157
|
+
* window. Pollers consult this to avoid scheduling doomed work.
|
|
158
|
+
*/
|
|
159
|
+
export const isRefreshBlocked = (authContext?: { refreshToken?: string }): boolean => {
|
|
160
|
+
const active = getActiveRefreshToken(authContext);
|
|
161
|
+
if (active !== null && active === deadRefreshToken) return true;
|
|
162
|
+
if (Date.now() < transientRefreshFailureUntil) return true;
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
31
165
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
166
|
+
/** Record a transient refresh failure (network / 5xx) and grow the backoff window. */
|
|
167
|
+
const noteTransientRefreshFailure = (): void => {
|
|
168
|
+
transientRefreshBackoffMs = Math.min(
|
|
169
|
+
Math.max(transientRefreshBackoffMs * 2, MIN_REFRESH_BACKOFF_MS),
|
|
170
|
+
MAX_REFRESH_BACKOFF_MS,
|
|
171
|
+
);
|
|
172
|
+
transientRefreshFailureUntil = Date.now() + transientRefreshBackoffMs;
|
|
173
|
+
};
|
|
40
174
|
|
|
41
|
-
|
|
175
|
+
/** Trip the circuit breaker for a refresh token value that terminally failed. */
|
|
176
|
+
const markRefreshTokenDead = (token: string | null): void => {
|
|
177
|
+
if (token) {
|
|
178
|
+
deadRefreshToken = token;
|
|
179
|
+
}
|
|
42
180
|
};
|
|
43
181
|
|
|
44
182
|
/**
|
|
@@ -210,14 +348,23 @@ export const checkAndRefreshAuth = async (
|
|
|
210
348
|
return false;
|
|
211
349
|
}
|
|
212
350
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
351
|
+
const refreshToken = getStoredRefreshToken();
|
|
352
|
+
if (!refreshToken) {
|
|
353
|
+
// No refresh token available - user needs to sign in again
|
|
354
|
+
signOutPrivacyCenter();
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Circuit breaker (incident 2026-06-22): a new token re-arms; a known-dead
|
|
359
|
+
// token or an open backoff window short-circuits without hitting the network.
|
|
360
|
+
if (deadRefreshToken !== null && refreshToken !== deadRefreshToken) {
|
|
361
|
+
resetRefreshCircuit();
|
|
362
|
+
}
|
|
363
|
+
if (refreshToken === deadRefreshToken || Date.now() < transientRefreshFailureUntil) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
220
366
|
|
|
367
|
+
try {
|
|
221
368
|
const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
|
|
222
369
|
if (result && result.success !== false) {
|
|
223
370
|
const newAccessToken = (result as { access_token?: string })?.access_token;
|
|
@@ -227,13 +374,17 @@ export const checkAndRefreshAuth = async (
|
|
|
227
374
|
// Validate the new token is not expired before storing
|
|
228
375
|
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
229
376
|
console.error('Refreshed access token is already expired - clearing auth state');
|
|
377
|
+
markRefreshTokenDead(refreshToken);
|
|
230
378
|
signOutPrivacyCenter();
|
|
231
379
|
return false;
|
|
232
380
|
}
|
|
233
381
|
sessionStorage.setItem('access_token', newAccessToken);
|
|
382
|
+
// Success — re-arm the circuit breaker.
|
|
383
|
+
resetRefreshCircuit();
|
|
234
384
|
} else {
|
|
235
385
|
// No access token in response - refresh failed
|
|
236
386
|
console.error('Refresh response missing access token - clearing auth state');
|
|
387
|
+
markRefreshTokenDead(refreshToken);
|
|
237
388
|
signOutPrivacyCenter();
|
|
238
389
|
return false;
|
|
239
390
|
}
|
|
@@ -252,27 +403,20 @@ export const checkAndRefreshAuth = async (
|
|
|
252
403
|
} else {
|
|
253
404
|
// Refresh token expired/revoked or server returned failure
|
|
254
405
|
// This is an expected scenario - silently handle it
|
|
406
|
+
markRefreshTokenDead(refreshToken);
|
|
255
407
|
signOutPrivacyCenter();
|
|
256
408
|
return false;
|
|
257
409
|
}
|
|
258
410
|
} catch (error: any) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
errorMessage.includes('revoked') ||
|
|
263
|
-
errorMessage.includes('expired') ||
|
|
264
|
-
errorMessage.includes('Refresh token') ||
|
|
265
|
-
errorMessage.includes('Invalid refresh token') ||
|
|
266
|
-
errorMessage.includes('401') ||
|
|
267
|
-
errorMessage.includes('Unauthorized');
|
|
268
|
-
|
|
269
|
-
if (isRefreshTokenExpired) {
|
|
270
|
-
// Refresh token is expired/revoked - silently handle (expected scenario)
|
|
411
|
+
if (isTerminalRefreshError(error)) {
|
|
412
|
+
// Refresh token is expired / not found / already used - trip the breaker.
|
|
413
|
+
markRefreshTokenDead(refreshToken);
|
|
271
414
|
signOutPrivacyCenter();
|
|
272
415
|
return false;
|
|
273
416
|
}
|
|
274
417
|
|
|
275
|
-
//
|
|
418
|
+
// Transient (network / 5xx): keep auth state, back off, retry on a later call.
|
|
419
|
+
noteTransientRefreshFailure();
|
|
276
420
|
console.error('[Token Refresh] Unexpected error refreshing token:', error);
|
|
277
421
|
return false;
|
|
278
422
|
}
|
|
@@ -323,12 +467,10 @@ export const signOutPrivacyCenter = (): void => {
|
|
|
323
467
|
sessionStorage.removeItem(key);
|
|
324
468
|
});
|
|
325
469
|
|
|
326
|
-
// Clear refresh token
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
document.cookie =
|
|
331
|
-
'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
470
|
+
// Clear the persisted refresh token (and the SDK's own legacy cookie from
|
|
471
|
+
// older versions). The host app's path=/ cookie is intentionally left alone.
|
|
472
|
+
removeStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
473
|
+
clearLegacyRefreshTokenCookie();
|
|
332
474
|
};
|
|
333
475
|
|
|
334
476
|
/**
|
|
@@ -371,6 +513,22 @@ export const ensureValidToken = async (
|
|
|
371
513
|
return accessToken;
|
|
372
514
|
}
|
|
373
515
|
|
|
516
|
+
// Circuit breaker: before any network work, consult the failure memory.
|
|
517
|
+
// (Incident 2026-06-22 — stops the /otp/refresh retry storm at its source.)
|
|
518
|
+
const activeRefreshToken = getActiveRefreshToken(authContext);
|
|
519
|
+
// A new refresh token value means the user re-authenticated — re-arm the breaker.
|
|
520
|
+
if (deadRefreshToken !== null && activeRefreshToken !== deadRefreshToken) {
|
|
521
|
+
resetRefreshCircuit();
|
|
522
|
+
}
|
|
523
|
+
// Known-dead token: do not touch the network until a different token appears.
|
|
524
|
+
if (activeRefreshToken !== null && activeRefreshToken === deadRefreshToken) {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
// Transient-failure backoff window still open: skip the network.
|
|
528
|
+
if (Date.now() < transientRefreshFailureUntil) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
374
532
|
// Token is missing or expired, need to refresh
|
|
375
533
|
// Use lock to prevent concurrent refresh attempts
|
|
376
534
|
if (isRefreshing && refreshPromise) {
|
|
@@ -430,26 +588,19 @@ const performTokenRefresh = async (
|
|
|
430
588
|
return null;
|
|
431
589
|
}
|
|
432
590
|
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
let refreshTokenValue: string | null = null;
|
|
437
|
-
|
|
438
|
-
if (authContext) {
|
|
439
|
-
// Props mode: use refreshToken from context
|
|
440
|
-
refreshTokenValue = authContext.refreshToken || null;
|
|
441
|
-
} else {
|
|
442
|
-
// Legacy mode: use refreshToken from cookie
|
|
443
|
-
refreshTokenValue = getCookieValue('refresh_token');
|
|
444
|
-
}
|
|
591
|
+
// Resolve the refresh token (props mode → context, legacy mode → storage) via
|
|
592
|
+
// the shared helper so the value matches what the circuit breaker keyed on.
|
|
593
|
+
const refreshTokenValue: string | null = getActiveRefreshToken(authContext);
|
|
445
594
|
|
|
446
595
|
// Validate refreshToken is a non-empty string
|
|
447
596
|
if (!refreshTokenValue || typeof refreshTokenValue !== 'string' || refreshTokenValue.trim() === '') {
|
|
448
597
|
// No refresh token available - user needs to sign in again
|
|
598
|
+
// Log presence only — never the raw token, which would leak a credential to
|
|
599
|
+
// browser log collectors.
|
|
449
600
|
console.warn('[Token Refresh] No valid refresh token available', {
|
|
450
601
|
hasAuthContext: !!authContext,
|
|
451
|
-
|
|
452
|
-
|
|
602
|
+
hasRefreshTokenInContext: !!authContext?.refreshToken?.trim(),
|
|
603
|
+
hasRefreshTokenInStorage: !!getStoredRefreshToken(),
|
|
453
604
|
});
|
|
454
605
|
const error = new Error('No refresh token available');
|
|
455
606
|
// Call onError if provided, check return value before proceeding
|
|
@@ -478,6 +629,7 @@ const performTokenRefresh = async (
|
|
|
478
629
|
// Validate the new token is not expired before storing
|
|
479
630
|
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
480
631
|
console.error('[Token Refresh] New access token is already expired');
|
|
632
|
+
markRefreshTokenDead(refreshToken);
|
|
481
633
|
const error = new Error('New access token is already expired');
|
|
482
634
|
// Call onError if provided, check return value before proceeding
|
|
483
635
|
if (authContext?.onError) {
|
|
@@ -501,11 +653,14 @@ const performTokenRefresh = async (
|
|
|
501
653
|
}
|
|
502
654
|
}
|
|
503
655
|
|
|
656
|
+
// Success — re-arm the circuit breaker so future failures are tracked afresh.
|
|
657
|
+
resetRefreshCircuit();
|
|
504
658
|
// Return the new access token directly (already stored in sessionStorage/context)
|
|
505
659
|
return newAccessToken;
|
|
506
660
|
} else {
|
|
507
661
|
// No access token in response - refresh failed
|
|
508
662
|
console.error('[Token Refresh] Response missing access token');
|
|
663
|
+
markRefreshTokenDead(refreshToken);
|
|
509
664
|
const error = new Error('Response missing access token');
|
|
510
665
|
// Call onError if provided, check return value before proceeding
|
|
511
666
|
if (authContext?.onError) {
|
|
@@ -520,6 +675,7 @@ const performTokenRefresh = async (
|
|
|
520
675
|
}
|
|
521
676
|
} else {
|
|
522
677
|
// Refresh token expired/revoked or server returned failure
|
|
678
|
+
markRefreshTokenDead(refreshToken);
|
|
523
679
|
const error = new Error('Refresh token expired or revoked');
|
|
524
680
|
// Call onError if provided, check return value before proceeding
|
|
525
681
|
if (authContext?.onError) {
|
|
@@ -533,19 +689,12 @@ const performTokenRefresh = async (
|
|
|
533
689
|
return null;
|
|
534
690
|
}
|
|
535
691
|
} catch (error: any) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
errorMessage.includes('Invalid refresh token') ||
|
|
542
|
-
errorMessage.includes('401') ||
|
|
543
|
-
errorMessage.includes('Unauthorized');
|
|
544
|
-
|
|
545
|
-
if (isRefreshTokenExpired) {
|
|
546
|
-
// Refresh token is expired/revoked - expected scenario
|
|
692
|
+
if (isTerminalRefreshError(error)) {
|
|
693
|
+
// Terminal: refresh token expired / not found / already used / 4xx. Trip the
|
|
694
|
+
// breaker so this dead token is never re-submitted (onError controls
|
|
695
|
+
// navigation only — the breaker has already tripped).
|
|
696
|
+
markRefreshTokenDead(refreshToken);
|
|
547
697
|
const err = new Error('Refresh token expired or revoked');
|
|
548
|
-
// Call onError if provided, check return value before proceeding
|
|
549
698
|
if (authContext?.onError) {
|
|
550
699
|
const shouldProceed = authContext.onError(err);
|
|
551
700
|
if (!shouldProceed) {
|
|
@@ -557,7 +706,8 @@ const performTokenRefresh = async (
|
|
|
557
706
|
return null;
|
|
558
707
|
}
|
|
559
708
|
|
|
560
|
-
//
|
|
709
|
+
// Transient (network / 5xx): keep auth state, back off, and let a later call retry.
|
|
710
|
+
noteTransientRefreshFailure();
|
|
561
711
|
console.error('[Token Refresh] Unexpected error:', error);
|
|
562
712
|
// Still call onError for unexpected errors
|
|
563
713
|
if (authContext?.onError) {
|
|
@@ -138,6 +138,9 @@
|
|
|
138
138
|
"activities": "Activities",
|
|
139
139
|
"activitiesSubtitle": "View your privacy center activity history",
|
|
140
140
|
"activity": "Activity",
|
|
141
|
+
"allCaughtUp": "You're all caught up",
|
|
142
|
+
"noActivitiesYet": "No activity yet",
|
|
143
|
+
"noActivitiesDescription": "Your privacy center activity will appear here as it happens.",
|
|
141
144
|
"myReceipt": "My Receipt",
|
|
142
145
|
"myReceiptSubtitle": "View your consent receipts",
|
|
143
146
|
"event": "Event",
|
|
@@ -386,6 +386,22 @@ export const injectPCStyles = () => {
|
|
|
386
386
|
transition: max-height 0.3s ease, padding 0.3s ease !important;
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/* Activity Timeline */
|
|
390
|
+
.pc-timeline-content {
|
|
391
|
+
transition: background-color var(--pc-transition-fast) !important;
|
|
392
|
+
}
|
|
393
|
+
.pc-timeline-row:hover .pc-timeline-content {
|
|
394
|
+
background-color: var(--pc-bg-secondary) !important;
|
|
395
|
+
}
|
|
396
|
+
.pc-timeline-item {
|
|
397
|
+
animation: pc-fadeIn 0.2s ease both;
|
|
398
|
+
}
|
|
399
|
+
@media (prefers-reduced-motion: reduce) {
|
|
400
|
+
.pc-timeline-item {
|
|
401
|
+
animation: none !important;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
389
405
|
/* Scrollbar Styles (for better UX) */
|
|
390
406
|
.pc-scrollbar::-webkit-scrollbar {
|
|
391
407
|
width: 8px;
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { default as RedactoNoticeConsent } from "./RedactoNoticeConsent";
|
|
2
2
|
export { default as RedactoNoticeConsentInline } from "./RedactoNoticeConsentInline";
|
|
3
|
+
export { default as RedactoNoticeAssisted } from "./RedactoNoticeAssisted";
|
|
4
|
+
export type { RedactoNoticeAssistedProps } from "./RedactoNoticeAssisted/RedactoNoticeAssisted";
|
|
3
5
|
|
|
4
6
|
// Guardian Verification Components
|
|
5
7
|
export { DigiLockerCallback } from "./RedactoNoticeConsent/components/DigiLockerCallback";
|
package/src/shared/api-errors.ts
CHANGED
|
@@ -52,6 +52,10 @@ interface ApiErrorDetail {
|
|
|
52
52
|
interface ApiErrorBody {
|
|
53
53
|
message?: string;
|
|
54
54
|
error_code?: string | null;
|
|
55
|
+
// Some endpoints (e.g. OTP verify) signal "this code is dead, request a fresh
|
|
56
|
+
// one" via a top-level boolean instead of an `error_code`. Captured so callers
|
|
57
|
+
// can branch on it (e.g. to distinguish an expired code from a wrong one).
|
|
58
|
+
should_request_new?: boolean;
|
|
55
59
|
// `detail` may arrive as:
|
|
56
60
|
// { message: "...", error_code: "..." } -- our MessageResponse via Ninja
|
|
57
61
|
// { detail: "..." } -- Ninja auto-401
|
|
@@ -69,16 +73,20 @@ export type ApiErrorCodeLike = ApiErrorCode | (string & {});
|
|
|
69
73
|
export class ApiError extends Error {
|
|
70
74
|
readonly status: number;
|
|
71
75
|
readonly code?: ApiErrorCodeLike;
|
|
76
|
+
// Mirrors the server's `should_request_new` flag, when present.
|
|
77
|
+
readonly shouldRequestNew?: boolean;
|
|
72
78
|
|
|
73
79
|
constructor(opts: {
|
|
74
80
|
status: number;
|
|
75
81
|
code?: ApiErrorCodeLike;
|
|
76
82
|
message: string;
|
|
83
|
+
shouldRequestNew?: boolean;
|
|
77
84
|
}) {
|
|
78
85
|
super(opts.message);
|
|
79
86
|
this.name = "ApiError";
|
|
80
87
|
this.status = opts.status;
|
|
81
88
|
this.code = opts.code;
|
|
89
|
+
this.shouldRequestNew = opts.shouldRequestNew;
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -167,5 +175,6 @@ export async function parseApiError(
|
|
|
167
175
|
status: response.status,
|
|
168
176
|
code,
|
|
169
177
|
message,
|
|
178
|
+
shouldRequestNew: body?.should_request_new,
|
|
170
179
|
});
|
|
171
180
|
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
2
|
-
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import { createColumnHelper } from '@tanstack/react-table';
|
|
4
|
-
import { PCText, PCTooltip } from '../ui';
|
|
5
|
-
import { pcStyles } from '../styles/pcStyles';
|
|
6
|
-
import dayjs from 'dayjs';
|
|
7
|
-
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
8
|
-
import type { Activity } from '../lib/types';
|
|
9
|
-
import StatusBadge from '../ui/StatusBadge';
|
|
10
|
-
import { getActivityTypeStatus, formatActivityType } from '../lib/utils';
|
|
11
|
-
import {
|
|
12
|
-
ACTIVITY_COLUMN_SIZES,
|
|
13
|
-
ACTIVITY_DESCRIPTION_TRUNCATE_LENGTH,
|
|
14
|
-
ACTIVITY_DATE_FORMATS,
|
|
15
|
-
} from '../lib/constants';
|
|
16
|
-
|
|
17
|
-
dayjs.extend(relativeTime);
|
|
18
|
-
|
|
19
|
-
const columnHelper = createColumnHelper<Activity>();
|
|
20
|
-
|
|
21
|
-
export const useActivitiesColumns = () => {
|
|
22
|
-
const { t } = useTranslation();
|
|
23
|
-
return useMemo(
|
|
24
|
-
() => [
|
|
25
|
-
columnHelper.accessor('title', {
|
|
26
|
-
id: 'title',
|
|
27
|
-
minSize: ACTIVITY_COLUMN_SIZES.TITLE,
|
|
28
|
-
header: t('activity'),
|
|
29
|
-
cell: (info) => {
|
|
30
|
-
const row = info.row.original;
|
|
31
|
-
const title = row.title_display || info.getValue();
|
|
32
|
-
return (
|
|
33
|
-
<div style={{ paddingTop: pcStyles.pySm.paddingTop, paddingBottom: pcStyles.pySm.paddingBottom }}>
|
|
34
|
-
<PCText size="sm" weight="medium" color="primary">
|
|
35
|
-
{title}
|
|
36
|
-
</PCText>
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
},
|
|
40
|
-
enableSorting: false,
|
|
41
|
-
enableColumnFilter: false,
|
|
42
|
-
}),
|
|
43
|
-
columnHelper.accessor('activity_type', {
|
|
44
|
-
id: 'activity_type',
|
|
45
|
-
minSize: ACTIVITY_COLUMN_SIZES.ACTIVITY_TYPE,
|
|
46
|
-
header: t('activityType'),
|
|
47
|
-
cell: (info) => {
|
|
48
|
-
const activityType = info.getValue();
|
|
49
|
-
const row = info.row.original;
|
|
50
|
-
const displayType = row.activity_type_display || formatActivityType(activityType);
|
|
51
|
-
const status = getActivityTypeStatus(activityType);
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<div style={{ paddingTop: pcStyles.pySm.paddingTop, paddingBottom: pcStyles.pySm.paddingBottom }}>
|
|
55
|
-
<StatusBadge status={status} className="pc-status-badge">
|
|
56
|
-
{displayType}
|
|
57
|
-
</StatusBadge>
|
|
58
|
-
</div>
|
|
59
|
-
);
|
|
60
|
-
},
|
|
61
|
-
enableSorting: false,
|
|
62
|
-
enableColumnFilter: false,
|
|
63
|
-
}),
|
|
64
|
-
columnHelper.accessor('description', {
|
|
65
|
-
id: 'description',
|
|
66
|
-
minSize: ACTIVITY_COLUMN_SIZES.DESCRIPTION,
|
|
67
|
-
header: t('activityDescription'),
|
|
68
|
-
cell: (info) => {
|
|
69
|
-
const row = info.row.original;
|
|
70
|
-
const description = row.description_display || info.getValue();
|
|
71
|
-
const truncatedDescription =
|
|
72
|
-
description.length > ACTIVITY_DESCRIPTION_TRUNCATE_LENGTH
|
|
73
|
-
? `${description.substring(0, ACTIVITY_DESCRIPTION_TRUNCATE_LENGTH)}...`
|
|
74
|
-
: description;
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<div style={{ paddingTop: pcStyles.pySm.paddingTop, paddingBottom: pcStyles.pySm.paddingBottom }}>
|
|
78
|
-
{description.length > ACTIVITY_DESCRIPTION_TRUNCATE_LENGTH ? (
|
|
79
|
-
<PCTooltip content={description} placement="top">
|
|
80
|
-
<span style={{ display: 'block', cursor: 'help' }}>
|
|
81
|
-
<PCText size="sm" color="secondary" style={{
|
|
82
|
-
display: '-webkit-box',
|
|
83
|
-
WebkitLineClamp: 2,
|
|
84
|
-
WebkitBoxOrient: 'vertical',
|
|
85
|
-
overflow: 'hidden',
|
|
86
|
-
}}>
|
|
87
|
-
{truncatedDescription}
|
|
88
|
-
</PCText>
|
|
89
|
-
</span>
|
|
90
|
-
</PCTooltip>
|
|
91
|
-
) : (
|
|
92
|
-
<PCText size="sm" color="secondary">
|
|
93
|
-
{description}
|
|
94
|
-
</PCText>
|
|
95
|
-
)}
|
|
96
|
-
</div>
|
|
97
|
-
);
|
|
98
|
-
},
|
|
99
|
-
enableSorting: false,
|
|
100
|
-
enableColumnFilter: false,
|
|
101
|
-
}),
|
|
102
|
-
columnHelper.accessor('timestamp', {
|
|
103
|
-
id: 'timestamp',
|
|
104
|
-
minSize: ACTIVITY_COLUMN_SIZES.TIMESTAMP,
|
|
105
|
-
header: t('timestamp'),
|
|
106
|
-
cell: (info) => {
|
|
107
|
-
const timestamp = info.getValue();
|
|
108
|
-
const date = dayjs(timestamp);
|
|
109
|
-
const formattedDateTime = date.format(ACTIVITY_DATE_FORMATS.DISPLAY);
|
|
110
|
-
const relativeTime = date.fromNow();
|
|
111
|
-
|
|
112
|
-
return (
|
|
113
|
-
<div style={{ paddingTop: pcStyles.pySm.paddingTop, paddingBottom: pcStyles.pySm.paddingBottom }}>
|
|
114
|
-
<PCTooltip
|
|
115
|
-
content={date.format(ACTIVITY_DATE_FORMATS.TOOLTIP)}
|
|
116
|
-
placement="top"
|
|
117
|
-
>
|
|
118
|
-
<div style={{
|
|
119
|
-
...pcStyles.flex,
|
|
120
|
-
...pcStyles.flexCol,
|
|
121
|
-
minWidth: '120px',
|
|
122
|
-
gap: '0.125rem',
|
|
123
|
-
}}>
|
|
124
|
-
<PCText size="sm" weight="medium" color="primary">
|
|
125
|
-
{formattedDateTime}
|
|
126
|
-
</PCText>
|
|
127
|
-
<PCText size="sm" color="secondary" style={{ fontSize: '0.75rem' }}>
|
|
128
|
-
{relativeTime}
|
|
129
|
-
</PCText>
|
|
130
|
-
</div>
|
|
131
|
-
</PCTooltip>
|
|
132
|
-
</div>
|
|
133
|
-
);
|
|
134
|
-
},
|
|
135
|
-
enableSorting: false,
|
|
136
|
-
enableColumnFilter: false,
|
|
137
|
-
}),
|
|
138
|
-
],
|
|
139
|
-
[t]
|
|
140
|
-
);
|
|
141
|
-
};
|