@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
@@ -0,0 +1,97 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Clock } from 'lucide-react';
3
+ import { PCButton, PCText, PCTitle } from '../ui';
4
+ import { pcStyles } from '../styles/pcStyles';
5
+ import { getVar } from '../lib/utils';
6
+ import { usePrivacyCenterExitAction } from './AppShell/usePrivacyCenterExitAction';
7
+
8
+ type PrivacyCenterSessionExpiredProps = {
9
+ onBack?: 'back' | 'signout';
10
+ };
11
+
12
+ /**
13
+ * Terminal screen shown when the session has expired (the refresh token is
14
+ * dead). Purely presentational — no data fetching happens behind it. Mirrors
15
+ * PrivacyCenterEmptyState's layout. The exit is MANUAL: an inline CTA (and the
16
+ * top-bar UserMenu item) reuse the consumer's existing `onBack` prop; nothing
17
+ * navigates automatically. When `onBack` is absent, only the message shows.
18
+ */
19
+ export function PrivacyCenterSessionExpired({
20
+ onBack,
21
+ }: PrivacyCenterSessionExpiredProps) {
22
+ const { t } = useTranslation();
23
+ const { enabled, handleAction, label, Icon } = usePrivacyCenterExitAction(onBack);
24
+
25
+ return (
26
+ <div
27
+ style={{
28
+ ...pcStyles.flex,
29
+ ...pcStyles.flexCol,
30
+ ...pcStyles.itemsCenter,
31
+ ...pcStyles.justifyCenter,
32
+ ...pcStyles.textCenter,
33
+ width: '100%',
34
+ maxWidth: 460,
35
+ margin: '0 auto',
36
+ minHeight: 'min(62vh, 540px)',
37
+ paddingTop: getVar('--pc-spacing-2xl'),
38
+ paddingBottom: getVar('--pc-spacing-2xl'),
39
+ gap: getVar('--pc-spacing-xl'),
40
+ }}
41
+ >
42
+ <div
43
+ aria-hidden="true"
44
+ style={{
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ width: 168,
49
+ height: 168,
50
+ borderRadius: '50%',
51
+ background:
52
+ 'color-mix(in srgb, var(--pc-primary) 8%, var(--pc-bg-shell))',
53
+ flexShrink: 0,
54
+ }}
55
+ >
56
+ <Clock size={72} strokeWidth={1.5} color={getVar('--pc-primary')} />
57
+ </div>
58
+
59
+ <div
60
+ style={{
61
+ ...pcStyles.flex,
62
+ ...pcStyles.flexCol,
63
+ ...pcStyles.itemsCenter,
64
+ gap: getVar('--pc-spacing-sm'),
65
+ }}
66
+ >
67
+ <PCTitle as="h2" style={{ margin: 0 }}>
68
+ {t('sessionExpiredTitle')}
69
+ </PCTitle>
70
+ <PCText
71
+ as="p"
72
+ size="base"
73
+ color="secondary"
74
+ align="center"
75
+ style={{ margin: 0, maxWidth: 400 }}
76
+ >
77
+ {t('sessionExpiredDescription')}
78
+ </PCText>
79
+ </div>
80
+
81
+ {enabled && (
82
+ <PCButton
83
+ variant="primary"
84
+ onClick={handleAction}
85
+ style={{
86
+ display: 'inline-flex',
87
+ alignItems: 'center',
88
+ gap: 8,
89
+ }}
90
+ >
91
+ <Icon size={16} strokeWidth={2} />
92
+ <span>{label}</span>
93
+ </PCButton>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -1,3 +1,5 @@
1
+ import type { BadgeVariant, MarkerTint } from './types';
2
+
1
3
  export const GRIEVANCE_TAB_IDS = {
2
4
  ALL: 'all',
3
5
  PROCESSING: 'processing',
@@ -14,3 +16,26 @@ export {
14
16
 
15
17
 
16
18
  export const MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL = 3;
19
+
20
+ // ─── Activity timeline ──────────────────────────────────────────────────────
21
+
22
+ export const TIMELINE_MARKER_SIZE = 30;
23
+ export const TIMELINE_TIME_COL = '64px';
24
+ export const TIMELINE_MARKER_COL = '34px';
25
+ export const TIMELINE_COL_GAP = 12;
26
+ // Rail sits at the centre of the marker column: time col + gap + half marker col.
27
+ export const TIMELINE_RAIL_LEFT = 64 + TIMELINE_COL_GAP + 17;
28
+
29
+ // Marker tint per variant — mirrors the PCBadge palette so tag + marker match.
30
+ // Literal `var(--…)` strings (same as getVar) avoid a constants↔utils import cycle.
31
+ export const MARKER_TINT: Record<BadgeVariant, MarkerTint> = {
32
+ success: { color: 'var(--pc-success)', bg: 'rgba(16, 185, 129, 0.12)' },
33
+ error: { color: 'var(--pc-error)', bg: 'rgba(239, 68, 68, 0.12)' },
34
+ warning: { color: 'var(--pc-warning)', bg: 'rgba(245, 158, 11, 0.13)' },
35
+ info: { color: 'var(--pc-info)', bg: 'rgba(59, 130, 246, 0.12)' },
36
+ secondary: { color: 'var(--pc-text-secondary)', bg: 'var(--pc-bg-tertiary)' },
37
+ };
38
+
39
+ // Internal error thrown when an activity page fails to load (surfaced to the
40
+ // user via the i18n `failedToFetchActivityData` toast).
41
+ export const ACTIVITY_FETCH_ERROR_MESSAGE = 'Failed to fetch activity data';
@@ -1,5 +1,7 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { LucideProps } from 'lucide-react';
2
3
  import type {
4
+ Activity,
3
5
  CaseRequest,
4
6
  ConsentModalRecord,
5
7
  } from '../lib/types';
@@ -90,3 +92,32 @@ export type ModifyConsentModalProps = {
90
92
  ) => void;
91
93
  };
92
94
 
95
+ // ─── Activity timeline ──────────────────────────────────────────────────────
96
+
97
+ export type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'secondary';
98
+
99
+ export type ActivityCategory = {
100
+ variant: BadgeVariant;
101
+ Icon: ComponentType<LucideProps>;
102
+ };
103
+
104
+ export type MarkerTint = Readonly<{
105
+ color: string;
106
+ bg: string;
107
+ }>;
108
+
109
+ export type ActivityDateGroup = {
110
+ key: string;
111
+ label: string;
112
+ newest: string;
113
+ items: Activity[];
114
+ };
115
+
116
+ export type ActivityTimelineProps = Readonly<{
117
+ activities: Activity[];
118
+ }>;
119
+
120
+ export type TimelineRowProps = Readonly<{
121
+ activity: Activity;
122
+ }>;
123
+
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
2
- import { setRefreshTokenCookie, decodeJwtToken } from '../lib/utils';
2
+ import { setRefreshTokenCookie, decodeJwtToken, setSessionExpiredListener } from '../lib/utils';
3
3
  import type { AuthContextValue } from './types';
4
4
 
5
5
  export type { AuthContextValue } from './types';
@@ -80,6 +80,21 @@ export const AuthProvider = ({
80
80
  return initialRefreshToken;
81
81
  });
82
82
 
83
+ // Terminal "session expired" flag. Set by utils' refresh routines (via the
84
+ // module listener registered below) when the refresh token is dead; reset when
85
+ // a new refresh token appears (re-auth) so the breaker and the screen recover
86
+ // together. Drives the shared session-expired screen and gates all fetching.
87
+ const [sessionExpired, setSessionExpired] = useState(false);
88
+ const markSessionExpired = useCallback(() => setSessionExpired(true), []);
89
+
90
+ // Bridge the module-level refresh routines to React state. utils calls the
91
+ // registered listener on terminal failure; this is the same single-instance
92
+ // singleton model as the OTP refresh circuit breaker.
93
+ useEffect(() => {
94
+ setSessionExpiredListener(markSessionExpired);
95
+ return () => setSessionExpiredListener(null);
96
+ }, [markSessionExpired]);
97
+
83
98
  // Re-sync when props change (for token refresh scenarios)
84
99
  useEffect(() => {
85
100
  if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
@@ -94,6 +109,9 @@ export const AuthProvider = ({
94
109
  useEffect(() => {
95
110
  if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
96
111
  setRefreshToken(initialRefreshToken);
112
+ // A fresh refresh token means the user re-authenticated — clear the
113
+ // terminal state (the breaker auto-re-arms on the next ensureValidToken).
114
+ setSessionExpired(false);
97
115
  if (typeof window !== 'undefined') {
98
116
  setRefreshTokenCookie(initialRefreshToken);
99
117
  }
@@ -106,6 +124,8 @@ export const AuthProvider = ({
106
124
  if (newRefreshToken) {
107
125
  setRefreshToken(newRefreshToken);
108
126
  }
127
+ // A successful refresh clears any prior terminal state.
128
+ setSessionExpired(false);
109
129
  // Also update storage
110
130
  if (typeof window !== 'undefined') {
111
131
  sessionStorage.setItem('access_token', newAccessToken);
@@ -123,7 +143,8 @@ export const AuthProvider = ({
123
143
  refreshToken,
124
144
  onError,
125
145
  updateTokens,
126
- }), [accessToken, refreshToken, onError, updateTokens]);
146
+ sessionExpired,
147
+ }), [accessToken, refreshToken, onError, updateTokens, sessionExpired]);
127
148
 
128
149
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
129
150
  };
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { render, screen, waitFor, cleanup } from '@testing-library/react';
3
+ import {
4
+ DataAvailabilityProvider,
5
+ useDataAvailability,
6
+ } from './DataAvailabilityContext';
7
+
8
+ /**
9
+ * Regression: when the session is terminally expired, the DataAvailabilityProvider
10
+ * must NOT fire any data fetch on mount — it goes straight to the 'session-expired'
11
+ * status so the shell renders the terminal screen instead of (fail-open) content.
12
+ */
13
+
14
+ // Mutable auth state shared with the hoisted mock so each test can flip it.
15
+ const auth = vi.hoisted(() => ({
16
+ current: {
17
+ accessToken: 'stale-access-token',
18
+ refreshToken: 'dead-refresh-token',
19
+ onError: () => false,
20
+ updateTokens: () => {},
21
+ sessionExpired: true,
22
+ },
23
+ }));
24
+
25
+ vi.mock('../lib/api', () => ({
26
+ fetchUserConsents: vi.fn(() => Promise.resolve(null)),
27
+ fetchActivities: vi.fn(() => Promise.resolve(null)),
28
+ fetchCaseHistory: vi.fn(() => Promise.resolve(null)),
29
+ }));
30
+
31
+ vi.mock('./AuthContext', () => ({ useAuth: () => auth.current }));
32
+ vi.mock('./OrganizationContext', () => ({
33
+ useOrganization: () => ({
34
+ organization: { uuid: 'org-uuid', workspaces: [{ uuid: 'ws-uuid' }] },
35
+ }),
36
+ }));
37
+ vi.mock('./BaseUrlContext', () => ({ useBaseUrl: () => ({ consentClient: {} }) }));
38
+ vi.mock('../lib/useCurrentLanguage', () => ({
39
+ useCurrentLanguage: () => ({ langParam: 'en' }),
40
+ }));
41
+
42
+ import { fetchUserConsents, fetchActivities, fetchCaseHistory } from '../lib/api';
43
+
44
+ function StatusProbe() {
45
+ const { status } = useDataAvailability();
46
+ return <div data-testid="status">{status}</div>;
47
+ }
48
+
49
+ describe('DataAvailabilityProvider — session-expired gate', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ sessionStorage.clear();
53
+ sessionStorage.setItem('email', 'user@example.com');
54
+ auth.current = {
55
+ accessToken: 'stale-access-token',
56
+ refreshToken: 'dead-refresh-token',
57
+ onError: () => false,
58
+ updateTokens: () => {},
59
+ sessionExpired: true,
60
+ };
61
+ });
62
+
63
+ afterEach(() => cleanup());
64
+
65
+ it('does not fetch and reports session-expired when the session is expired', () => {
66
+ auth.current.sessionExpired = true;
67
+
68
+ render(
69
+ <DataAvailabilityProvider>
70
+ <StatusProbe />
71
+ </DataAvailabilityProvider>,
72
+ );
73
+
74
+ expect(screen.getByTestId('status').textContent).toBe('session-expired');
75
+ expect(fetchUserConsents).not.toHaveBeenCalled();
76
+ expect(fetchActivities).not.toHaveBeenCalled();
77
+ expect(fetchCaseHistory).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('does fetch when the session is NOT expired (gate is meaningful)', async () => {
81
+ auth.current.sessionExpired = false;
82
+
83
+ render(
84
+ <DataAvailabilityProvider>
85
+ <StatusProbe />
86
+ </DataAvailabilityProvider>,
87
+ );
88
+
89
+ await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
90
+ expect(fetchActivities).toHaveBeenCalled();
91
+ });
92
+ });
@@ -14,7 +14,11 @@ import { useOrganization } from './OrganizationContext';
14
14
  import { useAuth } from './AuthContext';
15
15
  import { useBaseUrl } from './BaseUrlContext';
16
16
 
17
- export type DataAvailabilityStatus = 'checking' | 'has-data' | 'empty';
17
+ export type DataAvailabilityStatus =
18
+ | 'checking'
19
+ | 'has-data'
20
+ | 'empty'
21
+ | 'session-expired';
18
22
 
19
23
  type DataAvailabilityContextValue = {
20
24
  status: DataAvailabilityStatus;
@@ -36,7 +40,7 @@ const DataAvailabilityContext = createContext<DataAvailabilityContextValue | nul
36
40
  */
37
41
  export const DataAvailabilityProvider = ({ children }: { children: ReactNode }) => {
38
42
  const { organization } = useOrganization();
39
- const { accessToken, refreshToken, onError, updateTokens } = useAuth();
43
+ const { accessToken, refreshToken, onError, updateTokens, sessionExpired } = useAuth();
40
44
  const { consentClient } = useBaseUrl();
41
45
  const { langParam } = useCurrentLanguage();
42
46
 
@@ -141,10 +145,18 @@ export const DataAvailabilityProvider = ({ children }: { children: ReactNode })
141
145
  }, [organizationId, workspaceId, authContext, consentClient, langParam]);
142
146
 
143
147
  useEffect(() => {
148
+ // Terminal session: never fetch. Bump the run id so any in-flight runCheck
149
+ // resolves into a no-op (its fail-open path would otherwise flip the status
150
+ // back to 'has-data' and remount the content), then show the expired screen.
151
+ if (sessionExpired) {
152
+ runIdRef.current += 1;
153
+ setStatus('session-expired');
154
+ return;
155
+ }
144
156
  if (organizationId && workspaceId && accessToken) {
145
157
  void runCheck();
146
158
  }
147
- }, [organizationId, workspaceId, accessToken, runCheck]);
159
+ }, [organizationId, workspaceId, accessToken, runCheck, sessionExpired]);
148
160
 
149
161
  const value = useMemo<DataAvailabilityContextValue>(
150
162
  () => ({ status }),
@@ -6,6 +6,10 @@ export type AuthContextValue = Readonly<{
6
6
  onError: (error: Error) => boolean;
7
7
  // Internal: updates tokens after refresh
8
8
  updateTokens: (accessToken: string, refreshToken?: string) => void;
9
+ // Terminal: true once a refresh failed because the refresh token is
10
+ // expired/invalid. Consumers gate fetching/rendering on this to show the
11
+ // shared "session expired" screen and stop all further work.
12
+ sessionExpired: boolean;
9
13
  }>;
10
14
 
11
15
  export type BaseUrlContextValue = Readonly<{
@@ -1,14 +1,19 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { ConsentStatusEnum } from '../types';
4
- import type { PurposeItem } from '../types';
5
-
6
- type CsvRow = Partial<PurposeItem> & Record<string, unknown>;
7
4
  import { formatDateTime as formatDateTimeShared } from '../../utils/date';
8
5
 
9
6
  // Re-export shared date formatting
10
7
  export const formatDateTime = formatDateTimeShared;
11
8
 
9
+ // Leading characters that spreadsheet apps (Excel/Sheets) may interpret as a
10
+ // formula; values starting with these are prefixed with a quote to neutralize
11
+ // CSV formula injection.
12
+ const CSV_FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
13
+
14
+ // Byte-order mark so Excel detects UTF-8 and renders localized labels correctly.
15
+ const UTF8_BOM = '\uFEFF';
16
+
12
17
  export const useStatusOptions = () => {
13
18
  const { t } = useTranslation();
14
19
  return useMemo(
@@ -22,35 +27,32 @@ export const useStatusOptions = () => {
22
27
  );
23
28
  };
24
29
 
25
- export const exportTableToCSV = (rows: CsvRow[]) => {
30
+ // Rows must be pre-formatted display strings keyed by their (already localized)
31
+ // column header. Each cell is written verbatim so the CSV matches the on-screen
32
+ // table exactly; null/undefined become an empty cell rather than the text "null".
33
+ export const exportTableToCSV = (rows: Record<string, string>[]) => {
26
34
  if (!rows || rows.length === 0) {
27
- console.warn('No data to export');
28
35
  return;
29
36
  }
30
37
 
31
- const excludedFields = ['Action', 'data_elements'];
32
- const headers = Array.from(new Set(rows.flatMap((r) => Object.keys(r))))
33
- .filter((key) => !excludedFields.includes(key))
34
- .map((key) => key.toUpperCase())
35
- .join(',');
38
+ const escape = (value: string | null | undefined) => {
39
+ const raw = value ?? '';
40
+ // Defuse spreadsheet formula injection by prefixing risky leading chars.
41
+ const safe =
42
+ raw.length > 0 && CSV_FORMULA_TRIGGERS.includes(raw[0]) ? `'${raw}` : raw;
43
+ return `"${safe.replace(/"/g, '""')}"`;
44
+ };
36
45
 
37
- const csvRows = rows.map((row) => {
38
- return Object.entries(row)
39
- .filter(([key]) => !excludedFields.includes(key))
40
- .map(([key, value]) => {
41
- let formattedValue = value;
42
- if ((key === 'given_date' || key === 'valid_till') && value) {
43
- formattedValue = formatDateTime(String(value));
44
- }
45
- return `"${String(formattedValue).replace(/"/g, '""')}"`;
46
- })
47
- .join(',');
48
- });
46
+ const columns = Object.keys(rows[0]);
47
+ const headers = columns.map(escape).join(',');
48
+ const csvRows = rows.map((row) =>
49
+ columns.map((col) => escape(row[col])).join(',')
50
+ );
49
51
 
50
52
  const csvContent = [headers, ...csvRows].join('\n');
51
53
 
52
54
  try {
53
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
55
+ const blob = new Blob([UTF8_BOM, csvContent], { type: 'text/csv;charset=utf-8;' });
54
56
  const url = URL.createObjectURL(blob);
55
57
  const link = document.createElement('a');
56
58
  link.href = url;
@@ -167,21 +167,34 @@ export const DOCUMENT_REQUEST_STATUS_LABEL: Record<DocumentRequestStatus, Docume
167
167
  replacement_requested: { labelKey: 'docStatusReplacementRequested', color: STATUS_COLORS.warning.color },
168
168
  };
169
169
 
170
- // Activity columns constants
171
- export const ACTIVITY_COLUMN_SIZES = {
172
- TITLE: 220,
173
- ACTIVITY_TYPE: 160,
174
- DESCRIPTION: 320,
175
- TIMESTAMP: 200,
176
- } as const;
177
-
178
- export const ACTIVITY_DESCRIPTION_TRUNCATE_LENGTH = 100;
179
-
180
170
  export const ACTIVITY_DATE_FORMATS = {
181
171
  DISPLAY: 'MMM D, YYYY [at] h:mm A',
182
172
  TOOLTIP: 'MMMM D, YYYY [at] h:mm:ss A',
173
+ DATE: 'MMM D, YYYY',
174
+ TIME: 'h:mm A',
183
175
  } as const;
184
176
 
177
+ export const ACTIVITY_PAGE_SIZE = 10;
178
+
179
+
180
+ // ─── Token refresh ─────────────────────────────────────────────────────────
181
+
182
+ // Backoff window for *transient* OTP-refresh failures (network / 5xx). After a
183
+ // transient failure we wait at least MIN before the next attempt and grow the
184
+ // window exponentially up to MAX, so a flaky connection can't turn the
185
+ // proactive-refresh path into a request storm (incident 2026-06-22). Terminal
186
+ // failures (expired / already-used token) are handled by the circuit breaker in
187
+ // lib/utils.ts and are not retried at all.
188
+ export const MIN_REFRESH_BACKOFF_MS = 30_000; // 30s
189
+ export const MAX_REFRESH_BACKOFF_MS = 300_000; // 5m
190
+
191
+ // Where the privacy center persists the refresh token: localStorage (origin-
192
+ // scoped, no path matching) under a Redacto-namespaced key, so it is readable
193
+ // regardless of the host app's basePath and never collides with a host's own
194
+ // `refresh_token` cookie. LEGACY_REFRESH_TOKEN_COOKIE is the cookie name older
195
+ // SDK versions wrote (at path=/privacy-center); only that path is ever cleared.
196
+ export const REFRESH_TOKEN_STORAGE_KEY = 'redacto_pc_refresh_token';
197
+ export const LEGACY_REFRESH_TOKEN_COOKIE = 'refresh_token';
185
198
 
186
199
  // ─── Notification Hub ──────────────────────────────────────────────────────
187
200
 
@@ -9,6 +9,7 @@ import {
9
9
  } from './api';
10
10
  import { useBaseUrl } from '../context/BaseUrlContext';
11
11
  import { useCurrentLanguage } from './useCurrentLanguage';
12
+ import { isTerminalRefreshError } from '../api/fetcher';
12
13
  import type { SendCaseMessageInput, SubmitDocumentForRequestInput } from './types';
13
14
 
14
15
  export const useCaseHistory = (
@@ -33,7 +34,10 @@ export const useCaseHistory = (
33
34
  return response?.data || [];
34
35
  },
35
36
  enabled: !!organizationId && !!workspaceId && !!contact && !!consentClient,
36
- retry: 1,
37
+ // Don't retry terminal auth failures — retrying a dead token only adds load
38
+ // (incident 2026-06-22). Transient failures get a single backed-off retry.
39
+ retry: (failureCount, error) => !isTerminalRefreshError(error) && failureCount < 1,
40
+ retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
37
41
  });
38
42
  };
39
43
 
@@ -67,7 +71,10 @@ export const useCaseMessages = (
67
71
  return ta - tb;
68
72
  }),
69
73
  enabled: !!organizationId && !!workspaceId && !!caseUuid && !!consentClient,
70
- retry: 1,
74
+ // Don't retry terminal auth failures — retrying a dead token only adds load
75
+ // (incident 2026-06-22). Transient failures get a single backed-off retry.
76
+ retry: (failureCount, error) => !isTerminalRefreshError(error) && failureCount < 1,
77
+ retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
71
78
  });
72
79
  };
73
80
 
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ensureValidToken, isRefreshBlocked, resetRefreshCircuit } from './utils';
3
+ import { ApiError } from '../api/fetcher';
4
+
5
+ /**
6
+ * Regression tests for the OTP refresh circuit breaker.
7
+ *
8
+ * Production incident 2026-06-22: an embedded Privacy Center retry-looped a dead
9
+ * refresh token, sending ~9× the normal volume of `POST /otp/refresh` (all fast
10
+ * terminal rejections) and spiking consent-server CPU. ensureValidToken is the
11
+ * single chokepoint for every refresh — these tests pin the guarantee that a
12
+ * terminally-failed token is attempted exactly once until the user re-authenticates,
13
+ * and that transient failures back off instead of hammering.
14
+ */
15
+
16
+ // Minimal JWT with a chosen `exp`. The signature is irrelevant — the SDK only
17
+ // base64-decodes the payload to read `exp`.
18
+ const base64url = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=+$/, '');
19
+ const makeJwt = (expSeconds: number) =>
20
+ `${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url({ exp: expSeconds })}.sig`;
21
+
22
+ // `exp` in 1970 — always treated as expired, so ensureValidToken always takes the
23
+ // refresh path regardless of wall-clock / fake timers.
24
+ const EXPIRED_ACCESS_TOKEN = makeJwt(1_000);
25
+ const ORG = 'org-uuid';
26
+ const WS = 'ws-uuid';
27
+
28
+ const makeConsentClient = (
29
+ refreshOtpToken: (orgId: string, wsId: string, refreshToken: string) => Promise<unknown>
30
+ ) => ({ refreshOtpToken: vi.fn(refreshOtpToken) }) as any;
31
+
32
+ const makeAuthContext = (refreshToken: string) => ({
33
+ accessToken: EXPIRED_ACCESS_TOKEN,
34
+ refreshToken,
35
+ // Return false so the SDK suppresses navigation (jsdom can't navigate). The
36
+ // circuit breaker trips before onError is consulted, which is what we assert.
37
+ onError: vi.fn(() => false),
38
+ updateTokens: vi.fn(),
39
+ });
40
+
41
+ describe('ensureValidToken — OTP refresh circuit breaker (incident 2026-06-22)', () => {
42
+ beforeEach(() => {
43
+ resetRefreshCircuit();
44
+ sessionStorage.clear();
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ });
51
+
52
+ it('attempts a terminally-failed refresh token exactly once across many calls', async () => {
53
+ const client = makeConsentClient(() =>
54
+ Promise.reject(new ApiError('Token verification error: Refresh token has expired', { status: 401 }))
55
+ );
56
+ const authContext = makeAuthContext('dead-refresh-token');
57
+
58
+ // Simulate the 3 pollers + every API call + render-driven effects all hitting
59
+ // the path while the token is dead.
60
+ const results: Array<string | null> = [];
61
+ for (let i = 0; i < 25; i++) {
62
+ results.push(await ensureValidToken(ORG, WS, undefined, authContext, client));
63
+ }
64
+
65
+ expect(results.every((r) => r === null)).toBe(true);
66
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
67
+ expect(isRefreshBlocked(authContext)).toBe(true);
68
+ });
69
+
70
+ it('treats "not found / already used" (T0) as terminal, not retryable', async () => {
71
+ const client = makeConsentClient(() =>
72
+ Promise.reject(new ApiError('Refresh token not found or already used', { status: 401 }))
73
+ );
74
+ const authContext = makeAuthContext('already-used-token');
75
+
76
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
77
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
78
+
79
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('resumes refreshing once a new refresh token value appears', async () => {
83
+ const validAccess = makeJwt(Math.floor(Date.now() / 1000) + 3600);
84
+ const client = makeConsentClient((_org, _ws, token) =>
85
+ token === 'dead-refresh-token'
86
+ ? Promise.reject(new ApiError('Refresh token has expired', { status: 401 }))
87
+ : Promise.resolve({ access_token: validAccess, refresh_token: 'rotated' })
88
+ );
89
+
90
+ const deadCtx = makeAuthContext('dead-refresh-token');
91
+ await ensureValidToken(ORG, WS, undefined, deadCtx, client);
92
+ await ensureValidToken(ORG, WS, undefined, deadCtx, client);
93
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1); // breaker held the loop
94
+
95
+ // User re-authenticated → a *different* refresh token value re-arms the breaker.
96
+ const freshCtx = makeAuthContext('fresh-refresh-token');
97
+ const token = await ensureValidToken(ORG, WS, undefined, freshCtx, client);
98
+
99
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(2);
100
+ expect(token).toBe(validAccess);
101
+ expect(isRefreshBlocked(freshCtx)).toBe(false);
102
+ });
103
+
104
+ it('backs off after a transient failure instead of retrying immediately', async () => {
105
+ vi.useFakeTimers();
106
+ vi.setSystemTime(new Date('2026-06-22T11:00:00Z'));
107
+
108
+ const client = makeConsentClient(() =>
109
+ Promise.reject(new ApiError('Service Unavailable', { status: 503 }))
110
+ );
111
+ const authContext = makeAuthContext('flaky-refresh-token');
112
+
113
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
114
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
115
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
116
+
117
+ // One attempt, then the backoff window suppresses the rest (no token-marking —
118
+ // a 5xx is transient, so the token is NOT permanently dead).
119
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
120
+ expect(isRefreshBlocked(authContext)).toBe(true);
121
+
122
+ // Once the backoff window elapses, a later call is allowed to retry.
123
+ vi.advanceTimersByTime(31_000); // > MIN_REFRESH_BACKOFF_MS (30s)
124
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
125
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(2);
126
+ });
127
+ });