@redacto.io/consent-sdk-react 9.3.0 → 9.4.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.
Files changed (48) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +6 -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 +4 -1
  9. package/dist/privacy-center.d.ts +4 -1
  10. package/dist/privacy-center.js +3905 -3660
  11. package/dist/privacy-center.mjs +4172 -3921
  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/CaseDetails/MessagesTab.tsx +3 -1
  32. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +8 -1
  33. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +23 -17
  34. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +24 -10
  35. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +16 -3
  36. package/src/RedactoPrivacyCenter/components/constant.ts +25 -0
  37. package/src/RedactoPrivacyCenter/components/types.ts +32 -1
  38. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +25 -23
  39. package/src/RedactoPrivacyCenter/lib/constants.ts +23 -10
  40. package/src/RedactoPrivacyCenter/lib/queries.ts +9 -2
  41. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +127 -0
  42. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +85 -0
  43. package/src/RedactoPrivacyCenter/lib/utils.ts +213 -63
  44. package/src/RedactoPrivacyCenter/locales/en/translation.json +3 -0
  45. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +16 -0
  46. package/src/index.ts +2 -0
  47. package/src/shared/api-errors.ts +9 -0
  48. 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
+ }
@@ -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);
@@ -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,14 +1,19 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { ConsentStatusEnum } from '../types';
4
- import type { PurposeItem } from '../types';
5
-
6
- type CsvRow = Partial<PurposeItem> & Record<string, unknown>;
7
4
  import { formatDateTime as formatDateTimeShared } from '../../utils/date';
8
5
 
9
6
  // Re-export shared date formatting
10
7
  export const formatDateTime = formatDateTimeShared;
11
8
 
9
+ // Leading characters that spreadsheet apps (Excel/Sheets) may interpret as a
10
+ // formula; values starting with these are prefixed with a quote to neutralize
11
+ // CSV formula injection.
12
+ const CSV_FORMULA_TRIGGERS = ['=', '+', '-', '@', '\t', '\r'];
13
+
14
+ // Byte-order mark so Excel detects UTF-8 and renders localized labels correctly.
15
+ const UTF8_BOM = '\uFEFF';
16
+
12
17
  export const useStatusOptions = () => {
13
18
  const { t } = useTranslation();
14
19
  return useMemo(
@@ -22,35 +27,32 @@ export const useStatusOptions = () => {
22
27
  );
23
28
  };
24
29
 
25
- export const exportTableToCSV = (rows: CsvRow[]) => {
30
+ // Rows must be pre-formatted display strings keyed by their (already localized)
31
+ // column header. Each cell is written verbatim so the CSV matches the on-screen
32
+ // table exactly; null/undefined become an empty cell rather than the text "null".
33
+ export const exportTableToCSV = (rows: Record<string, string>[]) => {
26
34
  if (!rows || rows.length === 0) {
27
- console.warn('No data to export');
28
35
  return;
29
36
  }
30
37
 
31
- const excludedFields = ['Action', 'data_elements'];
32
- const headers = Array.from(new Set(rows.flatMap((r) => Object.keys(r))))
33
- .filter((key) => !excludedFields.includes(key))
34
- .map((key) => key.toUpperCase())
35
- .join(',');
38
+ const escape = (value: string | null | undefined) => {
39
+ const raw = value ?? '';
40
+ // Defuse spreadsheet formula injection by prefixing risky leading chars.
41
+ const safe =
42
+ raw.length > 0 && CSV_FORMULA_TRIGGERS.includes(raw[0]) ? `'${raw}` : raw;
43
+ return `"${safe.replace(/"/g, '""')}"`;
44
+ };
36
45
 
37
- const csvRows = rows.map((row) => {
38
- return Object.entries(row)
39
- .filter(([key]) => !excludedFields.includes(key))
40
- .map(([key, value]) => {
41
- let formattedValue = value;
42
- if ((key === 'given_date' || key === 'valid_till') && value) {
43
- formattedValue = formatDateTime(String(value));
44
- }
45
- return `"${String(formattedValue).replace(/"/g, '""')}"`;
46
- })
47
- .join(',');
48
- });
46
+ const columns = Object.keys(rows[0]);
47
+ const headers = columns.map(escape).join(',');
48
+ const csvRows = rows.map((row) =>
49
+ columns.map((col) => escape(row[col])).join(',')
50
+ );
49
51
 
50
52
  const csvContent = [headers, ...csvRows].join('\n');
51
53
 
52
54
  try {
53
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
55
+ const blob = new Blob([UTF8_BOM, csvContent], { type: 'text/csv;charset=utf-8;' });
54
56
  const url = URL.createObjectURL(blob);
55
57
  const link = document.createElement('a');
56
58
  link.href = url;