@redacto.io/consent-sdk-react 9.3.0 → 9.4.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 +12 -12
- package/CHANGELOG.md +13 -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 +5 -1
- package/dist/privacy-center.d.ts +5 -1
- package/dist/privacy-center.js +2958 -2583
- package/dist/privacy-center.mjs +2666 -2285
- 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/AppShell/AppShell.tsx +5 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +6 -16
- package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +36 -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/PrivacyCenterSessionExpired.tsx +97 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +25 -0
- package/src/RedactoPrivacyCenter/components/types.ts +32 -1
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +23 -2
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +92 -0
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +15 -3
- package/src/RedactoPrivacyCenter/context/types.ts +4 -0
- 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.session-expired.test.ts +109 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +265 -139
- package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/en/translation.json +5 -0
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -0
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -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,57 @@
|
|
|
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
|
+
|
|
31
|
+
// ─── Terminal "session expired" signal ──────────────────────────────────────
|
|
32
|
+
// The circuit breaker above stops the *network* (a dead refresh token costs one
|
|
33
|
+
// request). This listener lets the React tree learn that the session is
|
|
34
|
+
// terminally over so it can show a dedicated "session expired" screen and gate
|
|
35
|
+
// every further fetch — instead of silently rendering empty/stale content or
|
|
36
|
+
// hard-navigating away. A single module-level listener, consistent with the
|
|
37
|
+
// single-instance breaker singleton above (one Privacy Center per page).
|
|
38
|
+
let sessionExpiredListener: (() => void) | null = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register (or clear, with `null`) the callback fired when a refresh terminally
|
|
42
|
+
* fails — i.e. the refresh token is expired/invalid and re-authentication is
|
|
43
|
+
* required. AuthProvider registers its `markSessionExpired` here. Exported for
|
|
44
|
+
* that wiring and for tests; not part of the package's public API.
|
|
45
|
+
*/
|
|
46
|
+
export const setSessionExpiredListener = (fn: (() => void) | null): void => {
|
|
47
|
+
sessionExpiredListener = fn;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Notify the React tree that the session has terminally expired. */
|
|
51
|
+
const notifySessionExpired = (): void => {
|
|
52
|
+
sessionExpiredListener?.();
|
|
53
|
+
};
|
|
54
|
+
|
|
8
55
|
// Buffer time in seconds before token expiration to proactively refresh
|
|
9
56
|
// Using 30 seconds to ensure tokens are refreshed before they expire
|
|
10
57
|
const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
|
|
@@ -20,25 +67,140 @@ export const getCookieValue = (name: string) => {
|
|
|
20
67
|
return null;
|
|
21
68
|
};
|
|
22
69
|
|
|
70
|
+
// ─── Refresh token persistence ──────────────────────────────────────────────
|
|
71
|
+
// The refresh token lives in localStorage (key REFRESH_TOKEN_STORAGE_KEY), not a
|
|
72
|
+
// cookie. A path-scoped cookie is not readable when the host mounts the privacy
|
|
73
|
+
// center under a basePath (e.g. consent-app at /consent → /consent/privacy-center/...),
|
|
74
|
+
// so a rotated token can't be read back and the stale token gets re-presented,
|
|
75
|
+
// causing a 401 "Refresh token not found or already used" storm. localStorage is
|
|
76
|
+
// origin-scoped (no path matching), so it is readable under any basePath.
|
|
77
|
+
//
|
|
78
|
+
// localStorage access throws when storage is disabled or over quota (private
|
|
79
|
+
// modes, embedded webviews). Guard every access so the SDK degrades to "refresh
|
|
80
|
+
// fails -> re-auth" instead of crashing the render tree (setRefreshTokenCookie
|
|
81
|
+
// runs during AuthProvider's render). Mirrors components/AppShell/hooks/useSidebarState.ts.
|
|
82
|
+
const readStorage = (key: string): string | null => {
|
|
83
|
+
try {
|
|
84
|
+
return window.localStorage.getItem(key);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const writeStorage = (key: string, value: string): void => {
|
|
91
|
+
try {
|
|
92
|
+
window.localStorage.setItem(key, value);
|
|
93
|
+
} catch {
|
|
94
|
+
// localStorage unavailable / quota exceeded — ignore
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const removeStorage = (key: string): void => {
|
|
99
|
+
try {
|
|
100
|
+
window.localStorage.removeItem(key);
|
|
101
|
+
} catch {
|
|
102
|
+
// localStorage unavailable — ignore
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
23
106
|
/**
|
|
24
|
-
*
|
|
107
|
+
* Expire the SDK's own legacy refresh_token cookie (older versions wrote it at
|
|
108
|
+
* path=/privacy-center). Scoped to that path only on purpose: a refresh_token
|
|
109
|
+
* cookie at path=/ belongs to the host app, and clearing it would sign the host
|
|
110
|
+
* out.
|
|
111
|
+
*/
|
|
112
|
+
const clearLegacyRefreshTokenCookie = (): void => {
|
|
113
|
+
if (typeof window === 'undefined') {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
document.cookie = `${LEGACY_REFRESH_TOKEN_COOKIE}=; path=/privacy-center; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Persist the refresh token. Named `setRefreshTokenCookie` for backward
|
|
121
|
+
* compatibility (it is part of the package's exported surface); it now writes
|
|
122
|
+
* to localStorage rather than a cookie, and clears any cookie an older SDK
|
|
123
|
+
* version left at path=/privacy-center.
|
|
25
124
|
* @param refreshToken - The refresh token value to set
|
|
26
125
|
*/
|
|
27
126
|
export const setRefreshTokenCookie = (refreshToken: string): void => {
|
|
28
127
|
if (typeof window === 'undefined') {
|
|
29
128
|
return;
|
|
30
129
|
}
|
|
130
|
+
writeStorage(REFRESH_TOKEN_STORAGE_KEY, refreshToken);
|
|
131
|
+
clearLegacyRefreshTokenCookie();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read the persisted refresh token from localStorage.
|
|
136
|
+
*
|
|
137
|
+
* Intentionally does NOT fall back to reading a `refresh_token` cookie: a cookie
|
|
138
|
+
* read is name-only (the path isn't visible to JS), so it cannot tell the SDK's
|
|
139
|
+
* own legacy cookie from a host app's unrelated `refresh_token` cookie — reading
|
|
140
|
+
* the latter would leak a host credential to the OTP refresh endpoint. Existing
|
|
141
|
+
* sessions are preserved by AuthProvider re-seeding storage from the token prop.
|
|
142
|
+
*/
|
|
143
|
+
export const getStoredRefreshToken = (): string | null => {
|
|
144
|
+
if (typeof window === 'undefined') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return readStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the refresh token currently in play: from auth context (props mode) or
|
|
152
|
+
* persisted storage (legacy mode). Centralised so the circuit breaker and the
|
|
153
|
+
* refresh routine always agree on exactly which token value is being used.
|
|
154
|
+
*/
|
|
155
|
+
const getActiveRefreshToken = (authContext?: { refreshToken?: string }): string | null => {
|
|
156
|
+
if (authContext) {
|
|
157
|
+
return authContext.refreshToken && authContext.refreshToken.trim() !== ''
|
|
158
|
+
? authContext.refreshToken
|
|
159
|
+
: null;
|
|
160
|
+
}
|
|
161
|
+
if (typeof window === 'undefined') return null;
|
|
162
|
+
return getStoredRefreshToken();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clear all OTP-refresh circuit-breaker state. Called after a successful refresh
|
|
167
|
+
* and exposed for tests. Deliberately NOT called from signOutPrivacyCenter: in
|
|
168
|
+
* props mode the refresh token lives in a prop that sign-out cannot clear, so
|
|
169
|
+
* clearing the breaker there would immediately re-arm the retry storm. Recovery
|
|
170
|
+
* instead happens automatically when a *different* token value appears.
|
|
171
|
+
*/
|
|
172
|
+
export const resetRefreshCircuit = (): void => {
|
|
173
|
+
deadRefreshToken = null;
|
|
174
|
+
transientRefreshFailureUntil = 0;
|
|
175
|
+
transientRefreshBackoffMs = 0;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when attempting a refresh right now would be pointless — the active refresh
|
|
180
|
+
* token has already terminally failed, or we're inside the transient backoff
|
|
181
|
+
* window. Pollers consult this to avoid scheduling doomed work.
|
|
182
|
+
*/
|
|
183
|
+
export const isRefreshBlocked = (authContext?: { refreshToken?: string }): boolean => {
|
|
184
|
+
const active = getActiveRefreshToken(authContext);
|
|
185
|
+
if (active !== null && active === deadRefreshToken) return true;
|
|
186
|
+
if (Date.now() < transientRefreshFailureUntil) return true;
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
31
189
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
190
|
+
/** Record a transient refresh failure (network / 5xx) and grow the backoff window. */
|
|
191
|
+
const noteTransientRefreshFailure = (): void => {
|
|
192
|
+
transientRefreshBackoffMs = Math.min(
|
|
193
|
+
Math.max(transientRefreshBackoffMs * 2, MIN_REFRESH_BACKOFF_MS),
|
|
194
|
+
MAX_REFRESH_BACKOFF_MS,
|
|
195
|
+
);
|
|
196
|
+
transientRefreshFailureUntil = Date.now() + transientRefreshBackoffMs;
|
|
197
|
+
};
|
|
40
198
|
|
|
41
|
-
|
|
199
|
+
/** Trip the circuit breaker for a refresh token value that terminally failed. */
|
|
200
|
+
const markRefreshTokenDead = (token: string | null): void => {
|
|
201
|
+
if (token) {
|
|
202
|
+
deadRefreshToken = token;
|
|
203
|
+
}
|
|
42
204
|
};
|
|
43
205
|
|
|
44
206
|
/**
|
|
@@ -210,14 +372,24 @@ export const checkAndRefreshAuth = async (
|
|
|
210
372
|
return false;
|
|
211
373
|
}
|
|
212
374
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
375
|
+
const refreshToken = getStoredRefreshToken();
|
|
376
|
+
if (!refreshToken) {
|
|
377
|
+
// No refresh token available - terminal: surface the session-expired screen.
|
|
378
|
+
signOutPrivacyCenter();
|
|
379
|
+
notifySessionExpired();
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Circuit breaker (incident 2026-06-22): a new token re-arms; a known-dead
|
|
384
|
+
// token or an open backoff window short-circuits without hitting the network.
|
|
385
|
+
if (deadRefreshToken !== null && refreshToken !== deadRefreshToken) {
|
|
386
|
+
resetRefreshCircuit();
|
|
387
|
+
}
|
|
388
|
+
if (refreshToken === deadRefreshToken || Date.now() < transientRefreshFailureUntil) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
220
391
|
|
|
392
|
+
try {
|
|
221
393
|
const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
|
|
222
394
|
if (result && result.success !== false) {
|
|
223
395
|
const newAccessToken = (result as { access_token?: string })?.access_token;
|
|
@@ -227,14 +399,20 @@ export const checkAndRefreshAuth = async (
|
|
|
227
399
|
// Validate the new token is not expired before storing
|
|
228
400
|
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
229
401
|
console.error('Refreshed access token is already expired - clearing auth state');
|
|
402
|
+
markRefreshTokenDead(refreshToken);
|
|
230
403
|
signOutPrivacyCenter();
|
|
404
|
+
notifySessionExpired();
|
|
231
405
|
return false;
|
|
232
406
|
}
|
|
233
407
|
sessionStorage.setItem('access_token', newAccessToken);
|
|
408
|
+
// Success — re-arm the circuit breaker.
|
|
409
|
+
resetRefreshCircuit();
|
|
234
410
|
} else {
|
|
235
411
|
// No access token in response - refresh failed
|
|
236
412
|
console.error('Refresh response missing access token - clearing auth state');
|
|
413
|
+
markRefreshTokenDead(refreshToken);
|
|
237
414
|
signOutPrivacyCenter();
|
|
415
|
+
notifySessionExpired();
|
|
238
416
|
return false;
|
|
239
417
|
}
|
|
240
418
|
|
|
@@ -250,29 +428,23 @@ export const checkAndRefreshAuth = async (
|
|
|
250
428
|
}
|
|
251
429
|
return isValid;
|
|
252
430
|
} else {
|
|
253
|
-
// Refresh token expired/revoked or server returned failure
|
|
254
|
-
|
|
431
|
+
// Refresh token expired/revoked or server returned failure - terminal.
|
|
432
|
+
markRefreshTokenDead(refreshToken);
|
|
255
433
|
signOutPrivacyCenter();
|
|
434
|
+
notifySessionExpired();
|
|
256
435
|
return false;
|
|
257
436
|
}
|
|
258
437
|
} 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)
|
|
438
|
+
if (isTerminalRefreshError(error)) {
|
|
439
|
+
// Refresh token is expired / not found / already used - trip the breaker.
|
|
440
|
+
markRefreshTokenDead(refreshToken);
|
|
271
441
|
signOutPrivacyCenter();
|
|
442
|
+
notifySessionExpired();
|
|
272
443
|
return false;
|
|
273
444
|
}
|
|
274
445
|
|
|
275
|
-
//
|
|
446
|
+
// Transient (network / 5xx): keep auth state, back off, retry on a later call.
|
|
447
|
+
noteTransientRefreshFailure();
|
|
276
448
|
console.error('[Token Refresh] Unexpected error refreshing token:', error);
|
|
277
449
|
return false;
|
|
278
450
|
}
|
|
@@ -323,12 +495,10 @@ export const signOutPrivacyCenter = (): void => {
|
|
|
323
495
|
sessionStorage.removeItem(key);
|
|
324
496
|
});
|
|
325
497
|
|
|
326
|
-
// Clear refresh token
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
document.cookie =
|
|
331
|
-
'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
498
|
+
// Clear the persisted refresh token (and the SDK's own legacy cookie from
|
|
499
|
+
// older versions). The host app's path=/ cookie is intentionally left alone.
|
|
500
|
+
removeStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
501
|
+
clearLegacyRefreshTokenCookie();
|
|
332
502
|
};
|
|
333
503
|
|
|
334
504
|
/**
|
|
@@ -371,6 +541,22 @@ export const ensureValidToken = async (
|
|
|
371
541
|
return accessToken;
|
|
372
542
|
}
|
|
373
543
|
|
|
544
|
+
// Circuit breaker: before any network work, consult the failure memory.
|
|
545
|
+
// (Incident 2026-06-22 — stops the /otp/refresh retry storm at its source.)
|
|
546
|
+
const activeRefreshToken = getActiveRefreshToken(authContext);
|
|
547
|
+
// A new refresh token value means the user re-authenticated — re-arm the breaker.
|
|
548
|
+
if (deadRefreshToken !== null && activeRefreshToken !== deadRefreshToken) {
|
|
549
|
+
resetRefreshCircuit();
|
|
550
|
+
}
|
|
551
|
+
// Known-dead token: do not touch the network until a different token appears.
|
|
552
|
+
if (activeRefreshToken !== null && activeRefreshToken === deadRefreshToken) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
// Transient-failure backoff window still open: skip the network.
|
|
556
|
+
if (Date.now() < transientRefreshFailureUntil) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
374
560
|
// Token is missing or expired, need to refresh
|
|
375
561
|
// Use lock to prevent concurrent refresh attempts
|
|
376
562
|
if (isRefreshing && refreshPromise) {
|
|
@@ -401,7 +587,9 @@ export const ensureValidToken = async (
|
|
|
401
587
|
const performTokenRefresh = async (
|
|
402
588
|
organizationId?: string,
|
|
403
589
|
workspaceId?: string,
|
|
404
|
-
slug
|
|
590
|
+
// Retained for call-site compatibility; redirect-by-slug was removed in favor
|
|
591
|
+
// of the terminal "session expired" screen, so the value is intentionally unused.
|
|
592
|
+
_slug?: string,
|
|
405
593
|
authContext?: {
|
|
406
594
|
accessToken?: string;
|
|
407
595
|
refreshToken?: string;
|
|
@@ -413,54 +601,37 @@ const performTokenRefresh = async (
|
|
|
413
601
|
// Get organizationId and workspaceId from sessionStorage if not provided
|
|
414
602
|
const orgId = organizationId || sessionStorage.getItem('pc_org_id') || '';
|
|
415
603
|
const wsId = workspaceId || sessionStorage.getItem('pc_workspace_id') || '';
|
|
416
|
-
const orgSlug = slug || sessionStorage.getItem('pc_org_slug') || '';
|
|
417
604
|
|
|
418
605
|
if (!orgId || !wsId) {
|
|
419
606
|
console.error('[Token Refresh] Missing organizationId or workspaceId');
|
|
420
607
|
const error = new Error('Missing organizationId or workspaceId');
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (!shouldProceed) {
|
|
425
|
-
return null;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
608
|
+
// Inform the host (informational only — we no longer auto-navigate; the
|
|
609
|
+
// component surfaces a terminal "session expired" screen instead).
|
|
610
|
+
authContext?.onError?.(error);
|
|
428
611
|
signOutPrivacyCenter();
|
|
429
|
-
|
|
612
|
+
notifySessionExpired();
|
|
430
613
|
return null;
|
|
431
614
|
}
|
|
432
615
|
|
|
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
|
-
}
|
|
616
|
+
// Resolve the refresh token (props mode → context, legacy mode → storage) via
|
|
617
|
+
// the shared helper so the value matches what the circuit breaker keyed on.
|
|
618
|
+
const refreshTokenValue: string | null = getActiveRefreshToken(authContext);
|
|
445
619
|
|
|
446
620
|
// Validate refreshToken is a non-empty string
|
|
447
621
|
if (!refreshTokenValue || typeof refreshTokenValue !== 'string' || refreshTokenValue.trim() === '') {
|
|
448
622
|
// No refresh token available - user needs to sign in again
|
|
623
|
+
// Log presence only — never the raw token, which would leak a credential to
|
|
624
|
+
// browser log collectors.
|
|
449
625
|
console.warn('[Token Refresh] No valid refresh token available', {
|
|
450
626
|
hasAuthContext: !!authContext,
|
|
451
|
-
|
|
452
|
-
|
|
627
|
+
hasRefreshTokenInContext: !!authContext?.refreshToken?.trim(),
|
|
628
|
+
hasRefreshTokenInStorage: !!getStoredRefreshToken(),
|
|
453
629
|
});
|
|
454
630
|
const error = new Error('No refresh token available');
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
const shouldProceed = authContext.onError(error);
|
|
458
|
-
if (!shouldProceed) {
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
631
|
+
// Inform the host (informational only — we no longer auto-navigate).
|
|
632
|
+
authContext?.onError?.(error);
|
|
462
633
|
signOutPrivacyCenter();
|
|
463
|
-
|
|
634
|
+
notifySessionExpired();
|
|
464
635
|
return null;
|
|
465
636
|
}
|
|
466
637
|
|
|
@@ -478,16 +649,12 @@ const performTokenRefresh = async (
|
|
|
478
649
|
// Validate the new token is not expired before storing
|
|
479
650
|
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
480
651
|
console.error('[Token Refresh] New access token is already expired');
|
|
652
|
+
markRefreshTokenDead(refreshToken);
|
|
481
653
|
const error = new Error('New access token is already expired');
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
const shouldProceed = authContext.onError(error);
|
|
485
|
-
if (!shouldProceed) {
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
654
|
+
// Inform the host (informational only — we no longer auto-navigate).
|
|
655
|
+
authContext?.onError?.(error);
|
|
489
656
|
signOutPrivacyCenter();
|
|
490
|
-
|
|
657
|
+
notifySessionExpired();
|
|
491
658
|
return null;
|
|
492
659
|
}
|
|
493
660
|
|
|
@@ -501,63 +668,46 @@ const performTokenRefresh = async (
|
|
|
501
668
|
}
|
|
502
669
|
}
|
|
503
670
|
|
|
671
|
+
// Success — re-arm the circuit breaker so future failures are tracked afresh.
|
|
672
|
+
resetRefreshCircuit();
|
|
504
673
|
// Return the new access token directly (already stored in sessionStorage/context)
|
|
505
674
|
return newAccessToken;
|
|
506
675
|
} else {
|
|
507
676
|
// No access token in response - refresh failed
|
|
508
677
|
console.error('[Token Refresh] Response missing access token');
|
|
678
|
+
markRefreshTokenDead(refreshToken);
|
|
509
679
|
const error = new Error('Response missing access token');
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
const shouldProceed = authContext.onError(error);
|
|
513
|
-
if (!shouldProceed) {
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
680
|
+
// Inform the host (informational only — we no longer auto-navigate).
|
|
681
|
+
authContext?.onError?.(error);
|
|
517
682
|
signOutPrivacyCenter();
|
|
518
|
-
|
|
683
|
+
notifySessionExpired();
|
|
519
684
|
return null;
|
|
520
685
|
}
|
|
521
686
|
} else {
|
|
522
|
-
// Refresh token expired/revoked or server returned failure
|
|
687
|
+
// Refresh token expired/revoked or server returned failure - terminal.
|
|
688
|
+
markRefreshTokenDead(refreshToken);
|
|
523
689
|
const error = new Error('Refresh token expired or revoked');
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
const shouldProceed = authContext.onError(error);
|
|
527
|
-
if (!shouldProceed) {
|
|
528
|
-
return null;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
690
|
+
// Inform the host (informational only — we no longer auto-navigate).
|
|
691
|
+
authContext?.onError?.(error);
|
|
531
692
|
signOutPrivacyCenter();
|
|
532
|
-
|
|
693
|
+
notifySessionExpired();
|
|
533
694
|
return null;
|
|
534
695
|
}
|
|
535
696
|
} 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
|
|
697
|
+
if (isTerminalRefreshError(error)) {
|
|
698
|
+
// Terminal: refresh token expired / not found / already used / 4xx. Trip the
|
|
699
|
+
// breaker so this dead token is never re-submitted, then surface the
|
|
700
|
+
// session-expired screen (onError is informational; we no longer navigate).
|
|
701
|
+
markRefreshTokenDead(refreshToken);
|
|
547
702
|
const err = new Error('Refresh token expired or revoked');
|
|
548
|
-
|
|
549
|
-
if (authContext?.onError) {
|
|
550
|
-
const shouldProceed = authContext.onError(err);
|
|
551
|
-
if (!shouldProceed) {
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
703
|
+
authContext?.onError?.(err);
|
|
555
704
|
signOutPrivacyCenter();
|
|
556
|
-
|
|
705
|
+
notifySessionExpired();
|
|
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) {
|
|
@@ -568,30 +718,6 @@ const performTokenRefresh = async (
|
|
|
568
718
|
}
|
|
569
719
|
};
|
|
570
720
|
|
|
571
|
-
/**
|
|
572
|
-
* Helper to redirect to sign-in page
|
|
573
|
-
*/
|
|
574
|
-
const redirectToSignIn = (slug?: string, onError?: (error: Error) => boolean): void => {
|
|
575
|
-
if (typeof window === 'undefined') return;
|
|
576
|
-
|
|
577
|
-
// If onError is provided, call it and check return value
|
|
578
|
-
if (onError) {
|
|
579
|
-
const error = new Error('Authentication failed - redirecting to sign-in');
|
|
580
|
-
const shouldProceed = onError(error);
|
|
581
|
-
if (!shouldProceed) {
|
|
582
|
-
// User wants to handle navigation themselves
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const orgSlug = slug || sessionStorage.getItem('pc_org_slug') || '';
|
|
588
|
-
if (orgSlug) {
|
|
589
|
-
window.location.href = `/privacy-center/${orgSlug}/sign-in`;
|
|
590
|
-
} else {
|
|
591
|
-
window.location.href = '/privacy-center';
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
|
|
595
721
|
// Activity helper functions
|
|
596
722
|
|
|
597
723
|
export type ActivityBadgeStatus =
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "ডেটা নাই",
|
|
185
185
|
"noDataTitle": "এতিয়ালৈকে দেখুৱাবলৈ একো নাই",
|
|
186
186
|
"noDataDescription": "আপোনাৰ একাউণ্টৰ সৈতে সংযুক্ত কোনো সন্মতি, অনুৰোধ বা কাৰ্য্যাৱলী আমি এতিয়ালৈকে বিচাৰি পোৱা নাই। উপলব্ধ হোৱাৰ লগে লগে আপোনাৰ গোপনীয়তা ৰেকৰ্ডসমূহ ইয়াত দেখা যাব।",
|
|
187
|
+
"sessionExpiredTitle": "আপোনাৰ ছেচন শেষ হৈছে",
|
|
188
|
+
"sessionExpiredDescription": "অব্যাহত ৰাখিবলৈ অনুগ্ৰহ কৰি পুনৰ ছাইন ইন কৰক।",
|
|
187
189
|
"rowsPerPage": "পৃষ্ঠাত শাৰী:",
|
|
188
190
|
"pageOf": "পৃষ্ঠা {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}}-ৰ {{count}} শাৰী নিৰ্বাচিত",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "कोनो डेटा नै",
|
|
185
185
|
"noDataTitle": "अबहीं देखावे खातिर कुछ नइखे",
|
|
186
186
|
"noDataDescription": "रउरा खाता से जुड़ल कवनो सहमति, अनुरोध, या गतिविधि अबहीं तक ना मिलल। रउरा गोपनीयता रिकॉर्ड उपलब्ध होते ई इहाँ देखाई दिही।",
|
|
187
|
+
"sessionExpiredTitle": "रउआ के सत्र खतम हो गइल बा",
|
|
188
|
+
"sessionExpiredDescription": "जारी रखे खातिर कृपया फेर से साइन इन करीं।",
|
|
187
189
|
"rowsPerPage": "पृष्ठ प्रति पंक्ति:",
|
|
188
190
|
"pageOf": "पृष्ठ {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}} में से {{count}} पंक्ति चुनल गइल",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "কোনো ডেটা নেই",
|
|
185
185
|
"noDataTitle": "এখনো দেখানোর মতো কিছু নেই",
|
|
186
186
|
"noDataDescription": "আপনার অ্যাকাউন্টের সাথে সম্পর্কিত কোনো সম্মতি, অনুরোধ বা ক্রিয়াকলাপ আমরা এখনো খুঁজে পাইনি। উপলব্ধ হওয়ার সাথে সাথেই আপনার গোপনীয়তা রেকর্ডগুলি এখানে প্রদর্শিত হবে।",
|
|
187
|
+
"sessionExpiredTitle": "আপনার সেশন মেয়াদ শেষ হয়ে গেছে",
|
|
188
|
+
"sessionExpiredDescription": "চালিয়ে যেতে অনুগ্রহ করে আবার সাইন ইন করুন।",
|
|
187
189
|
"rowsPerPage": "প্রতি পৃষ্ঠায় সারি:",
|
|
188
190
|
"pageOf": "পৃষ্ঠা {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}}-এর মধ্যে {{count}} সারি নির্বাচিত",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "डेटा नंगौ",
|
|
185
185
|
"noDataTitle": "दानो दिनथिनो थाखाय जेबो गैया",
|
|
186
186
|
"noDataDescription": "दानो सम नोंथांनि एकाउन्टजों सोमोन्दो थानाय जेबो सहमति, हाबाय एबा खामानि जों मोननो हायाखै। नोंथांनि गोपनीयथा रिकर्डफोर मोननो हानाय बादि बेयावनो दिनथिजागोन।",
|
|
187
|
+
"sessionExpiredTitle": "नोंथांनि सेसन जोबबाय",
|
|
188
|
+
"sessionExpiredDescription": "सालायनो थाखाय अननानै फिन साइन इन खालाम।",
|
|
187
189
|
"rowsPerPage": "पृष्ठनि पंक्ति:",
|
|
188
190
|
"pageOf": "पृष्ठ {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}} निफ्राय {{count}} पंक्ति सायख 'खै",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "कोनो डेटा नै",
|
|
185
185
|
"noDataTitle": "अजें दिक्खाने आस्तै किछ नेईं",
|
|
186
186
|
"noDataDescription": "तुंदे खाते कन्नै जुड़ी दी कोई बी सहमति, बेनती जां गतिविधि अजें तक नेईं मिली। तुंदे गोपनीयता रिकॉर्ड उपलब्ध होंदे गै इत्थें दिक्खने गी मिलङन।",
|
|
187
|
+
"sessionExpiredTitle": "तुंदा सत्र खतम होई गेआ ऐ",
|
|
188
|
+
"sessionExpiredDescription": "जारी रक्खने आस्तै कृपा करियै फिरी साइन इन करो।",
|
|
187
189
|
"rowsPerPage": "पृष्ठ प्रति पंक्ति:",
|
|
188
190
|
"pageOf": "पृष्ठ {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}} मध्ये {{count}} पंक्ति चुनल गेल",
|
|
@@ -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",
|
|
@@ -197,6 +200,8 @@
|
|
|
197
200
|
"noData": "No Data",
|
|
198
201
|
"noDataTitle": "Nothing to show yet",
|
|
199
202
|
"noDataDescription": "We couldn't find any consents, requests, or activity linked to your account yet. Your privacy records will appear here once they're available.",
|
|
203
|
+
"sessionExpiredTitle": "Your session has expired",
|
|
204
|
+
"sessionExpiredDescription": "Please sign in again to continue.",
|
|
200
205
|
"rowsPerPage": "Rows per page:",
|
|
201
206
|
"pageOf": "Page {{current}} of {{total}}",
|
|
202
207
|
"rowsSelected": "{{count}} of {{total}} row(s) selected",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "डेटा ना",
|
|
185
185
|
"noDataTitle": "अजून दाखोवपाक कांय ना",
|
|
186
186
|
"noDataDescription": "तुमच्या खात्याकडेन जोडिल्ली कसलीच संमती, मागण्यो वा कृती आमकां अजून मेळूंक नात. उपलब्ध जातकच तुमच्यो गोपनीयता नोंदी हांगा दिसतल्यो.",
|
|
187
|
+
"sessionExpiredTitle": "तुमचें सत्र सोंपलां",
|
|
188
|
+
"sessionExpiredDescription": "फुडें वचपाक उपकार करून परतून साइन इन करात.",
|
|
187
189
|
"rowsPerPage": "पानाक पंक्ती:",
|
|
188
190
|
"pageOf": "पान {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}} मध्ये {{count}} पंक्ती निवडली",
|
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
"noData": "ડેટા નથી",
|
|
185
185
|
"noDataTitle": "હજુ સુધી બતાવવા માટે કંઈ નથી",
|
|
186
186
|
"noDataDescription": "તમારા ખાતા સાથે જોડાયેલી કોઈ સંમતિઓ, વિનંતીઓ અથવા પ્રવૃત્તિ અમને હજુ સુધી મળી નથી. તમારા ગોપનીયતા રેકોર્ડ ઉપલબ્ધ થતાં જ અહીં દેખાશે.",
|
|
187
|
+
"sessionExpiredTitle": "તમારું સત્ર સમાપ્ત થઈ ગયું છે",
|
|
188
|
+
"sessionExpiredDescription": "ચાલુ રાખવા માટે કૃપા કરીને ફરીથી સાઇન ઇન કરો.",
|
|
187
189
|
"rowsPerPage": "દર પૃષ્ઠ પંક્તિઓ:",
|
|
188
190
|
"pageOf": "પૃષ્ઠ {{current}} / {{total}}",
|
|
189
191
|
"rowsSelected": "{{total}} માંથી {{count}} પંક્તિ પસંદ",
|