@redacto.io/consent-sdk-react 9.3.0 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +93 -12
  4. package/dist/index.d.mts +39 -1
  5. package/dist/index.d.ts +39 -1
  6. package/dist/index.js +4454 -334
  7. package/dist/index.mjs +4454 -335
  8. package/dist/privacy-center.d.mts +5 -1
  9. package/dist/privacy-center.d.ts +5 -1
  10. package/dist/privacy-center.js +2958 -2583
  11. package/dist/privacy-center.mjs +2666 -2285
  12. package/package.json +1 -1
  13. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +847 -0
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +2133 -0
  15. package/src/RedactoNoticeAssisted/api/index.ts +269 -0
  16. package/src/RedactoNoticeAssisted/api/types.ts +180 -0
  17. package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
  18. package/src/RedactoNoticeAssisted/constants.ts +51 -0
  19. package/src/RedactoNoticeAssisted/i18n.ts +1463 -0
  20. package/src/RedactoNoticeAssisted/index.ts +1 -0
  21. package/src/RedactoNoticeAssisted/injectStyles.ts +251 -0
  22. package/src/RedactoNoticeAssisted/styles.ts +730 -0
  23. package/src/RedactoNoticeAssisted/types.ts +24 -0
  24. package/src/RedactoNoticeAssisted/useMediaQuery.ts +36 -0
  25. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +8 -0
  26. package/src/RedactoPrivacyCenter/api/actions.ts +6 -11
  27. package/src/RedactoPrivacyCenter/api/client.ts +15 -3
  28. package/src/RedactoPrivacyCenter/api/fetcher.ts +80 -1
  29. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +193 -213
  30. package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +239 -0
  31. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +5 -0
  32. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +6 -16
  33. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +36 -0
  34. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +3 -1
  35. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +8 -1
  36. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +23 -17
  37. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +24 -10
  38. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +16 -3
  39. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +97 -0
  40. package/src/RedactoPrivacyCenter/components/constant.ts +25 -0
  41. package/src/RedactoPrivacyCenter/components/types.ts +32 -1
  42. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +23 -2
  43. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +92 -0
  44. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +15 -3
  45. package/src/RedactoPrivacyCenter/context/types.ts +4 -0
  46. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +25 -23
  47. package/src/RedactoPrivacyCenter/lib/constants.ts +23 -10
  48. package/src/RedactoPrivacyCenter/lib/queries.ts +9 -2
  49. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +127 -0
  50. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +85 -0
  51. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +109 -0
  52. package/src/RedactoPrivacyCenter/lib/utils.ts +265 -139
  53. package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -0
  54. package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -0
  55. package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -0
  56. package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -0
  57. package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -0
  58. package/src/RedactoPrivacyCenter/locales/en/translation.json +5 -0
  59. package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -0
  60. package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -0
  61. package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -0
  62. package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -0
  63. package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -0
  64. package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -0
  65. package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -0
  66. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -0
  67. package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -0
  68. package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -0
  69. package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -0
  70. package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -0
  71. package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -0
  72. package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -0
  73. package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -0
  74. package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -0
  75. package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -0
  76. package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -0
  77. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +16 -0
  78. package/src/index.ts +2 -0
  79. package/src/shared/api-errors.ts +9 -0
  80. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +0 -141
@@ -0,0 +1,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 { useCallback, useEffect, useState, type CSSProperties } from 'react';
2
- import { useTranslation } from 'react-i18next';
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, signOutPrivacyCenter } from '../../lib/utils';
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 = useCallback(() => {
43
- if (onBack === 'back') {
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 { scrollContainerRef, handleScroll, scrollToBottom } = useMessagePolling(refetch, messages);
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(() => { refetch(); }, 30_000);
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
- const value = viewMode === 'product'
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
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
657
- const { purpose_uuid, link_reason, nominator, data_elements, ...rest } = row.original;
658
- return rest;
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
- type StatusVariant = 'success' | 'error' | 'warning' | 'info' | 'secondary';
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): StatusVariant =>
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<StatusVariant, { bg: string; fg: string }> = {
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 = date.format('MMM D, YYYY [at] h:mm A');
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
- export const renderValidTill = (consent: PurposeItem, t: TFunction) => {
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 <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>-</PCText>;
135
+ return '-';
128
136
  }
129
137
  if (normalized === ConsentStatusEnum.ACTIVE && (valid_till === null || valid_till === undefined)) {
130
- return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{t('untilWithdrawalNoExpiry')}</PCText>;
138
+ return t('untilWithdrawalNoExpiry');
131
139
  }
132
- return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{valid_till ? formatDateTime(valid_till) : '-'}</PCText>;
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
- pollUnreadCount();
76
- pollRef.current = setInterval(() => pollUnreadCount(), POLL_INTERVAL);
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
- }, [pollUnreadCount, organizationId, workspaceId]);
95
+ }, [organizationId, workspaceId]);
83
96
 
84
97
  const onRefresh = useCallback(async () => {
85
98
  setIsRefreshing(true);