@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,239 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import {
|
|
5
|
+
ShieldCheck,
|
|
6
|
+
FileText,
|
|
7
|
+
Eye,
|
|
8
|
+
MessageSquareWarning,
|
|
9
|
+
Activity as ActivityIcon,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
import { PCText, PCTooltip, PCBadge } from '../ui';
|
|
12
|
+
import { getVar, formatActivityType } from '../lib/utils';
|
|
13
|
+
import { ACTIVITY_DATE_FORMATS } from '../lib/constants';
|
|
14
|
+
import {
|
|
15
|
+
MARKER_TINT,
|
|
16
|
+
TIMELINE_COL_GAP,
|
|
17
|
+
TIMELINE_MARKER_COL,
|
|
18
|
+
TIMELINE_MARKER_SIZE,
|
|
19
|
+
TIMELINE_RAIL_LEFT,
|
|
20
|
+
TIMELINE_TIME_COL,
|
|
21
|
+
} from './constant';
|
|
22
|
+
import { formatTimeAgo } from './NotificationHub/utils';
|
|
23
|
+
import type {
|
|
24
|
+
ActivityCategory,
|
|
25
|
+
ActivityDateGroup,
|
|
26
|
+
ActivityTimelineProps,
|
|
27
|
+
TimelineRowProps,
|
|
28
|
+
} from './types';
|
|
29
|
+
|
|
30
|
+
// Colour by category (matches the reference): Consent = green, Case = blue, etc.
|
|
31
|
+
// Same colour regardless of grant/withdraw — deliberately not getActivityTypeStatus.
|
|
32
|
+
const getActivityCategory = (activityType: string): ActivityCategory => {
|
|
33
|
+
const type = activityType.toLowerCase();
|
|
34
|
+
if (type.includes('consent') || type.includes('grant')) return { variant: 'success', Icon: ShieldCheck };
|
|
35
|
+
if (type.includes('case')) return { variant: 'info', Icon: FileText };
|
|
36
|
+
if (type.includes('access') || type.includes('view')) return { variant: 'info', Icon: Eye };
|
|
37
|
+
if (type.includes('grievance') || type.includes('request')) return { variant: 'warning', Icon: MessageSquareWarning };
|
|
38
|
+
return { variant: 'secondary', Icon: ActivityIcon };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default function ActivityTimeline({
|
|
42
|
+
activities,
|
|
43
|
+
}: ActivityTimelineProps) {
|
|
44
|
+
const { t } = useTranslation();
|
|
45
|
+
|
|
46
|
+
// Group by calendar day, preserving the API's newest-first ordering.
|
|
47
|
+
const groups = useMemo<ActivityDateGroup[]>(() => {
|
|
48
|
+
const ordered: ActivityDateGroup[] = [];
|
|
49
|
+
const byKey = new Map<string, ActivityDateGroup>();
|
|
50
|
+
|
|
51
|
+
for (const activity of activities) {
|
|
52
|
+
const date = dayjs(activity.timestamp);
|
|
53
|
+
const key = date.format('YYYY-MM-DD');
|
|
54
|
+
let group = byKey.get(key);
|
|
55
|
+
if (!group) {
|
|
56
|
+
group = {
|
|
57
|
+
key,
|
|
58
|
+
label: date.format(ACTIVITY_DATE_FORMATS.DATE),
|
|
59
|
+
newest: activity.timestamp,
|
|
60
|
+
items: [],
|
|
61
|
+
};
|
|
62
|
+
byKey.set(key, group);
|
|
63
|
+
ordered.push(group);
|
|
64
|
+
}
|
|
65
|
+
group.items.push(activity);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ordered;
|
|
69
|
+
}, [activities]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div style={{ width: '100%' }}>
|
|
73
|
+
{groups.map((group, groupIndex) => (
|
|
74
|
+
<div
|
|
75
|
+
key={group.key}
|
|
76
|
+
style={{ marginTop: groupIndex === 0 ? 0 : getVar('--pc-spacing-lg') }}
|
|
77
|
+
>
|
|
78
|
+
{/* Date header: explicit date + relative hint */}
|
|
79
|
+
<div style={{
|
|
80
|
+
display: 'flex',
|
|
81
|
+
alignItems: 'baseline',
|
|
82
|
+
gap: getVar('--pc-spacing-sm'),
|
|
83
|
+
marginBottom: getVar('--pc-spacing-md'),
|
|
84
|
+
paddingLeft: '0.125rem',
|
|
85
|
+
}}>
|
|
86
|
+
<PCText
|
|
87
|
+
as="span"
|
|
88
|
+
size="xs"
|
|
89
|
+
weight="bold"
|
|
90
|
+
color="primary"
|
|
91
|
+
style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}
|
|
92
|
+
>
|
|
93
|
+
{group.label}
|
|
94
|
+
</PCText>
|
|
95
|
+
<PCText as="span" size="xs" color="tertiary">
|
|
96
|
+
{formatTimeAgo(group.newest, t)}
|
|
97
|
+
</PCText>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Rows + continuous rail */}
|
|
101
|
+
<div style={{ position: 'relative' }}>
|
|
102
|
+
<span
|
|
103
|
+
aria-hidden
|
|
104
|
+
style={{
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
top: TIMELINE_MARKER_SIZE / 2,
|
|
107
|
+
bottom: TIMELINE_MARKER_SIZE / 2,
|
|
108
|
+
left: TIMELINE_RAIL_LEFT,
|
|
109
|
+
width: 2,
|
|
110
|
+
marginLeft: -1,
|
|
111
|
+
background: `linear-gradient(180deg, transparent 0, ${getVar('--pc-border-color')} 12px, ${getVar('--pc-border-color')} calc(100% - 12px), transparent 100%)`,
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
{group.items.map((activity, index) => (
|
|
115
|
+
<TimelineRow
|
|
116
|
+
key={`${group.key}-${index}`}
|
|
117
|
+
activity={activity}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function TimelineRow({
|
|
128
|
+
activity,
|
|
129
|
+
}: TimelineRowProps) {
|
|
130
|
+
const { t } = useTranslation();
|
|
131
|
+
|
|
132
|
+
const { variant, Icon } = getActivityCategory(activity.activity_type);
|
|
133
|
+
const tint = MARKER_TINT[variant];
|
|
134
|
+
|
|
135
|
+
const title = activity.title_display || activity.title;
|
|
136
|
+
const description = activity.description_display || activity.description;
|
|
137
|
+
const typeLabel = activity.activity_type_display || formatActivityType(activity.activity_type);
|
|
138
|
+
|
|
139
|
+
const date = dayjs(activity.timestamp);
|
|
140
|
+
const clock = date.format(ACTIVITY_DATE_FORMATS.TIME);
|
|
141
|
+
const relative = formatTimeAgo(activity.timestamp, t);
|
|
142
|
+
const absolute = date.format(ACTIVITY_DATE_FORMATS.TOOLTIP);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className="pc-timeline-item pc-timeline-row"
|
|
147
|
+
style={{
|
|
148
|
+
position: 'relative',
|
|
149
|
+
display: 'grid',
|
|
150
|
+
gridTemplateColumns: `${TIMELINE_TIME_COL} ${TIMELINE_MARKER_COL} 1fr`,
|
|
151
|
+
columnGap: `${TIMELINE_COL_GAP}px`,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{/* Time column */}
|
|
155
|
+
<PCTooltip content={absolute} placement="top">
|
|
156
|
+
<div style={{
|
|
157
|
+
display: 'flex',
|
|
158
|
+
flexDirection: 'column',
|
|
159
|
+
alignItems: 'flex-end',
|
|
160
|
+
textAlign: 'right',
|
|
161
|
+
paddingTop: '0.5rem',
|
|
162
|
+
cursor: 'help',
|
|
163
|
+
}}>
|
|
164
|
+
<PCText as="span" size="sm" weight="medium" color="secondary" style={{ whiteSpace: 'nowrap', lineHeight: 1.3 }}>
|
|
165
|
+
{clock}
|
|
166
|
+
</PCText>
|
|
167
|
+
<PCText as="span" size="xs" color="tertiary" style={{ whiteSpace: 'nowrap', lineHeight: 1.3 }}>
|
|
168
|
+
{relative}
|
|
169
|
+
</PCText>
|
|
170
|
+
</div>
|
|
171
|
+
</PCTooltip>
|
|
172
|
+
|
|
173
|
+
{/* Marker on the rail */}
|
|
174
|
+
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: '0.5rem' }}>
|
|
175
|
+
<div
|
|
176
|
+
style={{
|
|
177
|
+
position: 'relative',
|
|
178
|
+
zIndex: 1,
|
|
179
|
+
width: TIMELINE_MARKER_SIZE,
|
|
180
|
+
height: TIMELINE_MARKER_SIZE,
|
|
181
|
+
borderRadius: '9999px',
|
|
182
|
+
display: 'flex',
|
|
183
|
+
alignItems: 'center',
|
|
184
|
+
justifyContent: 'center',
|
|
185
|
+
// Opaque base + tint overlay so the rail never shows through the disc.
|
|
186
|
+
backgroundColor: getVar('--pc-bg-primary'),
|
|
187
|
+
backgroundImage: `linear-gradient(${tint.bg}, ${tint.bg})`,
|
|
188
|
+
color: tint.color,
|
|
189
|
+
boxShadow: `0 0 0 3px ${getVar('--pc-bg-primary')}`,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<Icon size={16} strokeWidth={2.25} aria-hidden />
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Content */}
|
|
197
|
+
<div
|
|
198
|
+
className="pc-timeline-content"
|
|
199
|
+
style={{
|
|
200
|
+
minWidth: 0,
|
|
201
|
+
marginBottom: getVar('--pc-spacing-sm'),
|
|
202
|
+
padding: '0.5rem 0.625rem',
|
|
203
|
+
borderRadius: getVar('--pc-border-radius-md'),
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
207
|
+
<PCText
|
|
208
|
+
size="sm"
|
|
209
|
+
weight="semibold"
|
|
210
|
+
color="primary"
|
|
211
|
+
style={{ minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
212
|
+
>
|
|
213
|
+
{title}
|
|
214
|
+
</PCText>
|
|
215
|
+
<PCBadge variant={variant} size="sm" style={{ flexShrink: 0 }}>
|
|
216
|
+
{typeLabel}
|
|
217
|
+
</PCBadge>
|
|
218
|
+
</div>
|
|
219
|
+
{description ? (
|
|
220
|
+
<PCText
|
|
221
|
+
size="sm"
|
|
222
|
+
color="secondary"
|
|
223
|
+
title={description}
|
|
224
|
+
style={{
|
|
225
|
+
marginTop: '0.125rem',
|
|
226
|
+
lineHeight: 1.5,
|
|
227
|
+
display: '-webkit-box',
|
|
228
|
+
WebkitLineClamp: 2,
|
|
229
|
+
WebkitBoxOrient: 'vertical',
|
|
230
|
+
overflow: 'hidden',
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{description}
|
|
234
|
+
</PCText>
|
|
235
|
+
) : null}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
useSubmitDocumentForRequest,
|
|
12
12
|
} from '../../lib/queries';
|
|
13
13
|
import { STATUS_COLORS, useCaseFinalizedTooltip } from '../../lib/constants';
|
|
14
|
+
import { isRefreshBlocked } from '../../lib/utils';
|
|
14
15
|
import type { MessagesTabProps } from '../../lib/types';
|
|
15
16
|
import { MessageItem } from './MessageItem';
|
|
16
17
|
import { MessageComposer } from './MessageComposer';
|
|
@@ -40,7 +41,8 @@ export const MessagesTab = ({ caseUuid, caseStatus }: MessagesTabProps) => {
|
|
|
40
41
|
const uploadMutation = useUploadCaseDocument(organizationId, workspaceId, caseUuid, authContext);
|
|
41
42
|
const submitMutation = useSubmitDocumentForRequest(organizationId, workspaceId, caseUuid, authContext);
|
|
42
43
|
|
|
43
|
-
const
|
|
44
|
+
const isPollingPaused = useCallback(() => isRefreshBlocked(authContext), [authContext]);
|
|
45
|
+
const { scrollContainerRef, handleScroll, scrollToBottom } = useMessagePolling(refetch, messages, isPollingPaused);
|
|
44
46
|
|
|
45
47
|
const {
|
|
46
48
|
pendingUpload, setPendingUpload, submittingForRequest,
|
|
@@ -4,13 +4,20 @@ import type { CaseMessage } from '../../lib/types';
|
|
|
4
4
|
export function useMessagePolling(
|
|
5
5
|
refetch: () => void,
|
|
6
6
|
messages: CaseMessage[],
|
|
7
|
+
isPaused?: () => boolean,
|
|
7
8
|
) {
|
|
8
9
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
9
10
|
const isNearBottomRef = useRef(true);
|
|
10
11
|
const shouldAutoScrollRef = useRef(true);
|
|
12
|
+
// Latest pause predicate, read inside the interval without resetting it.
|
|
13
|
+
const isPausedRef = useRef(isPaused);
|
|
14
|
+
isPausedRef.current = isPaused;
|
|
11
15
|
|
|
12
16
|
useEffect(() => {
|
|
13
|
-
const interval = setInterval(() => {
|
|
17
|
+
const interval = setInterval(() => {
|
|
18
|
+
// Don't poll while auth is terminally failed / backing off (incident 2026-06-22).
|
|
19
|
+
if (!isPausedRef.current?.()) refetch();
|
|
20
|
+
}, 30_000);
|
|
14
21
|
return () => clearInterval(interval);
|
|
15
22
|
}, [refetch]);
|
|
16
23
|
|
|
@@ -39,6 +39,7 @@ import { ConsentManagerTableSkeleton } from './ConsentManagerTableSkeleton';
|
|
|
39
39
|
import { ModifyConsentModal } from './ModifyConsentModal';
|
|
40
40
|
import { useConsentManagerGroupedColumns } from './ConsentManagerGroupedColumns';
|
|
41
41
|
import { useConsentManagerColumns } from './ConsentManagerColumns';
|
|
42
|
+
import { getStatusLabel, getGivenDateLabel, getValidTillLabel } from './ConsentManagerCells';
|
|
42
43
|
import { ConsentManagerExpandedRow } from './ConsentManagerExpandedRow';
|
|
43
44
|
import { ConsentManagerProductCards } from './ConsentManagerProductCards';
|
|
44
45
|
import { ConsentManagerPurposeCards } from './ConsentManagerPurposeCards';
|
|
@@ -281,6 +282,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
281
282
|
acc.push({
|
|
282
283
|
...group,
|
|
283
284
|
purposes: filteredPurposes,
|
|
285
|
+
active_purposes: counts.active,
|
|
284
286
|
withdrawn_purposes: counts.withdrawn,
|
|
285
287
|
expired_purposes: counts.expired,
|
|
286
288
|
declined_purposes: counts.declined,
|
|
@@ -640,26 +642,30 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
|
|
|
640
642
|
};
|
|
641
643
|
|
|
642
644
|
const handleExport = useCallback(() => {
|
|
643
|
-
|
|
645
|
+
// Build CSV rows whose cells exactly match the on-screen table: formatted
|
|
646
|
+
// dates, translated status labels, and the "Until Withdrawal (no expiry)" /
|
|
647
|
+
// "-" valid-till labels. Keys are the localized column headers.
|
|
648
|
+
const toRow = (purpose: PurposeItem, productName: string | null) => ({
|
|
649
|
+
[t('purpose')]: purpose.name,
|
|
650
|
+
[t('descriptionLabel')]: purpose.description ?? '',
|
|
651
|
+
[t('product')]: productName ?? '-',
|
|
652
|
+
[t('status')]: getStatusLabel(String(purpose.status), t),
|
|
653
|
+
[t('givenDate')]: getGivenDateLabel(purpose.given_date),
|
|
654
|
+
[t('validTill')]: getValidTillLabel(purpose, t),
|
|
655
|
+
[t('method')]: purpose.method ?? '',
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const rows = viewMode === 'product'
|
|
644
659
|
? productTable.getRowModel().rows.flatMap((row) =>
|
|
645
|
-
row.original.purposes.map((purpose) =>
|
|
646
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
647
|
-
const { purpose_uuid, link_reason, data_elements, ...rest } = purpose;
|
|
648
|
-
return {
|
|
649
|
-
...rest,
|
|
650
|
-
product_name: row.original.product_name,
|
|
651
|
-
product_description: row.original.product_description,
|
|
652
|
-
};
|
|
653
|
-
})
|
|
660
|
+
row.original.purposes.map((purpose) => toRow(purpose, row.original.product_name))
|
|
654
661
|
)
|
|
655
|
-
: purposeTable.getRowModel().rows.map((row) =>
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
exportTableToCSV(value);
|
|
662
|
+
: purposeTable.getRowModel().rows.map((row) =>
|
|
663
|
+
toRow(row.original, row.original.product_name)
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
exportTableToCSV(rows);
|
|
661
667
|
setExportModal(false);
|
|
662
|
-
}, [viewMode, productTable, purposeTable]);
|
|
668
|
+
}, [viewMode, productTable, purposeTable, t]);
|
|
663
669
|
|
|
664
670
|
const handleLoadMorePurposes = useCallback(
|
|
665
671
|
async (group: DisplayProductGroup) => {
|
|
@@ -7,13 +7,12 @@ import { pcStyles } from '../styles/pcStyles';
|
|
|
7
7
|
import { ConsentStatusEnum } from '../lib/types';
|
|
8
8
|
import type { PurposeItem, ConsentModalRecord } from '../lib/types';
|
|
9
9
|
import { formatDateTime } from '../lib/consent-manager/utils';
|
|
10
|
+
import type { BadgeVariant } from './types';
|
|
10
11
|
|
|
11
12
|
dayjs.extend(relativeTime);
|
|
12
13
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const statusVariantMap: Record<string, StatusVariant> = {
|
|
15
|
+
const statusVariantMap: Record<string, BadgeVariant> = {
|
|
17
16
|
[ConsentStatusEnum.ACTIVE]: 'success',
|
|
18
17
|
[ConsentStatusEnum.WITHDRAWN]: 'error',
|
|
19
18
|
[ConsentStatusEnum.REVOKED]: 'error',
|
|
@@ -21,7 +20,7 @@ const statusVariantMap: Record<string, StatusVariant> = {
|
|
|
21
20
|
[ConsentStatusEnum.DECLINED]: 'warning',
|
|
22
21
|
};
|
|
23
22
|
|
|
24
|
-
export const getStatusVariant = (status: string):
|
|
23
|
+
export const getStatusVariant = (status: string): BadgeVariant =>
|
|
25
24
|
statusVariantMap[status?.toUpperCase()] ?? 'secondary';
|
|
26
25
|
|
|
27
26
|
export const getStatusLabel = (status: string, t: TFunction): string => {
|
|
@@ -34,7 +33,7 @@ export const getStatusLabel = (status: string, t: TFunction): string => {
|
|
|
34
33
|
return status;
|
|
35
34
|
};
|
|
36
35
|
|
|
37
|
-
const statusBadgeColors: Record<
|
|
36
|
+
const statusBadgeColors: Record<BadgeVariant, { bg: string; fg: string }> = {
|
|
38
37
|
success: { bg: 'rgba(16, 185, 129, 0.12)', fg: 'var(--pc-success)' },
|
|
39
38
|
error: { bg: 'rgba(239, 68, 68, 0.12)', fg: 'var(--pc-error)' },
|
|
40
39
|
warning: { bg: 'rgba(245, 158, 11, 0.12)', fg: 'var(--pc-warning)' },
|
|
@@ -77,6 +76,14 @@ export const renderStatusBadge = (status: string, t: TFunction) => {
|
|
|
77
76
|
);
|
|
78
77
|
};
|
|
79
78
|
|
|
79
|
+
// Plain-string version of the given-date cell, for reuse in CSV export etc.
|
|
80
|
+
export const getGivenDateLabel = (timestamp: string | null): string => {
|
|
81
|
+
if (!timestamp) {
|
|
82
|
+
return '-';
|
|
83
|
+
}
|
|
84
|
+
return dayjs(timestamp).format('MMM D, YYYY [at] h:mm A');
|
|
85
|
+
};
|
|
86
|
+
|
|
80
87
|
export const renderGivenDate = (
|
|
81
88
|
timestamp: string | null,
|
|
82
89
|
options?: { showTooltip?: boolean }
|
|
@@ -85,7 +92,7 @@ export const renderGivenDate = (
|
|
|
85
92
|
return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>-</PCText>;
|
|
86
93
|
}
|
|
87
94
|
const date = dayjs(timestamp);
|
|
88
|
-
const formatted =
|
|
95
|
+
const formatted = getGivenDateLabel(timestamp);
|
|
89
96
|
const relative = date.fromNow();
|
|
90
97
|
const showTooltip = options?.showTooltip ?? true;
|
|
91
98
|
|
|
@@ -116,7 +123,8 @@ export const renderGivenDate = (
|
|
|
116
123
|
);
|
|
117
124
|
};
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
// Plain-string version of the valid-till cell, for reuse in CSV export etc.
|
|
127
|
+
export const getValidTillLabel = (consent: PurposeItem, t: TFunction): string => {
|
|
120
128
|
const { status, valid_till } = consent;
|
|
121
129
|
const normalized = (typeof status === 'string' ? status : '').toUpperCase();
|
|
122
130
|
const inactiveLike =
|
|
@@ -124,14 +132,20 @@ export const renderValidTill = (consent: PurposeItem, t: TFunction) => {
|
|
|
124
132
|
normalized === ConsentStatusEnum.WITHDRAWN ||
|
|
125
133
|
normalized === ConsentStatusEnum.REVOKED;
|
|
126
134
|
if (inactiveLike) {
|
|
127
|
-
return
|
|
135
|
+
return '-';
|
|
128
136
|
}
|
|
129
137
|
if (normalized === ConsentStatusEnum.ACTIVE && (valid_till === null || valid_till === undefined)) {
|
|
130
|
-
return
|
|
138
|
+
return t('untilWithdrawalNoExpiry');
|
|
131
139
|
}
|
|
132
|
-
return
|
|
140
|
+
return valid_till ? formatDateTime(valid_till) : '-';
|
|
133
141
|
};
|
|
134
142
|
|
|
143
|
+
export const renderValidTill = (consent: PurposeItem, t: TFunction) => (
|
|
144
|
+
<PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>
|
|
145
|
+
{getValidTillLabel(consent, t)}
|
|
146
|
+
</PCText>
|
|
147
|
+
);
|
|
148
|
+
|
|
135
149
|
const methodIconMap: Record<string, JSX.Element> = {
|
|
136
150
|
MOBILE: <Smartphone size={12} style={{ color: 'var(--pc-text-secondary)' }} />,
|
|
137
151
|
WEB: <LaptopMinimal size={12} style={{ color: 'var(--pc-text-secondary)' }} />,
|
|
@@ -5,6 +5,7 @@ import { PCPopover } from '../../ui';
|
|
|
5
5
|
import { PCTooltip } from '../../ui/PCTooltip';
|
|
6
6
|
import { fetchUnreadCount, fetchNotifications } from '../../lib/api';
|
|
7
7
|
import { POLL_INTERVAL, NOTIFICATION_DROPDOWN_LIMIT } from '../../lib/constants';
|
|
8
|
+
import { isRefreshBlocked } from '../../lib/utils';
|
|
8
9
|
import { useCurrentLanguage } from '../../lib/useCurrentLanguage';
|
|
9
10
|
import type { Notification, NotificationCTA } from '../../lib/types';
|
|
10
11
|
import { NotificationDropdown } from './NotificationDropdown';
|
|
@@ -28,6 +29,10 @@ export const NotificationBell = () => {
|
|
|
28
29
|
if (!organizationId || !workspaceId) {
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
32
|
+
// Skip doomed work while auth is terminally failed / backing off (incident 2026-06-22).
|
|
33
|
+
if (isRefreshBlocked(authContext)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
31
36
|
const requestId = tenantRequestRef.current;
|
|
32
37
|
const result = await fetchUnreadCount(organizationId, workspaceId, authContext, consentClient);
|
|
33
38
|
if (requestId !== tenantRequestRef.current) {
|
|
@@ -64,6 +69,14 @@ export const NotificationBell = () => {
|
|
|
64
69
|
};
|
|
65
70
|
}, [organizationId, workspaceId]);
|
|
66
71
|
|
|
72
|
+
// Keep the interval pointed at the latest poll fn without resetting it (and
|
|
73
|
+
// re-firing an immediate poll) each time authContext identity churns — that
|
|
74
|
+
// churn was an amplifier in the 2026-06-22 refresh storm.
|
|
75
|
+
const pollUnreadCountRef = useRef(pollUnreadCount);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
pollUnreadCountRef.current = pollUnreadCount;
|
|
78
|
+
}, [pollUnreadCount]);
|
|
79
|
+
|
|
67
80
|
useEffect(() => {
|
|
68
81
|
if (!organizationId || !workspaceId) {
|
|
69
82
|
if (pollRef.current) {
|
|
@@ -72,14 +85,14 @@ export const NotificationBell = () => {
|
|
|
72
85
|
}
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
75
|
-
|
|
76
|
-
pollRef.current = setInterval(() =>
|
|
88
|
+
pollUnreadCountRef.current();
|
|
89
|
+
pollRef.current = setInterval(() => pollUnreadCountRef.current(), POLL_INTERVAL);
|
|
77
90
|
return () => {
|
|
78
91
|
if (pollRef.current) {
|
|
79
92
|
clearInterval(pollRef.current);
|
|
80
93
|
}
|
|
81
94
|
};
|
|
82
|
-
}, [
|
|
95
|
+
}, [organizationId, workspaceId]);
|
|
83
96
|
|
|
84
97
|
const onRefresh = useCallback(async () => {
|
|
85
98
|
setIsRefreshing(true);
|