@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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +93 -12
  4. package/dist/index.d.mts +39 -1
  5. package/dist/index.d.ts +39 -1
  6. package/dist/index.js +4454 -334
  7. package/dist/index.mjs +4454 -335
  8. package/dist/privacy-center.d.mts +5 -1
  9. package/dist/privacy-center.d.ts +5 -1
  10. package/dist/privacy-center.js +2958 -2583
  11. package/dist/privacy-center.mjs +2666 -2285
  12. package/package.json +1 -1
  13. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +847 -0
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +2133 -0
  15. package/src/RedactoNoticeAssisted/api/index.ts +269 -0
  16. package/src/RedactoNoticeAssisted/api/types.ts +180 -0
  17. package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
  18. package/src/RedactoNoticeAssisted/constants.ts +51 -0
  19. package/src/RedactoNoticeAssisted/i18n.ts +1463 -0
  20. package/src/RedactoNoticeAssisted/index.ts +1 -0
  21. package/src/RedactoNoticeAssisted/injectStyles.ts +251 -0
  22. package/src/RedactoNoticeAssisted/styles.ts +730 -0
  23. package/src/RedactoNoticeAssisted/types.ts +24 -0
  24. package/src/RedactoNoticeAssisted/useMediaQuery.ts +36 -0
  25. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +8 -0
  26. package/src/RedactoPrivacyCenter/api/actions.ts +6 -11
  27. package/src/RedactoPrivacyCenter/api/client.ts +15 -3
  28. package/src/RedactoPrivacyCenter/api/fetcher.ts +80 -1
  29. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +193 -213
  30. package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +239 -0
  31. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +5 -0
  32. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +6 -16
  33. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +36 -0
  34. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +3 -1
  35. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +8 -1
  36. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +23 -17
  37. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +24 -10
  38. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +16 -3
  39. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +97 -0
  40. package/src/RedactoPrivacyCenter/components/constant.ts +25 -0
  41. package/src/RedactoPrivacyCenter/components/types.ts +32 -1
  42. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +23 -2
  43. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +92 -0
  44. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +15 -3
  45. package/src/RedactoPrivacyCenter/context/types.ts +4 -0
  46. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +25 -23
  47. package/src/RedactoPrivacyCenter/lib/constants.ts +23 -10
  48. package/src/RedactoPrivacyCenter/lib/queries.ts +9 -2
  49. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +127 -0
  50. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +85 -0
  51. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +109 -0
  52. package/src/RedactoPrivacyCenter/lib/utils.ts +265 -139
  53. package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -0
  54. package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -0
  55. package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -0
  56. package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -0
  57. package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -0
  58. package/src/RedactoPrivacyCenter/locales/en/translation.json +5 -0
  59. package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -0
  60. package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -0
  61. package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -0
  62. package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -0
  63. package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -0
  64. package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -0
  65. package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -0
  66. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -0
  67. package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -0
  68. package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -0
  69. package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -0
  70. package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -0
  71. package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -0
  72. package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -0
  73. package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -0
  74. package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -0
  75. package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -0
  76. package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -0
  77. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +16 -0
  78. package/src/index.ts +2 -0
  79. package/src/shared/api-errors.ts +9 -0
  80. 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
- * Set refresh token cookie with proper attributes
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
- const cookieAttributes = [
33
- `refresh_token=${refreshToken}`,
34
- 'path=/privacy-center',
35
- `max-age=${60 * 60 * 24 * 7}`, // 7 days
36
- `samesite=lax`,
37
- ]
38
- .filter(Boolean)
39
- .join('; ');
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
- document.cookie = cookieAttributes;
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
- try {
214
- const refreshToken = getCookieValue('refresh_token');
215
- if (!refreshToken) {
216
- // No refresh token available - user needs to sign in again
217
- signOutPrivacyCenter();
218
- return false;
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
- // This is an expected scenario - silently handle it
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
- // Check if error indicates refresh token is expired/revoked (expected scenario)
260
- const errorMessage = error?.message || '';
261
- const isRefreshTokenExpired =
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
- // For other errors (network, etc.), log and don't clear auth state - might be temporary
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 cookie set for privacy center
327
- document.cookie =
328
- 'refresh_token=; path=/privacy-center; expires=Thu, 01 Jan 1970 00:00:00 GMT';
329
- // Fallback in case the cookie path differs
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?: string,
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
- // Call onError if provided, check return value before proceeding
422
- if (authContext?.onError) {
423
- const shouldProceed = authContext.onError(error);
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
- redirectToSignIn(orgSlug, authContext?.onError);
612
+ notifySessionExpired();
430
613
  return null;
431
614
  }
432
615
 
433
- // Use context refreshToken if provided, otherwise fall back to cookie
434
- // If authContext is provided, prioritize its refreshToken (props mode)
435
- // Otherwise, fall back to cookie (legacy mode)
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
- refreshTokenFromContext: authContext?.refreshToken,
452
- refreshTokenFromCookie: getCookieValue('refresh_token'),
627
+ hasRefreshTokenInContext: !!authContext?.refreshToken?.trim(),
628
+ hasRefreshTokenInStorage: !!getStoredRefreshToken(),
453
629
  });
454
630
  const error = new Error('No refresh token available');
455
- // Call onError if provided, check return value before proceeding
456
- if (authContext?.onError) {
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
- redirectToSignIn(orgSlug, authContext?.onError);
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
- // Call onError if provided, check return value before proceeding
483
- if (authContext?.onError) {
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
- redirectToSignIn(orgSlug, authContext?.onError);
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
- // Call onError if provided, check return value before proceeding
511
- if (authContext?.onError) {
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
- redirectToSignIn(orgSlug, authContext?.onError);
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
- // Call onError if provided, check return value before proceeding
525
- if (authContext?.onError) {
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
- redirectToSignIn(orgSlug, authContext?.onError);
693
+ notifySessionExpired();
533
694
  return null;
534
695
  }
535
696
  } catch (error: any) {
536
- const errorMessage = error?.message || '';
537
- const isRefreshTokenExpired =
538
- errorMessage.includes('revoked') ||
539
- errorMessage.includes('expired') ||
540
- errorMessage.includes('Refresh token') ||
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
- // Call onError if provided, check return value before proceeding
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
- redirectToSignIn(orgSlug, authContext?.onError);
705
+ notifySessionExpired();
557
706
  return null;
558
707
  }
559
708
 
560
- // For other errors (network, etc.), log but don't clear auth state
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}} પંક્તિ પસંદ",