@redacto.io/consent-sdk-react 9.4.0-beta.0 → 9.5.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +13 -0
- package/dist/privacy-center.d.mts +1 -0
- package/dist/privacy-center.d.ts +1 -0
- package/dist/privacy-center.js +1516 -1378
- package/dist/privacy-center.mjs +1199 -1061
- package/package.json +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +6 -4
- 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/PrivacyCenterSessionExpired.tsx +97 -0
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +29 -6
- 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/utils.session-expired.test.ts +109 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +83 -79
- 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 +2 -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/tests/resolveContactFromJwt.test.ts +44 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import type { OrganizationDetail } from './lib/types';
|
|
|
5
5
|
import {
|
|
6
6
|
checkAndRefreshAuth,
|
|
7
7
|
decodeJwtToken,
|
|
8
|
+
resolveContactFromJwt,
|
|
8
9
|
} from './lib/utils';
|
|
9
10
|
import { useBaseUrl } from './context/BaseUrlContext';
|
|
10
11
|
import { useAuth } from './context/AuthContext';
|
|
@@ -56,10 +57,11 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
|
|
|
56
57
|
if (workspaceId) sessionStorage.setItem('pc_workspace_id', workspaceId);
|
|
57
58
|
if (orgSlug) sessionStorage.setItem('pc_org_slug', orgSlug);
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Resolve the contact identifier (email, or mobile/org_user_id for
|
|
61
|
+
// mobile-only logins) for the sidebar and consent manager.
|
|
62
|
+
const contact = resolveContactFromJwt(decoded);
|
|
63
|
+
if (contact) {
|
|
64
|
+
sessionStorage.setItem('email', contact);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
}
|
|
@@ -22,6 +22,7 @@ import { PageHeader } from './PageHeader';
|
|
|
22
22
|
import { PCLoader } from '../../ui';
|
|
23
23
|
import { useDataAvailability } from '../../context/DataAvailabilityContext';
|
|
24
24
|
import { PrivacyCenterEmptyState } from '../PrivacyCenterEmptyState';
|
|
25
|
+
import { PrivacyCenterSessionExpired } from '../PrivacyCenterSessionExpired';
|
|
25
26
|
import type { PrivacyCenterLayoutProps } from '../types';
|
|
26
27
|
|
|
27
28
|
export default function AppShell({
|
|
@@ -149,6 +150,10 @@ export default function AppShell({
|
|
|
149
150
|
>
|
|
150
151
|
<PCLoader variant="spinner" color="info" size="lg" />
|
|
151
152
|
</div>
|
|
153
|
+
) : status === 'session-expired' ? (
|
|
154
|
+
<div style={{ flex: 1, minWidth: 0, width: '100%' }}>
|
|
155
|
+
<PrivacyCenterSessionExpired onBack={onBack} />
|
|
156
|
+
</div>
|
|
152
157
|
) : status === 'empty' ? (
|
|
153
158
|
<div style={{ flex: 1, minWidth: 0, width: '100%' }}>
|
|
154
159
|
<PrivacyCenterEmptyState />
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { ArrowLeft, ChevronDown, LogOut } from 'lucide-react';
|
|
1
|
+
import { useEffect, useState, type CSSProperties } from 'react';
|
|
2
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
3
|
import { PCAvatar, PCPopover } from '../../ui';
|
|
5
4
|
import { useBranding } from '../../context/BrandingContext';
|
|
6
5
|
import { useOrganization } from '../../context/OrganizationContext';
|
|
7
|
-
import { getVar
|
|
6
|
+
import { getVar } from '../../lib/utils';
|
|
7
|
+
import { usePrivacyCenterExitAction } from './usePrivacyCenterExitAction';
|
|
8
8
|
|
|
9
9
|
type UserMenuProps = {
|
|
10
10
|
onBack?: 'back' | 'signout';
|
|
@@ -30,7 +30,6 @@ function initialsFromEmail(email: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function UserMenu({ onBack }: UserMenuProps) {
|
|
33
|
-
const { t } = useTranslation();
|
|
34
33
|
const branding = useBranding();
|
|
35
34
|
const { organization } = useOrganization();
|
|
36
35
|
const [email, setEmail] = useState('');
|
|
@@ -39,17 +38,8 @@ export function UserMenu({ onBack }: UserMenuProps) {
|
|
|
39
38
|
setEmail(readEmail());
|
|
40
39
|
}, []);
|
|
41
40
|
|
|
42
|
-
const handleAction
|
|
43
|
-
|
|
44
|
-
window.history.back();
|
|
45
|
-
} else if (onBack === 'signout') {
|
|
46
|
-
signOutPrivacyCenter();
|
|
47
|
-
window.history.back();
|
|
48
|
-
}
|
|
49
|
-
}, [onBack]);
|
|
50
|
-
|
|
51
|
-
const actionLabel = onBack === 'back' ? t('back') : t('signOut');
|
|
52
|
-
const ActionIcon = onBack === 'back' ? ArrowLeft : LogOut;
|
|
41
|
+
const { handleAction, label: actionLabel, Icon: ActionIcon } =
|
|
42
|
+
usePrivacyCenterExitAction(onBack);
|
|
53
43
|
|
|
54
44
|
const displayName = branding?.brand_name || organization?.name || '';
|
|
55
45
|
const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { ArrowLeft, LogOut, type LucideIcon } from 'lucide-react';
|
|
4
|
+
import { signOutPrivacyCenter } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared "exit the Privacy Center" action, driven by the consumer's
|
|
8
|
+
* `onBack` prop ('back' | 'signout'). Centralised so the top-bar UserMenu item
|
|
9
|
+
* and the session-expired screen's CTA stay in lockstep — same label, icon, and
|
|
10
|
+
* behaviour. `enabled` is false when the consumer passed no `onBack`, so callers
|
|
11
|
+
* render no affordance (and nothing ever fires automatically).
|
|
12
|
+
*/
|
|
13
|
+
export function usePrivacyCenterExitAction(onBack?: 'back' | 'signout'): {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
handleAction: () => void;
|
|
16
|
+
label: string;
|
|
17
|
+
Icon: LucideIcon;
|
|
18
|
+
} {
|
|
19
|
+
const { t } = useTranslation();
|
|
20
|
+
|
|
21
|
+
const handleAction = useCallback(() => {
|
|
22
|
+
if (onBack === 'back') {
|
|
23
|
+
window.history.back();
|
|
24
|
+
} else if (onBack === 'signout') {
|
|
25
|
+
signOutPrivacyCenter();
|
|
26
|
+
window.history.back();
|
|
27
|
+
}
|
|
28
|
+
}, [onBack]);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
enabled: !!onBack,
|
|
32
|
+
handleAction,
|
|
33
|
+
label: onBack === 'back' ? t('back') : t('signOut'),
|
|
34
|
+
Icon: onBack === 'back' ? ArrowLeft : LogOut,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -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,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, resolveContactFromJwt, setSessionExpiredListener } from '../lib/utils';
|
|
3
3
|
import type { AuthContextValue } from './types';
|
|
4
4
|
|
|
5
5
|
export type { AuthContextValue } from './types';
|
|
@@ -25,12 +25,14 @@ const syncJwtDataToSessionStorage = (accessToken: string): void => {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
|
|
28
|
+
// Resolve the user's contact identifier (email, or mobile/org_user_id for
|
|
29
|
+
// mobile-only logins) so case + consent-management actions have a subject.
|
|
30
|
+
// Falls back to previously stored values when the token omits identifiers.
|
|
31
|
+
const contact = resolveContactFromJwt(decoded)
|
|
30
32
|
|| sessionStorage.getItem('email')
|
|
31
33
|
|| (typeof localStorage !== 'undefined' ? localStorage.getItem('userEmail') : null);
|
|
32
|
-
if (
|
|
33
|
-
sessionStorage.setItem('email',
|
|
34
|
+
if (contact && typeof contact === 'string') {
|
|
35
|
+
sessionStorage.setItem('email', contact);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
// Extract organization UUID
|
|
@@ -80,6 +82,21 @@ export const AuthProvider = ({
|
|
|
80
82
|
return initialRefreshToken;
|
|
81
83
|
});
|
|
82
84
|
|
|
85
|
+
// Terminal "session expired" flag. Set by utils' refresh routines (via the
|
|
86
|
+
// module listener registered below) when the refresh token is dead; reset when
|
|
87
|
+
// a new refresh token appears (re-auth) so the breaker and the screen recover
|
|
88
|
+
// together. Drives the shared session-expired screen and gates all fetching.
|
|
89
|
+
const [sessionExpired, setSessionExpired] = useState(false);
|
|
90
|
+
const markSessionExpired = useCallback(() => setSessionExpired(true), []);
|
|
91
|
+
|
|
92
|
+
// Bridge the module-level refresh routines to React state. utils calls the
|
|
93
|
+
// registered listener on terminal failure; this is the same single-instance
|
|
94
|
+
// singleton model as the OTP refresh circuit breaker.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setSessionExpiredListener(markSessionExpired);
|
|
97
|
+
return () => setSessionExpiredListener(null);
|
|
98
|
+
}, [markSessionExpired]);
|
|
99
|
+
|
|
83
100
|
// Re-sync when props change (for token refresh scenarios)
|
|
84
101
|
useEffect(() => {
|
|
85
102
|
if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
|
|
@@ -94,6 +111,9 @@ export const AuthProvider = ({
|
|
|
94
111
|
useEffect(() => {
|
|
95
112
|
if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
|
|
96
113
|
setRefreshToken(initialRefreshToken);
|
|
114
|
+
// A fresh refresh token means the user re-authenticated — clear the
|
|
115
|
+
// terminal state (the breaker auto-re-arms on the next ensureValidToken).
|
|
116
|
+
setSessionExpired(false);
|
|
97
117
|
if (typeof window !== 'undefined') {
|
|
98
118
|
setRefreshTokenCookie(initialRefreshToken);
|
|
99
119
|
}
|
|
@@ -106,6 +126,8 @@ export const AuthProvider = ({
|
|
|
106
126
|
if (newRefreshToken) {
|
|
107
127
|
setRefreshToken(newRefreshToken);
|
|
108
128
|
}
|
|
129
|
+
// A successful refresh clears any prior terminal state.
|
|
130
|
+
setSessionExpired(false);
|
|
109
131
|
// Also update storage
|
|
110
132
|
if (typeof window !== 'undefined') {
|
|
111
133
|
sessionStorage.setItem('access_token', newAccessToken);
|
|
@@ -123,7 +145,8 @@ export const AuthProvider = ({
|
|
|
123
145
|
refreshToken,
|
|
124
146
|
onError,
|
|
125
147
|
updateTokens,
|
|
126
|
-
|
|
148
|
+
sessionExpired,
|
|
149
|
+
}), [accessToken, refreshToken, onError, updateTokens, sessionExpired]);
|
|
127
150
|
|
|
128
151
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
129
152
|
};
|
|
@@ -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<{
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ensureValidToken,
|
|
4
|
+
resetRefreshCircuit,
|
|
5
|
+
setSessionExpiredListener,
|
|
6
|
+
} from './utils';
|
|
7
|
+
import { ApiError } from '../api/fetcher';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for the terminal "session expired" signal.
|
|
11
|
+
*
|
|
12
|
+
* When the refresh token is expired/invalid, the refresh endpoint must be hit at
|
|
13
|
+
* most once (the circuit breaker) AND the React tree must be told the session is
|
|
14
|
+
* over exactly once (so it can show the terminal screen and stop all work). A
|
|
15
|
+
* transient (5xx/network) failure must NOT raise the terminal signal — those are
|
|
16
|
+
* retryable and the user is still signed in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Minimal JWT with a chosen `exp` (only the base64 payload is read).
|
|
20
|
+
const base64url = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=+$/, '');
|
|
21
|
+
const makeJwt = (expSeconds: number) =>
|
|
22
|
+
`${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url({ exp: expSeconds })}.sig`;
|
|
23
|
+
|
|
24
|
+
// `exp` in 1970 → always expired, so ensureValidToken always takes the refresh path.
|
|
25
|
+
const EXPIRED_ACCESS_TOKEN = makeJwt(1_000);
|
|
26
|
+
const ORG = 'org-uuid';
|
|
27
|
+
const WS = 'ws-uuid';
|
|
28
|
+
|
|
29
|
+
const makeConsentClient = (
|
|
30
|
+
refreshOtpToken: (orgId: string, wsId: string, refreshToken: string) => Promise<unknown>,
|
|
31
|
+
) => ({ refreshOtpToken: vi.fn(refreshOtpToken) }) as any;
|
|
32
|
+
|
|
33
|
+
const makeAuthContext = (refreshToken: string) => ({
|
|
34
|
+
accessToken: EXPIRED_ACCESS_TOKEN,
|
|
35
|
+
refreshToken,
|
|
36
|
+
// Return false (host handles its own navigation). The terminal signal must fire
|
|
37
|
+
// regardless of this return value — that is the behaviour under test.
|
|
38
|
+
onError: vi.fn(() => false),
|
|
39
|
+
updateTokens: vi.fn(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('ensureValidToken — terminal session-expired signal', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
resetRefreshCircuit();
|
|
45
|
+
setSessionExpiredListener(null);
|
|
46
|
+
sessionStorage.clear();
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
setSessionExpiredListener(null);
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('fires the session-expired listener exactly once on a terminal failure', async () => {
|
|
56
|
+
const onExpired = vi.fn();
|
|
57
|
+
setSessionExpiredListener(onExpired);
|
|
58
|
+
|
|
59
|
+
const client = makeConsentClient(() =>
|
|
60
|
+
Promise.reject(new ApiError('Refresh token has expired', { status: 401 })),
|
|
61
|
+
);
|
|
62
|
+
const authContext = makeAuthContext('dead-refresh-token');
|
|
63
|
+
|
|
64
|
+
// Many triggers (pollers + API calls + effects) against the same dead token.
|
|
65
|
+
const results: Array<string | null> = [];
|
|
66
|
+
for (let i = 0; i < 10; i++) {
|
|
67
|
+
results.push(await ensureValidToken(ORG, WS, undefined, authContext, client));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expect(results.every((r) => r === null)).toBe(true);
|
|
71
|
+
// Breaker: the dead refresh token is submitted at most once.
|
|
72
|
+
expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
|
|
73
|
+
// Terminal signal raised once, even though onError returned false.
|
|
74
|
+
expect(onExpired).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does NOT fire the listener for a transient (5xx) failure', async () => {
|
|
78
|
+
vi.useFakeTimers();
|
|
79
|
+
vi.setSystemTime(new Date('2026-06-22T11:00:00Z'));
|
|
80
|
+
|
|
81
|
+
const onExpired = vi.fn();
|
|
82
|
+
setSessionExpiredListener(onExpired);
|
|
83
|
+
|
|
84
|
+
const client = makeConsentClient(() =>
|
|
85
|
+
Promise.reject(new ApiError('Service Unavailable', { status: 503 })),
|
|
86
|
+
);
|
|
87
|
+
const authContext = makeAuthContext('flaky-refresh-token');
|
|
88
|
+
|
|
89
|
+
await ensureValidToken(ORG, WS, undefined, authContext, client);
|
|
90
|
+
await ensureValidToken(ORG, WS, undefined, authContext, client);
|
|
91
|
+
|
|
92
|
+
// One attempt then backoff suppresses the rest — and the session is NOT
|
|
93
|
+
// declared terminally expired (the token may still be valid).
|
|
94
|
+
expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(onExpired).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('tolerates a terminal failure with no listener registered (no throw)', async () => {
|
|
99
|
+
const client = makeConsentClient(() =>
|
|
100
|
+
Promise.reject(new ApiError('Refresh token has expired', { status: 401 })),
|
|
101
|
+
);
|
|
102
|
+
const authContext = makeAuthContext('dead-refresh-token');
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
ensureValidToken(ORG, WS, undefined, authContext, client),
|
|
106
|
+
).resolves.toBeNull();
|
|
107
|
+
expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
});
|