@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
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|